Book a call
LESSON14mVERIFIED 2026-08-03 · CLAUDE CODE 2.1.220 · CODEX CLI 0.146.0 · GITHUB COPILOT CLI 1.0.77 · ANTIGRAVITY CLI 1.1.9 · KIMI CODE CLI 0.31.1

Failing loudly: surfacing a refusal the model will act on

A hook that blocks correctly but explains badly still loses the session. Where a refusal goes, how to word it, and what the model does with each version.

The gate is the easy half

The 2-exit-scenario in the pre-tool-hook is very deterministic and model-agnostic. In the next second, the next turn is not deterministic — the model reads the hook's prompt and chooses its response between:

  • Trying to achieve the same goal by different means

  • Giving up on this goal, and informing the user about it

  • Achieving the same goal using another tool that hasn't been blocked in the pre-tool-hook

And all of it comes down to just a single sentence written some time ago. People often make a mistake of expressing their refusal in a very brief status-like manner that they used to write in scripts rejecting user input; but here there's no such thing as a log reader — it's a model, so the refusal becomes its input. In other words, if you want your session to survive the gate, it all comes down to what you write.

The same block, three sentences

We've created three throwaway projects and set up identical guards in them using Claude Code 2.1.220 (a PreToolUse hook on Bash with a matcher that rejects every command containing grep as a whole word, \bgrep\b), and for each of them we've run the same prompt: "Using a shell command, list the line numbers in notes.txt that contain the word alpha. Report the numbers."

Differences were only in the hook's response:

the refusalwhat the model did
Error: command blocked by policy. (exit 2)It read the guard's code (it's at .claude/guard.py) to understand what policy is in place, and quit without giving an answer. 4 turns, $0.20
grep is not used in this repo. ripgrep is installed - run the same search with rg instead, e.g. rg -n alpha notes.txt. (exit 2)It understood from our message that we don't use grep here, ripgrep is available, and we showed it how to run it — so it used it on the very next turn, and gave a correct answer. 3 turns, $0.15
grep is not used here. Re-run the search with rg., delivered as a structured permissionDecision deny on exit 0It saw the structured denial and also used ripgrep immediately, giving a correct answer. 4 turns, $0.17

We were actually surprised that the first arm didn't circumvent the guard — it hit the wall and stopped there. This is what it told us:

I didn't route around it with awk/sed/nl, since that would defeat a control you deliberately configured, and the hook doesn't tell me whether it's meant to block the tool or the action.

It was good reasoning, but that arm was the most expensive one — no answer, and the model had to reverse-engineer the policy from the guard's script.

We also wouldn't say that these are results worth measuring — it's just a single prompt in a single run for every arm, and there's not much to measure anyway, as the refusal is just another input for the model. The only question is whether you write it like one.

Two channels, and only one of them reaches the model

Whenever you write a refusal, keep in mind that at this point there are actually two fields — one for the human, one for the model. If you mix them silently, the guard will be defused:

  • The human watching is curious where the policy comes from, so they want a file name

  • The model is focused on the task, so it wants a piece of info that will help it decide how to proceed

For example, in the last arm we've put a systemMessage of USER-FACING: the grep guard fired. (which is supposed to be displayed for the user) into the hook's response too, and as you can see, this string doesn't appear anywhere in the entire transcript — consistent with the Claude Code docs which say that systemMessage is a warning shown to the user. But what the model has actually received was permissionDecisionReason.

Let's have a look at what's it like for other guards:

  • Cursor: the deny message includes both user_message (which is "shown in client") and agent_message (which is "sent to agent")

  • Gemini CLI: systemMessage is "displayed immediately to the user in the terminal"; on BeforeTool, the reason "is sent to the agent as a tool error"

  • Codex: it has systemMessage (surfaced as a warning in the UI), and the reason (handed to the model)

  • Antigravity CLI: there's only one optional reason field — "the explanation shown to the agent or user for the decision"

So if you put the reason in the user-facing field, the model will see a decision without any explanation — worse than the first arm above.

Write it as an instruction

If you take a look at the Claude Code docs, you'll see that their own deny example (Destructive command blocked by hook) is written in past tense and third person — this way of talking about sth is indicative of an event that has already happened. In such a case, the model has already watched it happen, so it just needs to be told what to do now.

