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:
@@ -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
|
||||||
Reference in New Issue
Block a user