๐Ÿ”Œ #3430 โ€” turning on a security control that had never run

Plus #3438, which turned out to be the same gate seen from a different angle.
before: 0 timing entries / 193,606 rows allowlist reach 4 of 32 under the old gate after: 9/9 control ยท 7553 hook tests cost 47.9ms vs 50ms budget

What was actually wrong

agent/restrict-bash was a well-built allowlist โ€” 40 entries, deny-by-default, correct compound handling โ€” with zero registrations in hooks.json. Its unit tests passed for months against a control no session was ever subject to. This change makes it fire for the first time, and everything below was discovered in the act of doing that.

The specified fix would not have worked. The issue's decision comment says to route it through sync-bash-dispatcher "because that array demonstrably fires." It does fire โ€” behind an if gate admitting 13 command prefixes. Handing a deny-by-default allowlist that view inverts it: 28 of 32 allowlisted head-commands are never evaluated, and ssh, wget, nc, scp pass silently. An enumerated gate cannot front an allowlist โ€” whatever is not enumerated is never judged. So the gate is deleted, not widened, which is also the entirety of #3438.
And its verification would have passed anyway. Required gate item 4 says "assert a restrict-bash timing entry appears." It would have โ€” git status reaches the hook. A green control test beside an ineffective control is the exact failure this issue exists to end, so case 4 of the new suite deliberately asserts on commands outside the old 13 prefixes.

Three findings from wiring it up

#FindingWhy it matters
1The if gate โ€” deleted from the Bash matcher blockFixes #3438 too: network-egress-guard could see 1 of the 7 vectors it documents
2Identity from payload, not env. process.env.CLAUDE_AGENT_ID โ†’ input.agent_type ?? input.subagent_type, via normalizeAgentNameThe env var has no guarantee inside a global hook; reading 'unknown' would restrict every session or none. ork: prefix stripping is the #3354 defect.
3Member order decides the verdict. Moved before network-egress-guardThe dispatcher short-circuits on first non-silent result. Placed after, a restricted agent running nc -l 1234 got ask โ€” a prompt a human might approve โ€” instead of deny. Caught by case 4.

The bypass sitting inside the allowlist

Found while fixing a failing test. Four entries were filed under "Misc read-only":

  'pwd', 'date', 'echo', 'which', 'env', 'printenv',
  'node -e', 'node --eval', 'python -c', 'python3 -c',   โ† arbitrary code execution

python -c "import os; os.system(...)" passes a read-only allowlist whose own denial message promises "this agent investigates and reports โ€” it does not modify the system." Harmless while the control was inert; activating it would have shipped a live bypass on day one. Removed, with a regression test that fails if they return.

Safe by construction, not by assumption

restrictBash(input)
  โ”‚
  โ”œโ”€ agent = normalizeAgentName(input.agent_type ?? input.subagent_type ?? '')
  โ”œโ”€ RESTRICTED_AGENTS.has(agent)?  โ”€โ”€ no โ”€โ”€โ–ถ outputSilentSuccess()   โ† 99% of calls
  โ”‚                                            main thread lands here
  โ”œโ”€ compound operators?  โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ yes โ”€โ”€โ–ถ deny
  โ””โ”€ allowlist match?  โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ no โ”€โ”€โ”€โ–ถ deny  / yes โ”€โ”€โ–ถ allow + log

This is an allowlist of restricted agents, deliberately not a test for "am I a subagent". A missing or unrecognised identity falls through to today's exact behaviour, so if CC's payload does not carry the field the change degrades to a no-op rather than to restrict-everything or restrict-nothing.

The perf trade, measured before committing

MeasurementValueNote
bare node startup33.6 msthe floor โ€” unavoidable per hook process
dispatcher, benign command47.9 ms~14 ms is our own member chain
PreToolUse budget50 ms~2 ms headroom; CI runners are slower

Removing the gate moves the dispatcher onto every Bash call, not just 13 prefixes. That is required for correctness but it is a real cost, so restrict-bash-budget.test.ts guards the part this change controls โ€” the in-process member chain โ€” rather than asserting wall-clock, which would be flaky on CI. My first two attempts to measure this were wrong (one spawned two python processes per iteration; the next used $SECONDS, granularity 1s, and reported "0ms"). The 47.9 figure is the third attempt, with timing outside the loop.

Verification

Still unproven, stated plainly: the four cases assert reachability through the dispatcher in-process. They do not prove CC populates agent_type on a real subagent Bash call โ€” that needs a live spawn and a timing entry in production telemetry. If CC does not send it, this change is a no-op rather than a regression, by the design above. The honest next check is a real restricted-agent run with a restrict-bash entry in the timing log.