There are four ingredients that carry most of the effect:

  • Naming the replacement (in Kimi Code's docs we like this "Please use rg instead of grep" part — just 6 words pointing at what we should run; in this case including a literal command would make sense too)

  • Clarifying whether it's the tool or the goal that's not allowed (we were blocked on this one in the first arm — if grep is banned but searching is okay, say so; if it's not about the tool but the goal, say so, or you've invited a workaround)

  • Communicating if it's worth it to try again (Claude Code exposes retry: true on PermissionDenied, saying that this field "tells the model it may retry the denied tool call"; if there's no such property in your guard, you can always communicate it with a clause)

  • Limiting to one or two sentences (every block puts this text into the model's context, so a document on why we don't use grep here would be counterproductive)

The three ways a refusal goes wrong

There are three ways in which you can mess this up:

  • Circumvention — you don't want to use grep, but the model might try to reach for awk; not observed this time (the model listed awk, sed and nl, and refused on the principle that it's not good to bypass deliberate controls), but that's a risk that deny-lists carry; this is the only place where you can say that there are no alternatives, anything that you need to list should go into permissions and approvals, which is where Kimi Code's docs send you for really sensitive cases

  • Stall — the gate holds, the work isn't done, and the human needs to read the transcript to understand why; this wasn't even the cheapest outcome — the first arm was the most expensive one, as a turn was spent on reading the guard's code to understand the policy

  • Loop — there are cases when the model will get stuck in a loop of trying-and-denying; for example, if you refuse the stop event without saying what the model needs to do to complete the task (like which command to run, and what would count as done); Cursor caps stop and subagentStop follow-ups with loop_limit, 5 by default, but elsewhere it's good to check; a refusal in the stop event should be like "to complete this task, you need to run this command, and this is what counts as done"

The person watching needs a different sentence

For the human, there's also the other side of the coin — they'll be curious where the policy comes from, so make sure that you point them to the guard's file.

And lastly, with Claude Code on the 2-exit path, it prepends the guard's own command line to the reason in the model's context. Here's how it looked like in the first arm (the tool result):

TEXT
PreToolUse:Bash hook error: [python3 $CLAUDE_PROJECT_DIR/.claude/guard.py]: Error: command blocked by policy.

As you can see, this part is the guard's command line; it's a piece of information for the operator, sitting in the model's context, and the structured JSON path delivered the reason with nothing wrapped around it — we'd say that using the JSON path is better here if you need to be really strict about the wording.

Read the sentence back

Before you release your guard, make sure that:

  • It's addressed to the model (you, present tense)

  • It names the replacement (with a literal command if applicable)

  • It communicates if it's about the tool or the goal

  • It communicates if retrying is worth it

  • It's no longer than 1–2 lines

  • It's in the field for the model (not the one for the user)

  • You have seen it get fired (if you haven't, it might not actually work)

IN YOUR HARNESS

In Claude Code

There are two ways you can do it in Claude Code, they're slightly different in terms of how they work:

  • exit code 2 — the standard output and any JSON inside is ignored and the text from stderr is "fed back to Claude as an error message" (simple, and enough when the wording is the only thing you need to get right) like this for example:

PYTHON
print("Migrations are generated, not hand-edited. Change the model and re-run the generator.",
      file=sys.stderr)
sys.exit(2)
  • exit code 0 and a decision object (JSON) in the standard output, which is read by Claude only when the exit code is 0 (like this for example):

JSON
{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Migrations are generated, not hand-edited. Change the model and re-run the generator."
  },
  "systemMessage": "Blocked by .claude/hooks/no-hand-edited-migrations.py"
}

This way it returns both the hook-specific part (the permissionDecision field with the migrations message) and systemMessage with the path to the file that contains the hook. The permissionDecision field can contain allow, deny, ask or defer values, so if you put ask there, it will mean that the user should make a decision in such scenario. The systemMessage is info that is displayed to the user (the stopReason field is connected with continue: false and is documented as "not shown to Claude"), so these two are not being seen by the model, only additionalContext is (for PreToolUse and a few other events) which can be used to provide the model with some info without denying the action.

