◉CLAUDE LABJP
●THREADS — Message threads arrived in beta on the Messages API (11/2): the server keeps the conversation and you send only new messages●CONSOLE — Console IP allowlist is in beta (11/5): requests from addresses not on the list are refused●SONNET 4.5 — Retires on the Claude API on 11/30, 25 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST — Opus 4.8 fast mode is deprecated and will be removed on 11/30. Migrate to Opus 5.5●NEW — I Put a Ten-Line Promise File at the To…●THREADS — Message threads arrived in beta on the Messages API (11/2): the server keeps the conversation and you send only new messages●CONSOLE — Console IP allowlist is in beta (11/5): requests from addresses not on the list are refused●SONNET 4.5 — Retires on the Claude API on 11/30, 25 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST — Opus 4.8 fast mode is deprecated and will be removed on 11/30. Migrate to Opus 5.5●NEW — I Put a Ten-Line Promise File at the To…
Articles/Claude Code
⟐ Claude Code/2026-08-07Advanced

The Write Your Permission Mode Never Stops — Auditing an Agent-Scoped PreToolUse Gate Across 150 Payloads

Workflow subagents auto-approve file edits regardless of your session permission mode. I measured the remaining boundary — an agent_id-scoped PreToolUse hook — across 150 payloads, found 54 silent pass-throughs, and rebuilt it to fail closed.

Claude Code282PreToolUse5subagents11permissions14

✦ Premium Article

I stopped mid-edit while reviewing my automation settings.

Workflow subagents auto-approve file edits regardless of the session's permission mode. Reading that line, I realized I could not explain what was actually happening in my own setup. As an indie developer running unattended jobs across several repositories, I assumed I had drawn a boundary around where agents may write. That boundary, it turned out, lived entirely on the permission-mode side.

Shell commands and MCP calls outside your allowlist can still prompt mid-run. File writes will not. Under auto, bypass, or claude -p, even the launch prompt is skipped.

So what is left? Following the docs, PreToolUse hook input carries agent_id and agent_type — but only when the hook fires inside a subagent. You can tell whose write this is. That is the remaining boundary.

I wrote the obvious gate, generated 150 payloads, and ran them through. The result was the opposite of what I expected.

54 Silent Pass-Throughs — Build the Population First

Before arguing about decision quality, you need to decide what range of input the decision has to survive. From the PreToolUse common input fields, I kept only the axes that actually change the branch.

  • permission_mode — six values: default, plan, acceptEdits, auto, dontAsk, bypassPermissions (the mode labeled Manual in the UI arrives as default)
  • Caller — main thread, two named subagents, a plugin-scoped my-plugin:reviewer, and an --agent launch that carries agent_type but no agent_id
  • Tool and target — inside the allowed root, outside it, under tests, a CI config file, and one input with file_path missing entirely

Six times five times five is 150. Here is the generator.

# payloads.py — the PreToolUse payload population
import copy
import itertools
 
BASE = {
    "session_id": "sess_gate_probe",
    "prompt_id": "550e8400-e29b-41d4-a716-446655440000",
    "transcript_path": "/home/u/.claude/projects/x/transcript.jsonl",
    "cwd": "/repo",
    "hook_event_name": "PreToolUse",
    "tool_use_id": "toolu_probe",
}
 
MODES = ["default", "plan", "acceptEdits", "auto", "dontAsk", "bypassPermissions"]
 
ORIGINS = [
    {},                                                # main thread
    {"agent_id": "ag_01", "agent_type": "doc-writer"},
    {"agent_id": "ag_02", "agent_type": "test-runner"},
    {"agent_id": "ag_03", "agent_type": "my-plugin:reviewer"},
    {"agent_type": "doc-writer"},                      # --agent launch, no agent_id
]
 
