diff --git a/research/GATE-MAP.md b/research/GATE-MAP.md new file mode 100644 index 0000000..f52508f --- /dev/null +++ b/research/GATE-MAP.md @@ -0,0 +1,189 @@ +# 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: + +```json +[{ "hooks": [{ "type": "command", "command": "/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":"","payload":}` 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: + +```sql +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_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.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