Files
clawmates/research/GATE-MAP.md
T
Omar Sobh 0de7661229 clawmates: phase work
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.
2026-09-22 20:08:00 +00:00

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:

  1. Reads the hook JSON payload from stdin via node (the NODE_EXTRACT constant, line 789) — extracts tool_name, tool_input.command, tool_input.file_path, and agent_type (the subagent role).
  2. Optionally checks the task-permission policy (TaskPolicy, line 297): if enforce = true, any tool not in allowed is denied; in shadow mode the denial is written to would-deny.jsonl and the call is allowed.
  3. Checks role policies (ROLE_POLICIES, line 261): the verifier role is denied write tools (Write, Edit, MultiEdit, NotebookEdit).
  4. 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.
  5. For Bash only, iterates over command segments split on ;|&\n and applies the RULES deny list (line 127): rm -rf /, force-push, hard-reset onto remote, outbound POST (curl/wget with 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:

  1. Inert marker (drain_inert): if the file exists the host logs the count and records a gate.inert MissionEvent.
  2. Denials (drain_denied, line ~657): each denied.jsonl line becomes a gate.denied MissionEvent. The detail is parsed by vm_tool_gate::denial_detail (line 386) so the rule field is alongside the hook payload.
  3. Would-deny shadow (drain_would_deny, line ~672): each would-deny.jsonl line becomes a gate.would_deny MissionEvent.
  4. Tool tap (drain, line ~707): the tap file is read-then-cleared. Each parsed Observed record is passed to record_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 (constant TOOL_CALL, line 23)
  • file.touch — one per tool that touched a path (constant FILE_TOUCH, line 24)
  • gate.denied — one per gate denial
  • gate.would_deny — one per shadow-mode would-have-denied
  • gate.inert — when the gate ran without node

Summary Table

Step Where File Function What happens
1 Guest vm_tool_gate.rs hook_script_withtool-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.rs is explicit: the §15 GatePolicy (chat path, keys on session_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, not run_events / audit_log. Chat-path tool calls land in run_events (written by Runtime::emit, cm-runtime/src/runtime.rs:624). Mission-path tool calls land in mission_events. The audit_log table 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