The thing is, these two ways are not equivalent in terms of what the model sees:

  • exit code 2 — Claude will see the following: PreToolUse:Bash hook error: [<hook command>]: <reason>. In our run on version 2.1.220 the model saw the entire hook command prefixed to the reason

  • exit code 0 — in this scenario Claude will see only what's inside the permissionDecisionReason field, so not the hook command

In Codex CLI

In terms of the Codex CLI, the hooks are not experimental, you can see them as stable already when running the codex features list command. The main way to refuse an action is by returning status code 2 with some message printed to stderr; it's the most bulletproof solution. For instance:

PYTHON
print("Migrations are generated, not hand-edited. Change the model and re-run the generator.",
      file=sys.stderr)
sys.exit(2)

Another way is to return from the hook with 0 and a JSON with structured information on stdout, instead of a simple message on stderr. The reference lists permissionDecision (allow, deny or ask) with permissionDecisionReason for the reason, the older decision: "block" paired with reason, additionalContext as a piece of information you can pass to the model without blocking it, and systemMessage, which is a message visible to the user as a warning in the interface. If you reject an action using the PreToolUse or PostToolUse hook, the message from these hooks becomes the reason of the halt and appears in the continuation prompt (the model sees it), but not on the user's side.

Generally, these are the hooks that can refuse:

  • PreToolUse

  • UserPromptSubmit

  • PreCompact

  • PostToolUse

  • PostCompact

  • Stop

  • SubagentStop

And these ones can't:

  • SessionStart

  • SessionEnd

  • SubagentStart

  • PermissionRequest

(any rejection in these is ignored)

If you return from a hook successfully with 0, everything on stdout will be attached as the developer context to the model, so don't forget to remove logs if you accidentally left them in some guard script during implementation of the hook. There's an exception for Stop and SubagentStop hooks though — in this case the thing that's on stdout is being ignored.

In GitHub Copilot CLI

We kind of inherit patterns from the other harnesses, and that's exactly what drops the denial reason in GitHub Copilot CLI: the standard error never carries it to the model. It's "surfaced to the user but the run continues", so the model can't access it anyway. In other words, for the model to know why we've denied sth, we need to pass it through the standard output in a JSON object — there's a field called permissionDecisionReason which is required when the decision is deny.

For example, here's what a denial looks like:

JSON
{
  "permissionDecision": "deny",
  "permissionDecisionReason": "Migrations are generated, not hand-edited. Change the model and re-run the generator."
}

So a hook that writes a careful explanation to stderr and exits 2 does block the call — exit code 2 is treated as a deny by preToolUse and permissionRequest, and it overrides an allow in the standard output — but the model still doesn't know why. That's this lesson's failure mode available by default.

