Sessionboxer

A broken Claude Code hook used to let every command through. Claude Code 2.1.295 can make it block instead

Before Claude Code 2.1.295, a policy hook that crashed or timed out let the action run. What onFailure "block" does, where it does nothing, and how to test it.

A Sessionboxer session where Claude Code runs commands without permission prompts, inside its own container

You wrote a PreToolUse hook for Claude Code. It reads each Bash command and refuses anything that touches production. You tested it once, it worked, and you stopped thinking about it. Then someone renamed the folder the script lives in. Claude Code could no longer start the hook, printed a small hook error notice, and ran every command anyway.

That is how hooks behaved by default until this week, and it is still the default. Claude Code 2.1.295, published to npm on October 8, adds a setting that turns a broken hook into a refusal instead. Below: what a hook failure did before, what the new onFailure field changes, where it changes nothing, and the other permission fixes from the same week.

What happened when a hook failed

The hooks reference sorts every finished hook run into three outcomes. Exit 0 is success, and any JSON the hook printed applies. Exit 2 is a blocking error, which stops the action on events that can be blocked. Anything else is a non-blocking error: "The action goes ahead."

"Anything else" covers more than people expect:

  • Exit code 1. The docs say it plainly: without valid JSON on stdout, Claude Code treats exit code 1 as a non-blocking error, "even though 1 is the conventional Unix failure code." A Python script that raises an exception exits 1.
  • A hook that cannot start. A mistyped path in settings.json makes the shell exit with something like 127, and the notice reads Failed with non-blocking status code: /bin/sh: /path/to/hook.sh: No such file or directory.
  • A timeout. On PreToolUse, a timed-out command, HTTP or MCP tool hook "doesn't block the tool call", and the default timeout for a command hook is 600 seconds.
  • Output Claude Code cannot parse, such as half a JSON object.

So a policy hook failed open. A guard that crashed let the command through, with only a notice in the transcript to show for it. For a hook that formats code after an edit, that is the right default. For a hook that stands between the agent and rm -rf, it is a hole.

What onFailure: "block" does

The new field goes on a command or http hook. It takes "continue", the default, or "block". Here is the example from the docs section on blocking when a hook fails, a PreToolUse hook in .claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "node",
            "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/check-command.js"],
            "onFailure": "block"
          }
        ]
      }
    ]
  }
}

With "block", these count as failures: the hook cannot start, it exits with a code other than 0 or 2, an HTTP hook gets a connection error or a non-2xx status, the hook reaches its timeout, or its output is invalid JSON. A failure then does what exit 2 does on that event. A failed PreToolUse hook blocks the tool call, and a failed UserPromptSubmit hook blocks the prompt. On PermissionRequest it denies the request.

One detail will catch people out. Under "block", a command hook that exits 1 counts as failed even if it printed JSON that says permissionDecision: "allow". If your hook returns a JSON decision, make it exit 0.

Where it does nothing

The docs list two cases where the field has no effect:

  • Stop, SubagentStop, TaskCompleted and TeammateIdle hooks. Exit 2 on those events sends Claude back to keep working, and Claude cannot repair a hook that will not run, so blocking there would loop.
  • Command hooks that run in the background with async or asyncRewake.

prompt and agent hooks are not covered either, because the field exists only for command and http hooks.

It also needs Claude Code 2.1.295 or later on the machine that runs the hook. That matters if you use the stable channel. The setup docs say "stable" serves "a version that is typically about one week old", and the claude-code Homebrew cask tracks it. When we checked npm on October 11, the stable tag was 2.1.287 and latest was 2.1.296. On stable, you do not have onFailure yet.

How to test that your guard holds

The docs give a test worth copying. Point the hook at a script that does not exist, start Claude Code and ask it to run ls. With "onFailure": "block" the call is refused and the error says:

PreToolUse:Bash hook error: [node ${CLAUDE_PROJECT_DIR}/.claude/hooks/check-command.js]: failed; blocking because onFailure is "block"

Without the field, the same missing script is a non-blocking error and ls runs. After a timeout, the message says timed out instead of failed.

A short checklist for any hook you rely on as policy:

  1. Run claude --version and update to 2.1.295 or later.
  2. Add "onFailure": "block" to each command or http hook that enforces something.
  3. Make the hook deny with exit 2, or with JSON and exit 0. Never rely on exit 1.
  4. Set a timeout that suits the check, so a stalled script blocks in seconds instead of ten minutes.
  5. Run the missing-script test above once, and again after anyone moves the hooks folder.

On a team, managed settings can also turn on allowManagedHooksOnly, which blocks user, project, local and plugin hooks, so the guards you ship cannot be joined by ones you did not.

The other fixes from the same week

onFailure was not the only change to how Claude Code says no. The same changelog has several fixes for checks that were quietly skipped:

  • 2.1.294 fixed prompt and agent hooks written as instructions, such as "Block commands that...", allowing what they should block.
  • 2.1.292 has a line marked Security: PreToolUse hook approvals and auto mode bypassed the permission prompt for file reads from network (UNC) paths.
  • 2.1.289 fixed Bash deny and ask rules missing a command behind an environment variable prefix with an expanded value, such as TZ="$HOME" rm -rf build, when the sandbox auto-allows commands.

None of this is a reason to stop using hooks or rules. It is a reason to update, and to remember what they are. A deny rule matches text, and a hook is a script. Both are checks on the way to the system, and checks get edge cases wrong. Each of those fixes was a case someone found.

Where the box comes in

We build Sessionboxer, so this part is about our own product, and it takes a different approach. Inside a Sessionboxer box the agent runs with all permissions granted: Claude Code starts in bypassPermissions mode and does not ask before a command. What keeps that safe is that each session is its own Docker container with its own clone of the code. A command that slips past a check runs in the container, not on your laptop. Your Claude token goes only to the boxes that use it and is never stored in snapshots.

The other half is undo. Every turn ends with a snapshot of the whole box, so if the agent did something in turn 9 you did not want, you fork from turn 8. The snapshots and forks chapter of the guide shows how.

What Sessionboxer does not do: it does not write hooks or deny rules for you, and it does not check commands before they run. The Sandbox image also pins Claude Code at 2.1.272, which is older than 2.1.295, so onFailure is not available inside a box until the image moves to a newer version. And a container does not limit what the agent can do with credentials you give it on purpose. If you put a production database URL in the box, a command that reaches it reaches production. Our post on the agent that deleted a production database is about exactly that.

Hooks and boxes do different jobs, and you can have both. Use hooks to stop the commands you never want, with onFailure: "block" so a broken guard stops them too. Use a box so the commands no hook thought of land somewhere you can throw away. Mods, added in 2.1.287, let plugins change deeper behavior and raise the same question from the other side. Our post on why Claude Code mods are not sandboxed covers where they should run.

Features mentioned

Keep reading