Glass Box Terrarium, explained from the inside

Last updated · Latest source on GitHub ↗

Claude CodeLesson

Blocking hooks

Some instructions keep being missed however they are worded. A blocking hook stops asking and checks instead: a PreToolUse hook that looks at a tool call before it runs, denies the wrong ones, and tells the agent what to do. It must also decide what happens when it breaks. A blocking hook that fails closed blocks the call when it can't do its job; one that fails open lets the call through, and a crash quietly becomes a pass.

Know PreToolUse hooks already? Skip to In Terrarium.

Outside the box · General

The concept

A blocking hook is a PreToolUse hook. Claude Code runs it before each tool call its matcher names and hands it the call as JSON on stdin, including tool_name, tool_input and the path of the session's transcript. To refuse the call, the hook exits 0 and prints a decision:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Use rg instead of grep."
  }
}

Claude Code blocks the call and shows Claude the reason, so the reason is the instruction the model acts on next. When several hooks decide on one call, the strictest decision wins.

A hook that fails is another matter. If it exits with any code but 0 or 2 and prints no valid JSON, or doesn't start at all, Claude Code reports a non-blocking error and the call goes ahead: by default a hook fails open. The docs' advice for a hook that enforces a policy is to exit 2 to block, rather than 1. A crash, though, picks its own exit code. Newer versions of Claude Code take "onFailure": "block" on a command hook, which turns a failure into a block: a hook that can't start, exits with a code other than 0 or 2, prints output that can't be parsed, or times out. It has no effect on a Stop hook.

Full referenceClaude Code docs: Block the action when a hook fails

Looking inside

In Terrarium

Terrarium calls its blocking hooks guards. docs/agents/guards.md is the roster: each guard, what it denies, where the rule it enforces lives, and its issue. It lists the hints too, which add guidance and deny nothing. Each guard's own script header holds its reasons and the cases it can't see.

A guard is a small script in scripts/hooks/: a registry of the cases it knows, a pure check, and a message. scripts/hooks/hook-io.ts holds the plumbing they share: reading the payload and the transcript, the deny JSON, and a --dry-run mode. A guard's main shows the pattern. An empty stdin, a bare run by hand, passes; a payload it can't read is denied:

scripts/hooks/skill-inline-guard.ts ↗98–107as of
98export function main(): void {
99  const result = readHookPayload()
100  if (result.kind === 'none') return
101  if (result.kind === 'invalid') return denyUninspectable(LABEL, REF, 'the hook payload was not valid JSON')
102  if (result.kind === 'no-tool') return denyUninspectable(LABEL, REF, 'the hook payload named no tool')
103
104  const { payload } = result
105  const output = denyOutputFor(checkSkillInlineCall(payload.tool_input, readTranscript(payload.transcript_path)))
106  if (output) process.stdout.write(JSON.stringify(output))
107}

A transcript it can't read denies too. denyUninspectable says the block is a guard fault, not the agent's mistake. Every guard starts through runGuard, which turns a thrown error into the same kind of deny:

scripts/hooks/hook-io.ts ↗126–135as of
126  const onCrash = (err: unknown): void => {
127    if (opts.failOpen) return
128    denyUninspectable(opts.label, opts.ref, `the guard itself crashed (${err instanceof Error ? err.message : String(err)})`)
129  }
130  try {
131    const done = opts.dryRun && argv.includes('--dry-run') ? opts.dryRun(argv) : opts.main()
132    if (done instanceof Promise) done.catch(onCrash)
133  } catch (err) {
134    onCrash(err)
135  }

A crash before that try, such as a TypeScript runner that won't start or an import that throws, prints nothing and exits nonzero. The wiring in .claude/settings.json covers that case: each guard's hook sets onFailure, except the ones docs/agents/guards.md lists as wired with || true. The first entry here is such an exception; the second is the usual wiring:

.claude/settings.json ↗15–33as of
15      {
16        "matcher": "TaskCreate|Monitor",
17        "hooks": [
18          {
19            "type": "command",
20            "command": "cd \"${CLAUDE_PROJECT_DIR:-.}\" && node_modules/.bin/tsx scripts/hooks/deferred-tool-guard.ts || true"
21          }
22        ]
23      },
24      {
25        "matcher": "ScheduleWakeup",
26        "hooks": [
27          {
28            "type": "command",
29            "command": "cd \"${CLAUDE_PROJECT_DIR:-.}\" && node_modules/.bin/tsx scripts/hooks/loop-only-tool-guard.ts",
30            "onFailure": "block"
31          }
32        ]
33      },