TOOLS = [
    ("Write", {"file_path": "/repo/content/a.mdx", "content": "x"}),
    ("Write", {"file_path": "/repo/src/config/pricing.ts", "content": "x"}),
    ("Edit",  {"file_path": "/repo/tests/t.spec.ts", "old_string": "a", "new_string": "b"}),
    ("Edit",  {"file_path": "/repo/.github/workflows/deploy.yml",
               "old_string": "a", "new_string": "b"}),
    ("Write", {"content": "x"}),                       # file_path missing
]
 
 
def build():
    out = []
    for mode, origin, (tool, tin) in itertools.product(MODES, ORIGINS, TOOLS):
        p = copy.deepcopy(BASE)
        p["permission_mode"] = mode
        p.update(origin)
        p["tool_name"] = tool
        p["tool_input"] = tin
        out.append(p)
    return out

The gate I fed them to is the one I wrote first. Written plainly, it looks like this.

# naive_gate.py — "just restrict subagent writes," first attempt
import json
import sys
 
ALLOWED_ROOTS = {
    "doc-writer": ["/repo/content"],
    "test-runner": ["/repo/tests"],
}
 
data = json.load(sys.stdin)
agent_type = data["agent_type"]
path = data["tool_input"]["file_path"]
 
roots = ALLOWED_ROOTS.get(agent_type, [])
if not any(path.startswith(r) for r in roots):
    print(f"{agent_type} may not write to {path}", file=sys.stderr)
    sys.exit(2)
sys.exit(0)

Running all 150:

OutcomeCount
BLOCK (exit 2, tool call cancelled)78
PASS (silent allow)18
PASS (hook errored, execution continued)54

Fifty-four of 150 — 36% — landed in a state where the hook crashed and the write proceeded anyway. Not blocked. Not allowed on purpose. The hook simply raised an exception, and nothing stopped.

Exit 1 Does Not Mean Failure — It Means "No Opinion"

The cause is in Claude Code's exit code contract.

For most hook events, only exit code 2 blocks the action. Exit code 1 is treated as a non-blocking error and execution proceeds, even though 1 is the conventional Unix failure code. The one exception is WorktreeCreate, where any non-zero exit aborts.

A Python script that dies on an uncaught exception exits 1. So a single KeyError quietly flips your gate from "stop this" to "I have no opinion."

Breaking down the 54, two distinct shapes appear.

CallerCauseCount
Main threadagent_type missing24
Main threadfile_path missing6
Subagentfile_path missing24

The 30 main-thread cases are fine to let through. agent_id is an optional field present only inside a subagent, so its absence is normal. But they passed by accident, not by design — an exception happened to behave the way I wanted.

The bottom row is the problem. Twenty-four writes originating from a subagent, where file_path could not be read, and the gate returned no judgment at all while the write went ahead. That was the moment I understood my boundary only applied to code paths that happened not to throw.

My prediction had been backwards. I worried the gate would be too strict and stall development. In practice, the inputs carrying the least information sailed straight through. A gate that cannot decide has to stop — otherwise it is decoration.

✦

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
✦A PreToolUse hook that exits 1 lets the write through. See the measured breakdown of how many of 150 payloads slipped past a naive gate
✦A complete, drop-in PreToolUse gate that scopes writes to per-agent_type roots, plus the settings.json wiring
✦Prefix matching quietly widens your allowlist: granting /repo/content also grants /repo/content-archive. Reproduction and fix included
Secure payment via Stripe · Cancel anytime
✦

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

Related Articles

⟐ Claude Code2026-10-24
The Settings File I Kept Saying Yes To Had a Rule That Let Any Python Through — Auditing Allow Rules After the 2.1.309 Changelog
Claude Code 2.1.309 is full of permission-check and sandbox fixes. Here is a script that audits the allow rules in settings.local.json, and how to fix a prefix wildcard on an interpreter.
⟐ Claude Code2026-10-19
If You Wrote MCP Allow Rules as Just `mcp__servername` — After v2.1.299, Only Servers With `__` in Their Names Need a Recheck
Claude Code v2.1.299 changed MCP allow rules to match only the server they name. A bare mcp__servername is affected only when the server name contains __ or ends in _. Includes a short audit script.
⟐ Claude Code2026-10-15
The Hook That Was Meant to Guard My Japanese Folder Never Blocked Anything — Python Was Reading stdin as cp932
A PreToolUse hook guarding folders like 経理 or 議事録 silently lets edits through when Python's stdin defaults to cp932. How I reproduced it, the three-line fix, and one command to check your own hooks.