DEV Community

Dekita
Dekita

Posted on Originally published at dekita-ai.pages.dev

Your agent tool guard probably fails open. One minute to tell.

A guard sits in front of a coding agent's tool call. Its job is to stop the calls you would regret: a push to main, a delete outside the workspace, a request that carries a secret to a host nobody reviewed.

You tested it. The call you aimed at it stopped. Then you shipped.

The test exercised one shape of input. Three others never ran. In every guard I have read this month, those three went through, and the guard printed an apology into a transcript nobody opens.

The three are one question asked from different sides: when the guard cannot reach a verdict, what does the tool do? You cannot answer it from inside the guard. You answer it from the contract the tool publishes, and then by making the guard say "no verdict" in the form that contract treats as a stop.

A gate is not a yes/no function

A yes/no function returns True or False. Its caller owns the decision. A guard between an agent and a tool is not that: it produces an event that the tool did not generate, and the tool continues unless the guard says otherwise.

That asymmetry is the reason to read the exit-code contract as a security document rather than clutter in a README. Writers of "0 = success, non-zero = failure" usually mean it for command-line programs whose caller will look at the code and care. In a hook system, the caller is an orchestrator whose default is to proceed. Success, failure, timeout, a process that never started: from the orchestrator's side, most of these look the same, and the same is "carry on".

So the first thing to know about any hook you run is not what your script does. It is what the orchestrator does when your script does nothing.

Shape 1: the script is not there

The hook fired. Your script did not run, because it moved, was renamed, or the settings file that named it is not the settings file the runner reads. JSON parsing failed on the first line, the except branch returned an error object that the runner did not recognise, and the call went ahead.

A guard that is not executed cannot refuse. An unexecuted guard looks exactly like no guard, in a log that records decisions.

Check: print the resolved settings path and the hook path from inside a test run, with --debug on. Do this on the machine that runs the job, not your laptop.

Shape 2: the script ran and said nothing

This one is written into the docs, which makes it easier to find and no less easy to miss.

For the Claude Code hooks system as documented on 2026-11-03, exit code 2 is the blocking error. Any other exit code, or a failure to start at all, is a non-blocking error: the action goes ahead. The reference puts it plainly, that a hook meant to enforce a policy should exit 2. That sentence exists because exit 1 reads like a refusal to every Unix tooling author and like a shrug to this orchestrator.

Two corollaries:

  • An exception escaping your script exits 1 in Python and in Node by default, so it allows the call. It also allows in Go when you os.Exit(1) in the error path. A guard that "crashes loudly" is a guard that fails open loudly.
  • A script that exits 0 without printing a decision is not a guard at all. It is a notice in the transcript that a hook ran. Silence there has the same effect as a missing file in Shape 1.

The hook contract also has a second code for pre-tool events: exit 2 blocks, and stderr goes to the model as the reason. That is the correct way to refuse. It is also the code most shells treat as "a file the program could not open", which is why it reads wrong until you internalise it.

Check: in a scratch directory, have the hook raise once, return nothing once, and exit 2 once. Confirm in the transcript which of the three refused the call. Only one will.

Shape 3: the guard ran, too late

Some of these frameworks decide the verdict in one step and act on it in a later one: route the approval, narrow the permissions, write the audit record. If the refusal is delivered after that later step, the refusal lands on a request that has already been handled, and the guard's veto becomes a line in a log rather than a wall.

Two shapes of the same thing sit close together in the reference:

  • A permission request is resolved on its own path, separate from the event on which the hook verdict arrives.
  • Sandbox settings are re-read as a session proceeds. A rule written by a later step does not re-apply to an operation that already began.

Neither is a bug. Both are properties of a system that checks policy at points rather than holding a lock. The mistake is treating "I wrote this into the settings" as a gate. A settings edit is a gate only for operations that have not yet resolved their policy.

Check: put your deny where the side effect is decided, not where configuration is loaded. Trace one call from the tool name to the syscall. If the trace shows the action beginning before the policy printing, you have a race, and today it lost that one.

Shape 4: two layers that both deny, and the second one allows

Deny lists, classifiers and hooks are not one authority with several names. They resolve in a definite order, and an allow at a coarse level is not a deny at a narrow one. A deny rule in settings.json can stop a match that the hook allowed, but from the other side, "harmless" at the permission layer does not mean your hook got consulted: an operation the classifier already allowed may never need a hook.