We need to define some things per event:

  • permissionRequestbehavior of allow / deny, plus message (that's what gets passed back to the LLM on deny)

  • agentStop / subagentStopdecision: "block" with reason (the reason is a part of the prompt for the next turn, so you might want to word it as an instruction rather than a complaint)

The gotcha: preToolUse is the one event that fails closed on a non-zero exit, so a guard that crashes denies the call and has no JSON left to explain itself with. But timeouts fail open on every event (including preToolUse), so a hook that hangs doesn't really enforce anything.

In Cursor

Cursor uses the human-vs-model dichotomy in the field names themselves, so it's kinda hard to mess things up here. The hook returns a JSON object in these four cases:

  • beforeShellExecution

  • beforeReadFile

  • beforeMCPExecution

  • preToolUse

It will signal the engine the action is not to be done, and you can provide your own messages for both the user (to display it) and the model (to consume it). So, a denial payload would look like this:

JSON
{
  "permission": "deny",
  "user_message": "Blocked by .cursor/hooks/no-hand-edited-migrations.sh",
  "agent_message": "Migrations are generated, not hand-edited. Change the model and re-run the generator."
}

You can decide if the action is to be allowed/forbidden or ask the user about it. It's being done using the permission property, you can assign it either allow, deny or ask. The two messages are for the user and the model respectively; the former one "displays to the end-user in the UI" and only the latter one "is fed back to the AI model when an action is denied".

If you were to set up a hook for the stop or subagentStop events you can use the followup_message property instead, which will be submitted as the next user input. There's a little "gotcha", though — by default the loop_limit property is set to 5, so if your hook keeps feeding followup messages back, anything past the fifth one is capped. Set it to null if you want to remove the cap.

The last thing is that the failClosed property of these guards is set to false by default. What it means is that if any of these hooks throws an error, the action is still being done. And if it happens before the hook emitted anything, the action isn't even being refused with a reason. So make sure to set failClosed: true for any guard you actually rely on.

In Gemini CLI

There are 2 ways of communicating decisions in the Gemini CLI — an exit code of 0 and JSON being printed to stdout (as per docs, it's "preferred for all logic"), or 2 which is the rough way and anything printed on stderr is the rejection message. For example:

JSON
{
  "decision": "deny",
  "reason": "Migrations are generated, not hand-edited. Change the model and re-run the generator.",
  "systemMessage": "Blocked by .gemini/hooks/no-hand-edited-migrations.sh"
}

— this is how you communicate a decision not to perform an action in Gemini, it includes a reason field which is a string and a systemMessage field with a name of the hook file.

The destination of the reason field changes for different hook events, and the docs are more explicit about it than most other tools:

  • BeforeTool — the reason "is sent to the agent as a tool error, allowing it to respond or retry". Given that, it makes sense for the reason to say when it's pointless to try again.

  • AfterTool — the reason "replaces the tool result sent back to the model".

  • AfterAgent — the reason is "sent to the agent as a new prompt to request a correction".

Another channel is the systemMessage field, "displayed immediately to the user in the terminal". So they will see it straight away.

It's worth keeping in mind that the docs say "your script must not print any plain text to stdout other than the final JSON", so any debug log you left in the guard script is printing into the same channel your decision has to arrive on.

If you're using the consumer edition, you get the Antigravity CLI which is a different tool and has its own config file hooks.json located in the customisation directory (in the workspace — .agents/ — or ~/.gemini/config/) with slightly different options for the PreToolUse hook: allow, deny, ask, force_ask. The reason field is also optional and described as "the explanation shown to the agent or user for the decision".

But in Antigravity there's only one reason field so it's not possible to distinguish between the scenarios when it's used for communicating with the agent or the user.

In Kimi Code CLI

In Kimi Code your hook exits with status code 2 if it decides to deny the action, and writes the reason to stderr.

You can also return a deny payload in the form of JSON, which is exactly the shape Claude Code uses:

JSON
{
  "hookSpecificOutput": {
    "permissionDecision": "deny",
    "permissionDecisionReason": "Please use rg instead of grep"
  }
}

Moonshot uses this one: "Please use rg instead of grep", which we really like because it's pointing the model at what to do instead, in contrast to a second example from Moonshot docs: "Dangerous command detected, blocked" — such a message is not what we should aim for.

In terms of Kimi Code there are only three events that can be used to actually block an action, these are: PreToolUse, Stop and UserPromptSubmit; in other events (especially PostToolUse) the deny payload won't stop anything even if you write a really passionate message.

You can use Kimi Code hooks for alerting and some light interception but as written in the docs they "should not be used as the sole security barrier" — for anything genuinely high-risk they send you to permission approvals and manual confirmation instead.

The gotcha: setting up non-2 exit codes in a deny logic will not work as they will fall through to allow, also if you do something like throwing an error from a guard it'll just allow too without saying anything. Also be careful using the config file (config.toml) as it's very rigid, any unrecognised key will cause an error which means that entire config won't be loaded. For example if you were to add reason = "no grep here" to one of the [[hooks]] rules in the config file, you'd get hooks[0]: Unrecognized key: "reason" from kimi doctor config <path> and no hooks loaded at all. Run the validator after every edit — the reason belongs in the script's output, never in the rule.

One gate, then the wording

And at the very end — if you already have an existing guard, do yourself a favour and fire it once deliberately to see what the model actually sees. If it's a status line, it should take you no longer than 2 minutes to make it an instruction; on these three runs it was the difference between a session that got finished and one that got stopped.

j / k to move between lessons