scripts/hooks/deferred-tool-guard.ts catches a tool called with another tool's arguments. It passes failOpen: true to runGuard, and its hook ends in || true, which turns any exit into 0, with no onFailure. The Stop hook scripts/hooks/harness-tag-stop.ts, which the roster lists with the guards, and every hint are wired with || true too.

A test holds the wiring in place. tests/unit/guards-wiring.spec.ts finds every scripts/hooks/*-guard.ts, checks a PreToolUse hook runs it, and, for all but the fail-open list, checks the hook sets onFailure and holds no || true:

tests/unit/guards-wiring.spec.ts ↗8–24as of
8const FAIL_OPEN = ['deferred-tool-guard']
⋯// …
15describe('every scripts/hooks/*-guard.ts is wired', () => {
16  it.each(guards)('%s runs from a PreToolUse hook', (guard) => {
17    const wired = entries.filter((h) => h.command.includes(`scripts/hooks/${guard}.`))
18    expect(wired, `no PreToolUse hook runs ${guard}`).not.toHaveLength(0)
19    if (FAIL_OPEN.includes(guard)) return
20    for (const h of wired) {
21      expect(h.onFailure, h.command).toBe('block')
22      expect(h.command, 'a `|| true` exits 0, so onFailure never fires').not.toContain('|| true')
23    }
24  })

Each guard has its own unit test in tests/unit/, such as tests/unit/skill-inline-guard.spec.ts: it calls the pure check with recorded and adversarial inputs, an unreadable transcript included, and runs the real script once through stdin and once through --dry-run. Most guards' Usage: headers show the --dry-run command that prints the decision for a given call, with no deny.

Why it's built this way

  • The deny message is the rule's home. A guard's message states the rule and its fix at the call, where a doc went unread (Claude Code hooks says why). scripts/hooks/skill-inline-guard.ts.
  • A crash blocks. Guards were once wired with || true. A crash before the guard's own try exited nonzero, || true made that a silent 0, and a guard let through a commit it should have refused. A wrapper script fixed it, until Claude Code's onFailure replaced the wrapper; runGuard's deny stays, since it names the guard. The cost: a broken guard blocks every call of its kind until fixed. docs/agents/guards.md.
  • A guard that can't tell may fail open. The deferred-tool guard can recognize a wrong call only by the shape of its arguments, and a guard that denied what it couldn't identify would block correct calls. So it stays silent on any doubt, its own crash included. The cost: when it breaks, that mistake goes unchecked. scripts/hooks/hook-io.ts.
  • A new mistake is a data row. Each guard keeps the cases it knows in an exported array at the top of its script, so a newly seen variant of the mistake is one more row and a test case, not new logic. The cost: a new tool also needs adding to the hook's matcher in .claude/settings.json, which a person merges. docs/agents/guards.md.
  • Tests assert a property. A regex once matched across a line break, so a write verb on one line denied an unrelated command on the next, and every single-line test still passed. Guard tests now cover multi-line and adversarial input, and a guard's real deny is probed live once. The cost: more cases per guard, and a manual probe no test replaces. docs/agents/guards.md.

What to watch for

  • The deny message is the instruction. Do what it says. If it is wrong, report it on the guard's issue in the roster rather than rewording the call until it slips past.
  • Fail-closed covers crashes, not blind spots. A guard that blocks when it breaks can still miss a call it doesn't recognize. The Bash, Edit and Write guards and their shell pre-filters match text, so a path built from a variable, or a payload key the harness renames, passes unchecked. Each guard's header lists its gaps.
  • onFailure is version-bound. A Claude Code older than the minimum in docs/agents/guards.md ignores it, and a guard that can't start fails open again.
  • A probe can be blocked by the guard it probes. A Bash call that carries a denying --dry-run --input in its own text can trip the guard before the dry run starts. Put the input in a file and pass --input-file.
  • A unit test can't see a live deny. It checks the decision, not that the hook fires in a session, which is one reason guards are to merge (Claude Code hooks says why).

Try it in your project

Add "onFailure": "block" to a PreToolUse hook you rely on, then make it crash on purpose (an import of a module that doesn't exist will do) and check that the call is blocked. Remove the setting and run it again: the call goes ahead.

Where next

Walkthroughs through this Lesson

Last updated · Latest source on GitHub ↗

Something went wrong

This content couldn’t be loaded. Reloading the page usually fixes it.

Technical details
route: /t/glassbox/guide/claude-code/blocking-hooks