Check: take the two actions you most want to block and turn each layer off in turn. If any layer is the only one on and one of the actions still goes through, you have found where the real decision lives. Everything wrapped around it is a code-level check now, not a security boundary.

What settles the question

The answer is not a better regex.

  1. A guard's exit path is non-zero for everything the contract does not read as a refusal: crashes, timeouts, a missing script, a hang.
  2. The refusal has an audience. Stderr or a JSON reason the model can read, not a dead exit that the transcript files under "hook error".
  3. The event that fires is the one that occurs in the mode the job actually runs, and a test proves the verdict reached the tool.
  4. The layer above is checked twice, with the guard on and with it off. The call that still goes through with the guard off is the layer the agent actually uses, and that is where the next bug will live.

A guard is worth exactly what its failure mode is worth. So pick one guard in your repository tonight, find the branch it takes when it does not know, and either fix that branch or write down that you chose it. The four probes between here and the sources need no fixture and no network; they hold for a lock-file guard as well as for a shell one.

Four probes you can paste

Each line prints its own exit code. Run them against the command string your hook actually receives.

printf '%s' '{"tool_name":"Bash","tool_input":{"command":"git push origin main"}}' | ./gate.sh; echo "exit=$?"

printf '%s' '{"tool_name":"Bash","tool_input":{"command":"cd \"$HOME/dev/project\" && git push origin main"}}' | ./gate.sh; echo "exit=$?"

printf '%s' '{"tool_name":"Bash","tool_input":{"command":"git -C . push origin main"}}' | ./gate.sh; echo "exit=$?"

printf '%s' '{"tool_name":"Bash","tool_input":{"command":"git '"'"'push'"'"' origin main"}}' | ./gate.sh; echo "exit=$?"

printf '' | ./gate.sh; echo "exit=$?"
Enter fullscreen mode Exit fullscreen mode

Line 2 is the shape that broke one gate on 2026-10-28: a quoted home directory, an unexpanded variable, and a branch that returned 0 at the moment it should have refused. Line 3 removes the cd a gate might look for. Line 4 is the same program with different quoting on its name. Line 5 is the absence of input.

A guard can deny lines 2 to 4 and still fail on line 5, or the reverse. Both failures read the same in the transcript: the action runs.

Sources

  • Anthropic, "Claude Code hooks reference", code.claude.com/docs/en/hooks (accessed 2026-11-03). The hooks documentation says that exit 2 blocks a tool call on blocking events, and that an exit with any other code is a non-blocking error: the action goes ahead, and the transcript shows a <hook name> hook error notice. It also states that exit code 1 without valid JSON output is a non-blocking error, and that a hook meant to enforce a policy should exit 2. For PreToolUse, hookSpecificOutput.permissionDecision takes the values allow, ask, deny and defer; the docs treat the older top-level decision fields as deprecated for this event.

  • Anthropic, permission and allow/deny rule documentation under code.claude.com/docs/en (permissions and hooks pages, accessed 2026-11-03). It describes Bash rules as string matching and states that rules cover the form of the command Claude normally produces, that a deny rule is not a security boundary around the program itself, and that shell operators are recognised so the parts of a compound command are matched separately. I am paraphrasing; check the current page before you rely on a quotation.

  • atsushi_apps, "Claude Code の PreToolUse フックで、AI に「やらせない」を仕組みにする", Zenn, 2026-11-02. A working Python hook that emits a permissionDecision: "deny" JSON object and exits 0. The author's lock-file example covers the multi-session simulator collision; the article notes that asking Claude to avoid a command is a request, and that hook is a binding.

  • y_ukai (azarashi), "Auto mode時代のClaude Codeを守る。検証済み10キットで始めるセキュリティ対策【全員・チーム・組織別】", Zenn, 2026-10-28. Reports a run of 12 operations against each permission mode in October 2026, and that in the auto mode, force push and config rewrites went through without the confirmation screen when the kit was absent, and were stopped by hooks when the kit was present.

  • norman6464, "git push を止める hook は要らなかった:無人 claude -p には push の権限を渡さない、という置き方", Zenn, 2026-11-02. Relays, as the author's description, kazuki_okura's 2026-10-28 Zenn post: a git push gate received cd "$HOME/dev/project" && git push, did not expand $HOME, found no repository at the unexpanded path, and exited 0, so the push went ahead; the same push written with an absolute path was blocked. The fix the author reports is to deny when the resolved directory does not exist (fail closed), with a 16-case test script for the hook.

This post was written with AI assistance. The author is responsible for its content.

Top comments (0)