●RUNNER — claude self-hosted-runner turns your own machines or containers into a place web, mobile, and desktop sessions can run, on Team and Enterprise plans●PLUGIN — Plugins can now be installed from a zip over HTTPS, with no git or npm required and optional SHA-256 pinning●BEDROCK — ANTHROPIC_BEDROCK_REGION_PREFIX lets Bedrock prefer a specific cross-region inference profile instead of the one derived from AWS_REGION●STATUS — Sessions waiting on a sandbox, MCP input, or managed-settings prompt now read as Needs input rather than Working●DLP — Inference Hooks are in beta for Enterprise. Prompts route to your own security server for an allow or deny verdict, typically within five seconds, before the model sees them●WORKBENCH — The legacy Workbench and experimental prompt tools API retire on August 17, and Sonnet 5 promotional pricing runs through August 31●RUNNER — claude self-hosted-runner turns your own machines or containers into a place web, mobile, and desktop sessions can run, on Team and Enterprise plans●PLUGIN — Plugins can now be installed from a zip over HTTPS, with no git or npm required and optional SHA-256 pinning●BEDROCK — ANTHROPIC_BEDROCK_REGION_PREFIX lets Bedrock prefer a specific cross-region inference profile instead of the one derived from AWS_REGION●STATUS — Sessions waiting on a sandbox, MCP input, or managed-settings prompt now read as Needs input rather than Working●DLP — Inference Hooks are in beta for Enterprise. Prompts route to your own security server for an allow or deny verdict, typically within five seconds, before the model sees them●WORKBENCH — The legacy Workbench and experimental prompt tools API retire on August 17, and Sonnet 5 promotional pricing runs through August 31
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.
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 a content generation pipeline across several sites, 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.
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 attemptimport jsonimport sysALLOWED_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:
Outcome
Count
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.
Caller
Cause
Count
Main thread
agent_type missing
24
Main thread
file_path missing
6
Subagent
file_path missing
24
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.
Return judgments as exit 0 plus JSON on stdout. Claude Code only parses JSON on exit 0; exit 2 causes JSON to be ignored. Pick one mechanism and stay with it.
Exit 2 on unexpected exceptions. Exit 1 fails open. "If I cannot decide, stop" should be expressed in code, not in a comment.
Escalate incomplete input to ask, not deny. Rejecting every write with unclear provenance stalls legitimate work. Handing it to a human was the right landing spot.
Here is the rewrite, ready to drop into .claude/hooks/agent_write_gate.py.
#!/usr/bin/env python3"""PreToolUse: scope subagent writes to per-agent_type allowed roots.Designed around Claude Code's contract: - Return decisions as exit 0 + JSON on stdout (never mixed with exit 2) - Exit 2 on unexpected exceptions (exit 1 is a non-blocking error and fails open) - agent_id / agent_type are optional fields present only inside a subagent"""import jsonimport osimport sysWRITE_TOOLS = {"Write", "Edit", "NotebookEdit"}# agent_type -> roots this agent may write to (absolute paths)ALLOWED_ROOTS = { "doc-writer": ["/repo/content"], "test-runner": ["/repo/tests", "/repo/fixtures"], "my-plugin:reviewer": [], # read-only: no writes at all}DEFAULT_ROOTS = [] # unknown agent_type writes nothingdef decide(payload): """Return (permissionDecision, reason). None means 'no opinion'.""" tool = payload.get("tool_name", "") if tool not in WRITE_TOOLS: return None, None agent_type = payload.get("agent_type") if agent_type is None: # Main-thread write. Defer to normal permission evaluation. return None, None tool_input = payload.get("tool_input") or {} path = tool_input.get("file_path") if not path: # Write with unclear provenance. Escalate rather than wave through. return "ask", f"{agent_type} issued {tool} without a file_path" path = os.path.normpath(path) roots = ALLOWED_ROOTS.get(agent_type, DEFAULT_ROOTS) for r in roots: r = os.path.normpath(r) if path == r or path.startswith(r + os.sep): return "allow", f"{agent_type} is writing inside its allowed root {r}" return "deny", ( f"{agent_type} may only write to {roots or '(nothing)'}; " f"{path} is out of scope, so the write was stopped" )def main(): try: payload = json.load(sys.stdin) decision, reason = decide(payload) except Exception as e: # Fail closed. Exit 1 would be a non-blocking error and the write proceeds. print(f"agent-write-gate could not decide: {e!r}", file=sys.stderr) sys.exit(2) if decision is None: sys.exit(0) # No JSON emitted -> normal evaluation json.dump({ "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": decision, "permissionDecisionReason": reason, } }, sys.stdout) sys.exit(0)if __name__ == "__main__": main()
Putting hookEventName inside hookSpecificOutput is easy to forget; at the top level the output is not interpreted. permissionDecisionReason is also fed back to the model, so wording it so the model does not retry saves a pointless round trip.
Rerunning the same 150 payloads:
Outcome
naive_gate
agent_write_gate
BLOCK (deny)
78
78
ASK (escalated to a human)
0
24
PASS (explicit allow)
0
18
PASS (silent, deferred to normal evaluation)
18
30
PASS (hook errored, execution continued)
54
0
The 54 pass-throughs are gone. Twenty-four now reach a human as ask, and 30 are explicitly deferred to normal permission evaluation as main-thread calls. Both "pass," but intentional delegation and accidental side effect are not the same thing.
I also fed it deliberately broken input.
stdin
naive_gate
agent_write_gate
empty string
exit 1 (proceeds)
exit 2 (blocked)
not json at all
exit 1 (proceeds)
exit 2 (blocked)
{"tool_name":"Write"}
exit 1 (proceeds)
exit 0, no output (normal evaluation)
tool_input is null
exit 1 (proceeds)
exit 0, returns ask
Prefix Matching Was Widening the Allowlist
There is a second hole I would not have found without measuring: startswith on the allowed root.
I granted doc-writer access to /repo/content. Here is what that actually permits.
Target
naive_gate
agent_write_gate
/repo/content/a.mdx
passes
allow
/repo/content-archive/a.mdx
passes
deny
/repo/contentious.txt
passes
deny
/repo/content/../src/pricing.ts
passes
deny
Any string beginning with /repo/content includes /repo/content-archive/ and /repo/contentious.txt. Add one sibling directory and territory you never intended silently joins the allowlist. The fix is to make the separator explicit: path == r or path.startswith(r + os.sep).
For .. segments, the docs state that Claude Code expands ~ and relative paths before hooks run, so in practice you receive a normalized absolute path. Treat os.path.normpath as defense in depth — though it does matter when you unit-test the hook locally, as I was doing here.
On Windows there is one more layer. The docs note that file_path arrives with backslash separators even when your hook runs under Git Bash where $PWD looks like /c/project, so a comparison written with forward slashes such as a /src/ check never matches. Path comparison has to be platform-aware.
Measure the Cost Before You Wire It Up
PreToolUse fires every time Claude calls a tool. In a write-heavy pipeline, the hook's own runtime accumulates. Here are 1,000 invocations against an identical payload.
Metric
Value
p50
21.9 ms
p95
23.3 ms
p99
23.7 ms
Total for 1,000 runs
22.1 s
Attributing that cost: python3 -c pass alone has a p50 of 16.8 ms. The decision logic is roughly 5 ms; the rest is interpreter startup. Twenty-two seconds per thousand writes was acceptable for my pipeline, but if you need it tighter, the room is in moving the decision into a single binary or a resident process. The worst outcome would have been to skip hooks entirely on a vague sense that they are slow, without ever taking the number.
The default timeout for command hooks is 600 seconds, which is far too long for a policy gate. Against a measured p99 of 23.7 ms, ten seconds leaves generous headroom.
Two wiring behaviors are worth internalizing. First, all matching hooks run in parallel, and deny takes priority over defer, which takes priority over ask, which takes priority over allow. If any hook returns deny, the operation is blocked no matter what the others say — which means you can add gates incrementally without them fighting each other.
Second, PreToolUse only fires when Claude calls a tool. Files you pull in with @ in a prompt are inserted while building the prompt, with no tool call, so no hook fires — including hooks matching Read. To block paths from @ references, use a Read deny rule instead of a hook.
Two Production Gotchas I Hit
Right after wiring it up, the gate appeared to say nothing for a while. The cause is straightforward: stderr from a hook that exits 0 goes to the debug log only. It never reaches the transcript and Claude never sees it. Sprinkling print(..., file=sys.stderr) for debugging produces nothing on screen. Until I understood that, I could not tell whether the gate was failing to fire or simply failing to log. The workaround is to append your decisions to your own file, or enable debug logging.
Second, a hook process inherits the parent environment — with one exception. Claude Code strips OTEL_* exporter variables from every subprocess it spawns, including hooks. If you planned to ship decisions straight to telemetry, that single detail makes your instrumentation silently go nowhere. For correlation, log the prompt_id from the common input fields yourself; it matches the prompt.id attribute on OpenTelemetry events, so you can join them afterward.
What Remains Unverified
One honest caveat.
What I measured here is how my gate script behaves against the PreToolUse input contract. For turn-by-turn (Task) subagents, the docs explicitly state that agent_id and agent_type are delivered, so it is reasonable to rely on this gate there.
Whether the same hook fires inside the isolated workflow runtime is not something I found documented. If workflows are the core of your autonomous setup, verify that in your own environment before depending on it. Verification is cheap: add a few lines at the top of main() that append the received payload to a log file, run one workflow, and check whether any PreToolUse entries carrying an agent_id appear. If none do, that gate does not exist as far as that runtime is concerned.
What to Do Next
If you run any kind of autonomous pipeline, there is one thing worth checking today.
Pipe deliberately malformed JSON into your existing PreToolUse hook. If the exit code is anything other than 2, that hook is not functioning as a policy gate. Before it can decide wrongly, it is already letting things through undecided.
I spent a long stretch believing I had drawn a boundary. The string in a settings file and the behavior that actually runs are separate things. Running 150 payloads took two short scripts and a few minutes — a reasonable price for finding out which one you have.
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.