Mission: 01a0cab6-749e-7e30-b236-de16218856c9 Phase: 01a0cab6-74a0-7332-aafe-a0669901213d Committed by the ClawMates delivery pipeline from the agents' working tree. Authored by agents, not by the named committer.
8.5 KiB
GATE-MAP — Mission Agent Tool Call Gate, End to End
Research phase finding. No source files were modified. Traced from code; every claim cites a file and function.
Overview
There are two distinct gating paths in this repository, depending on whether
the agent is a chat agent (ZeroClaw, tool-free, MCP door) or a mission agent
(claude -p inside a container or microVM). This document traces the mission
path exclusively, as scoped by the task.
The mission path has three layers that execute in order:
[GUEST] PreToolUse hook (tool-gate.sh) — blocks inside the container
[HOST] Phase runner drain — reads gate records + tap after phase ends
[HOST] mission_events INSERT — writes to the `mission_events` DB table
Hop 1 — The PreToolUse Hook Inside the Guest
File: crates/cm-api/src/vm_tool_gate.rs
Executes in: the guest (container or microVM)
Installation
The host generates a shell script (hook_script_with, line 562) and installs it
via install_command_with (line 819). For container-tier missions the installer
is run by container_tool_hooks::install_with (crates/cm-api/src/container_tool_hooks.rs,
line 53), which calls container_exec::exec_as_root to run the install script
inside the container. The installed file is /root/toolhooks/tool-gate.sh
(constant HOOK_DIR = "/root/toolhooks"). For microVM missions the same script
is placed at /root/toolgate/tool-gate.sh (constant GUEST_DIR = "/root/toolgate").
The settings document written to /root/toolhooks/settings.json (or
/root/guest-settings.json for the microVM tier) registers the hook via:
[{ "hooks": [{ "type": "command", "command": "<dir>/tool-gate.sh" }] }]
(settings_hook function, line 809.)
What the hook does
When claude -p is about to execute a tool it fires the PreToolUse event.
The generated shell script:
- Reads the hook JSON payload from stdin via
node(theNODE_EXTRACTconstant, line 789) — extractstool_name,tool_input.command,tool_input.file_path, andagent_type(the subagent role). - Optionally checks the task-permission policy (
TaskPolicy, line 297): ifenforce = true, any tool not inallowedis denied; in shadow mode the denial is written towould-deny.jsonland the call is allowed. - Checks role policies (
ROLE_POLICIES, line 261): theverifierrole is denied write tools (Write,Edit,MultiEdit,NotebookEdit). - Checks write-tool path rules: write tools targeting protected paths
(
PROTECTED_PATHS, line 225 —/root/toolgate,/root/toolhooks,/root/tap/,/root/guest-settings.json,/root/.claude/settings,.git/hooks/) are denied. - For
Bashonly, iterates over command segments split on;|&\nand applies theRULESdeny list (line 127):rm -rf /, force-push, hard-reset onto remote, outbound POST (curl/wgetwith body flags),--dangerously-skip-permissions, and reads/writes of the hook files themselves.
On denial: the script writes one JSONL line
{"rule":"<id>","payload":<hook event>} to denied.jsonl (or
would-deny.jsonl for shadow task-permission), prints the human reason to
stderr, and exits 2. Exit 2 is the Claude Code contract for "block this
tool call and return the stderr text to the model."
On allow: exits 0 — the tool call proceeds.
Inert gate: if node is absent, the script writes to inert and exits 0
(never fail-closed), making every call allowed while leaving a marker the host
can check.
Hop 2 — PostToolUse Tap (Guest, Telemetry Only)
File: crates/cm-api/src/vm_tool_tap.rs
Executes in: the guest (container or microVM)
A second hook (PostToolUse) appends one JSON record per completed tool call to
/root/tap/tools.jsonl (container tier: /root/toolhooks/tap/). It always
exits 0 (line 13) — it is an observer, not a participant. A non-zero exit here
would feed its stderr back to the model. The tap records tool name, path,
session id, bounded response (for command tools only), and the subagent's
agent_type/agent_id.
Hop 3 — Host Drains the Gate and Tap Records
File: crates/cm-api/src/phase_runner.rs, function
drain_finished_container_phases (line ~601)
Executes in: the host (server process)
After a container-tier phase reaches completed or failed, the host's sweep
(run on a tick) connects to Docker and reads the guest files:
- Inert marker (
drain_inert): if the file exists the host logs the count and records agate.inertMissionEvent. - Denials (
drain_denied, line ~657): eachdenied.jsonlline becomes agate.deniedMissionEvent. The detail is parsed byvm_tool_gate::denial_detail(line 386) so therulefield is alongside the hook payload. - Would-deny shadow (
drain_would_deny, line ~672): eachwould-deny.jsonlline becomes agate.would_denyMissionEvent. - Tool tap (
drain, line ~707): the tap file is read-then-cleared. Each parsedObservedrecord is passed torecord_vm_tools.
For microVM phases the tap and gate records are read from inside the VM over SSH
before the VM is destroyed (phase_runner.rs, line ~1852 and ~1855).
Hop 4 — DB Write: mission_events Table
File: crates/cm-api/src/mission_events.rs, function record / record_all
(line ~126)
Executes in: the host
record_vm_tools (phase_runner.rs line 2423) builds MissionEvent structs
and calls mission_events::record_all. Each event is inserted into the
mission_events Postgres table:
INSERT INTO mission_events
(mission_id, phase_id, run_id, agent_id, kind, target, detail)
VALUES ($1, $2, $3, $4, $5, $6, $7)
-- subject to a per-phase cap of 400 events for TOOL_CALL / FILE_TOUCH kinds
Event kinds recorded for tool calls:
tool.call— one per tool invocation (constantTOOL_CALL, line 23)file.touch— one per tool that touched a path (constantFILE_TOUCH, line 24)gate.denied— one per gate denialgate.would_deny— one per shadow-mode would-have-deniedgate.inert— when the gate ran withoutnode
Summary Table
| Step | Where | File | Function | What happens |
|---|---|---|---|---|
| 1 | Guest | vm_tool_gate.rs |
hook_script_with → tool-gate.sh |
PreToolUse hook evaluates deny rules; exits 2 (block) or 0 (allow) |
| 2 | Guest | vm_tool_tap.rs |
PostToolUse tap script | Appends tool call record to tools.jsonl; always exits 0 |
| 3a | Host | container_tool_hooks.rs |
drain_denied / drain_would_deny / drain_inert / drain |
Reads gate denial + tap files out of the container via Docker exec |
| 3b | Host | phase_runner.rs |
drain_finished_container_phases |
Calls drain functions; calls record_vm_tools with parsed Observed |
| 4 | Host | mission_events.rs |
record_all |
INSERTs tool.call, file.touch, gate.denied, … into mission_events |
Key Design Notes
- Pre-execution gate is not the §15 human-approval gate. The comment at the
top of
vm_tool_gate.rsis explicit: the §15GatePolicy(chat path, keys onsession_id/message_id) has no mission-shaped form, and a hook that blocked the agent's process while waiting for a human would wedge the turn. The gate is a deterministic deny list only. - The hook is installed by the host, baked at install time. The task policy
is compiled into the script at install time, not re-read from a file at call
time, so the guest cannot persuade itself to use a different policy file
(
hook_script_with, line 562 comment). - Container and microVM tiers share the same gate/tap code but differ in how
the host drains: containers via
container_exec::connect(DOCKER_HOST-aware); microVMs by SSH before the VM is destroyed. - DB table is
mission_events, notrun_events/audit_log. Chat-path tool calls land inrun_events(written byRuntime::emit,cm-runtime/src/runtime.rs:624). Mission-path tool calls land inmission_events. Theaudit_logtable is only for the MCP door path (chat agents using the ZeroClaw MCP door), not for mission agents.
Follow-up Items
TASK: INT-01 — Verify that record_vm_tools attribution logic correctly maps tap session IDs to agent IDs under multi-agent phases
TASK: INT-02 — Document the MCP door path (chat agents) in a parallel DOOR-MAP.md for comparison
TASK: INT-03 — Confirm CLAWMATES_TASK_PERMISSION=enforce flag is set (or note it is still shadow) in production config