feat(missions): a pre-execution gate on mission tool calls

The second half of the tool-call research. Until now a mission agent's
Bash call was gated by nothing, anywhere.

WHY THERE WAS NO GATE

vm_tool_tap is a PostToolUse hook: it fires after the tool has already run
and exit-0s unconditionally, because a non-zero PostToolUse talks back to
the model. It is telemetry and says so. GatePolicy — the §15 door — has one
enforcement site, the chat loop, and its approvals key on
(session_id, message_id), which no mission phase can produce. Meanwhile the
solo tiers run `claude -p --permission-mode acceptEdits` with Read, Edit,
Write and Bash pre-approved.

PreToolUse fires under `claude -p` in this image — measured by vm_stop_gate,
which also proved the exit-2-plus-stderr contract — and had zero callers.
This is that hook.

WHAT IT IS, AND IS NOT

A deterministic policy gate: a short deny list of actions with no legitimate
form inside a mission, blocked before they run, with the reason handed back
so the model can choose differently.

It is NOT the §15 human approval gate, and the module says so. A hook blocks
the agent's process while it runs and a human decision takes minutes to
hours; waiting inside the hook would wedge the turn. This closes the gap
between nothing and something.

The deny list is short on purpose. A gate that blocks legitimate work is
worse than none: the agent cannot ask a human, so it either works around the
block — doing something stranger than what was denied — or burns the turn.

FOUR BUGS THE TESTS FOUND, IN ORDER

1. Substring matching denied `grep -rn 'rm -rf /' docs/`. Searching for a
   string is not running it. Now rules anchor to the start of a shell
   segment, with a separate flag-style match that exempts text tools.
2. The `case` patterns were unquoted, so a needle containing a space made
   the whole script a SYNTAX ERROR — which as a PreToolUse hook exits
   non-zero and denies EVERY call. Every text assertion passed while the
   script was in that state; only running it under a real `sh` found it.
3. The hook receives JSON, not a command, so "starts with" could never
   match — `case` saw `{"tool_name":"bash",...` every time. Now extracts
   tool_name and tool_input.command with `node` (no jq in the image; node is
   guaranteed because Claude Code is a node program).
4. `IFS='\n'` in POSIX sh sets IFS to backslash and the letter n, not a
   newline. Nothing split, so only commands with no separator were ever
   tested and `cd /tmp && rm -rf /` sailed through. Now a literal newline.

Every failure path allows. A gate that fails closed on a parse error blocks
the whole phase, which is exactly what bug 2 did.

Wired through vm_tool_tap::guest_settings, still the single writer of the
guest settings document — a third hook makes the clobber it prevents more
likely, not less, and a test asserts all three survive one document and that
PreToolUse points at the gate's own script rather than the tap's.

Honest limit, stated in the module: a determined agent defeats any
string-matching gate. This is aimed at accidents and obvious cases; the real
isolation is the container and microVM boundary.

Full workspace suite green: 106 binaries, zero build errors.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-19 14:50:01 -07:00
co-authored by Claude Opus 5
parent ea0b989b3f
commit 547b5d9987
5 changed files with 545 additions and 10 deletions
+1
View File
@@ -67,6 +67,7 @@ pub mod topology_worker;
pub mod validator_preflight;
pub mod vm_placement;
pub mod vm_stop_gate;
pub mod vm_tool_gate;
pub mod vm_tool_tap;
pub mod workflow_registry;