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.
This commit is contained in:
Omar Sobh
2026-09-22 20:08:00 +00:00
parent 4a3f1cc2f9
commit 0de7661229
+189
View File
@@ -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": "<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:
```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