Files
clawmates/docs/TOOL-CALL-ARCHITECTURE.md
T
Omar SobhandClaude Opus 5 563b074116 docs: ZeroClaw upstream, scanned against what we actually run
331 behind, 54 ahead. The previous scan said 218 and its conclusion about the
egress commit was wrong, so it is marked superseded rather than edited.

Merge cost is smaller than the number suggests: 660 files changed upstream, 52
by us, and **18 overlap**. `claude_cli.rs` — the provider every mission runs
through — exists in our tree and in zero upstream files, so it cannot conflict.

The find worth recording is not a feature. Upstream defaulted skills to compact
injection on 2026-08-05 (#8313), then restored the full default for v0.8.x on
2026-08-13 (#9913). Eight days. That is our `index` arm, tried at larger scale
and pulled back out of the stable line — evidence bearing directly on our own
open question of whether to flip the default, and with our own data at n=1 per
arm it argues for more pairs before flipping, not fewer.

Their documentation also states plainly what ours should: "Compact mode reduces
prompt size; it is not an isolation boundary for untrusted skill sources."
Progressive disclosure is a token optimisation. It is not a security control.

Also noted, as a documented limit rather than a surprise: upstream fixed
case-insensitive allowlist matching (#9568) and symlink-escape path resolution
(#9384) in their command gate. Ours resolves no paths, so a symlink to `curl`
defeats it.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
2026-08-27 09:58:01 -05:00

317 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Mission tool calls: what is actually true, and what to do
*Research, 2026-08-19. Supersedes the claim "missions can't call tools at all",
which I wrote and which is wrong.*
## The claim was wrong, and the truth is worse
Mission agents **do** call tools. Three of the four execution paths end in
`claude -p` with Claude Code's own toolset and permissions pre-accepted:
| Path | Command | Tools | Permission |
|---|---|---|---|
| Solo microVM | `microvm_executor.rs:341` | `Read Edit Write Bash Agent` | `--permission-mode acceptEdits` |
| Composed microVM | same, per node | same | same |
| Direct session | `session_executor.rs:118` | `Read Edit Write Bash` | `acceptEdits` |
| Container / ZeroClaw | `claude -p --output-format json` | see below | n/a |
So the real position is not "no tools". It is:
> **Mission agents run `Bash` and `Write` with permissions pre-accepted, and
> nothing in this platform can gate them.**
That is a stronger finding than the one it replaces. "Can't call tools" sounds
like a missing feature. "Calls tools freely, ungated, and mostly unobserved" is
a security posture, and it is the one we have.
## Observe versus gate — they are different, and both are partial
As of 2026-08-21, with the container-tier hooks shipped:
| Path | Has tools | We observe | We gate |
|---|---|---|---|
| Solo microVM | yes | **yes**`vm_tool_tap` | **yes**`vm_tool_gate` |
| Composed microVM | yes | **yes** — same tap | **yes** — same gate |
| Direct session | yes | **no mechanism at all** | no |
| Container / ZeroClaw | yes (see below) | **yes**`container_tool_hooks` | **yes** — same |
The direct-session row is the only remaining gap, and it is dormant:
`CLAWMATES_MISSION_EXECUTOR` is unset in production, so that tier never runs.
Checking that before building a tap for it is the reason there is no tap for it.
`vm_tool_tap` installs a **`PostToolUse`** hook, which fires *after* the tool has
already run, and `exit 0`s unconditionally because a non-zero `PostToolUse`
talks back to the model. It is telemetry and says so. It is structurally
incapable of gating — which is why the gate is a separate `PreToolUse` hook
rather than a stricter version of this one.
**What the tap records.** Until 2026-08-21 it kept the tool's name and the path
its arguments named, and threw the arguments themselves away. A phase that
recorded `Bash × 6` could not answer whether it ran the tests, whether it
committed, or whether it called an API a skill forbids. It now keeps the
arguments, bounded: file bodies become a byte count, over-long strings are
truncated with a marker. That is what makes `skill_use` able to score behaviour
rather than the agent's own account of it.
The §15 `GatePolicy` has exactly **one** enforcement site — `Runtime::drive`,
the chat loop — and its approvals are keyed to `(session_id, message_id)`, which
no mission phase can produce. A mission agent's `Bash` call is gated by nothing,
anywhere.
## Why the container tier looked tool-free
`claude_cli` builds `claude -p … --output-format json`, which returns a single
final result object, and the provider then hardcodes `tool_calls: Vec::new()`.
The tool calls happen; the transport discards them.
That is why `ToolTrace.calls` was empty on gw-04, and the comment in
`topology_exec.rs` reads that emptiness as "§15 by construction: agents are
provisioned tool-free". It is not by construction. It is the output format.
**Proven, not assumed.** Against the live runtime (Claude Code 2.1.228):
```
claude -p "Run the bash command: echo hello" \
--output-format stream-json --verbose --allowedTools Bash
```
emits exactly what we need:
```
assistant block: tool_use Bash
user block: tool_result
assistant block: text
event: result success
```
The calls are fully observable. We ask for the wrong output format.
## The door is deployed — 2026-08-21
Proven against the real binary in the runtime container, which is a two-minute
loop rather than the ten-minute rebuild-and-run-a-mission one I reached for
first:
```
claude -p --mcp-config <doc> --strict-mcp-config "List the MCP resources you can see"
→ lists skill:global/a11y-checklist, … (see the count caveat below)
claude -p … "Read skill:global/workspace-repo-commit-protocol, reply with its first heading"
→ # Mission repo + commit protocol
```
Connect, list and **read** all work. `mission_orchestrator::install_skills_door`
writes the document at launch (mode `0600`, under `/root`, never the checkout)
and points the daemon at it in the same step.
**No `--allowedTools` change was needed.** That question was left open rather
than guessed, and the guess would have been wrong in an expensive way: the
daemon exposes no config read, so "adding" the MCP tools would have meant
overwriting the seed's `tools` list and stripping `Write` and `Bash` from every
mission agent — to solve a problem that does not exist.
### It needed a credential first, and that was not "config"
The section below called this "config, not code". That was wrong, and the reason
is authentication. `/mcp/skills` authenticated via `AuthService::authenticate`,
which returns a full `AuthedUser` carrying the user's role; there was no
narrower credential in the system. The document sits in a file the agent can
`cat` — it runs `Bash` with egress — so the documented approach meant handing an
**owner-privileged API token to something explicitly untrusted**. Checked before
concluding: no such credential was in a mission container, so it would have been
a new exposure rather than an existing one.
`auth_sessions.scope` fixes it. `authenticate` delegates to
`authenticate_scoped(token, SCOPE_FULL)`, so every existing caller rejects a
narrow token and a route opts in by name; `/mcp/skills` is the only opt-in.
Measured live with one token:
```
POST /mcp/skills → 53 skill resources
GET /api/missions → 401
```
### And confirmed from inside a real mission
Run 8, all three agents, per-agent attributed:
```
Pedro ListMcpResourcesTool ReadMcpResourceTool Write …
Ebele ListMcpResourcesTool ReadMcpResourceTool Read …
Ahmad ListMcpResourcesTool ReadMcpResourceTool Write …
```
The agent's own report: *"53 MCP resources are available, all from the
`clawmates_skills` server … `skill:global/workspace-repo-commit-protocol` was
read and its first heading is `# Mission repo + commit protocol`."*
**Count caveat, and it is the usual lesson.** The first probe's transcript said
"58 MCP resources" and that number went into this document as though it were a
measurement. It was the model's own paraphrase. The authoritative count is 53 —
`resources/list` returns 53 and `SELECT count(*) FROM skills` is 53. A model's
self-report is not an observation, which this project has now learned three
separate times.
### What this does NOT yet buy
**Trigger is still not measured.** `pinned_skills_text` still inlines full skill
bodies, so the agent is still handed skills rather than reaching for them. The
door makes retrieval *possible*; Trigger becomes real only when delivery
switches to progressive disclosure — and that could regress Compliance, so it
wants an A/B rather than a flip.
## How it looked before it was plugged in
`claude_cli.rs` is **ours** — upstream `zeroclaw-labs/zeroclaw` has no such file.
So is the feature that solves this, our own commit
`88eef99d4 feat(providers): claude_cli --mcp-config + allow/disallow tools (act via door)`.
The provider already accepts:
- `mcp_config``claude -p --mcp-config <file> --strict-mcp-config`, so Claude
Code's **own** MCP client connects to our door;
- `tools``--allowedTools`, auto-approving door tools;
- `disallowed_tools``--disallowedTools`, locking out `Bash`, `Write`, `Edit`,
`Read`, … so **the gated door is the only actuator**.
`deploy/clawmates-runtime/agent.config.example.toml` documents the whole shape
as `[providers.models.claude_cli.door]`.
And in the live runtime: `/zeroclaw-data/clawmates-mcp.json` **does not exist**,
the config has no `[providers.*]` block at all, and every mission claw is bound
to `claude_cli.default` — which sets none of the three fields.
So my earlier conclusion that "`claude_cli` cannot reach MCP, therefore the
skills server is unreachable" was wrong in its reasoning. The capability is
built, documented by us, and simply never deployed.
Related: we have been setting `agents.<alias>.mcp_bundles`, which configures
**ZeroClaw's own** MCP client for its native agent loop. A `claude_cli` agent's
actuator is the claude subprocess, which reads `mcp_config` on the **provider**.
We were turning a knob connected to a loop that does not run.
## Upstream: nothing that solves this, one thing worth taking
We are **218 commits behind** `upstream/master`. Scanning for anything relevant:
> **Superseded 2026-08-27** — now 331 behind, and the egress conclusion below
> is wrong: `net_guard` cannot see a mission's tool calls. See
> `UPSTREAM-SCAN.md` and `MISSION-EGRESS.md`.
- **No upstream work on `claude_cli`** — the file is ours; upstream has none.
- **ACP** (Agent Client Protocol) exists in the fork already
(`zeroclaw-gateway/src/acp.rs`, `zeroclaw-channels/src/acp_channel.rs`); the
three upstream commits since our merge are workspace-default and
tool-approval-localization fixes, not new capability. ACP *does* surface tool
calls natively and is a credible long-term transport, but it is a bigger move
than the two fixes below and buys the same observability.
- **`feat(plugins): add shared egress policy foundation (#9137)`** — a network
guard in `zeroclaw-infra::net_guard` with DNS pinning, IPv4-mapped metadata
blocking, proxy-conflict surfacing. This is the one upstream item genuinely
worth pulling: it is defence for the egress problem we have not solved, and
it is hardening we would otherwise write ourselves.
## RESOLVED 2026-08-21 — hooks, not the transport
Everything above the line is still an accurate account of how mission tools
work. The recommendation that followed it was wrong, and this is what actually
happened.
### `stream-json` was necessary-looking and useless
Shipped, deployed, and driven with a real mission: **zero `tool.call` events**,
with the parser working perfectly. `TurnEvent::ToolCall` — the thing the
gateway turns into a frame — is emitted from `tool_execution.rs`, i.e. only for
tools **ZeroClaw itself executes**. Claude Code runs its tools inside its own
subprocess, so the event never fires. A provider that *knows* about the calls
changes nothing.
Worse, v1 was harmful: it returned the observed calls as
`ChatResponse.tool_calls`, meaning "execute these", so the loop tried to run
`Bash`, got `Unknown tool: Bash`, and fed that back to the model — an extra LLM
round-trip per call, telling the agent its own work failed. The mission
completed correctly anyway, which is why it would have gone unnoticed. Fixed in
fork `295d6d3b8` (log them, never return them); the corrected image
`streamjson2` was built and **deliberately not deployed**, since it delivers
nothing on its own.
### What worked: hooks
`claude -p --settings <doc>` honours `PreToolUse` and `PostToolUse`, so the gate
blocks and the tap records **without ZeroClaw being involved at all**. That
sidesteps the entire `TurnEvent` problem instead of trying to solve it.
Shipped, and verified end to end on a real mission:
```
tool.call 10 Bash 6, Read 3, Write 1
file.touch 4 research/tapproof.md
```
- `--settings` on `claude_cli` — fork `be9c34b1c`
- `container_tool_hooks` writes both hook scripts and one settings document
into the mission container
- `set_claude_cli_settings` points the provider at it
- `phase_runner::drain_finished_container_phases` collects the tap into
`mission_events`, idempotent by truncation
### The lesson worth keeping
Two of the three ranked recommendations below were written from reading the
code, and the first one was confidently wrong in a way that took a deployment
and a live mission to discover. The gate (§2) was right. The transport fix (§1)
was not — the evidence for it was an absence, and an absence had another
explanation.
## The original recommendations, kept for the record
### 1. ~~Observability — switch `claude_cli` to `stream-json`~~ *(WRONG — see above)*
In our fork: `--output-format stream-json --verbose`, parse `tool_use` /
`tool_result` blocks into `ChatResponse.tool_calls` instead of `Vec::new()`.
Unblocks: real `tool.call` events on the container tier; `ToolTrace.calls`
non-empty; Skill-Use **Trigger** becomes observable on that tier for the first
time. Risk: the parser must handle a stream rather than one object, and
`--verbose` is required alongside it.
### 2. A real gate — use the `PreToolUse` hook *(small, mechanism already proven)*
`vm_stop_gate.rs` records that `PreToolUse` **fires** under `claude -p` in our
image, and it is used nowhere — one doc-comment mention, zero call sites. Same
install pattern as the tap, but exit-2-to-deny instead of exit-0.
This is the only pre-execution gate available to the microVM and direct-session
paths, and it is the one thing that would let §15 mean something for missions.
It must be added to `vm_tool_tap::guest_settings`, the single settings writer,
or it will clobber `Stop` and `PostToolUse`.
Also: give the direct-session path the tap at all. It is the same settings file,
written into a container instead of a VM, and today that tier is completely dark.
### 3. Deploy the door we already built *(config, not code)*
Create `/zeroclaw-data/clawmates-mcp.json`, define a door-shaped provider alias,
and bind mission claws to it. Gets the container tier a gated actuator and makes
the `clawmates_skills` MCP server genuinely reachable — which would let skills
move from inlined bodies to progressive disclosure, and make Trigger measurable
the way the paper defines it.
Sequencing note: (1) and (2) are independent of (3) and worth doing first,
because they make the container tier legible before we change what it can do.
### Not recommended yet
Replacing the mission executor with `ProviderExecutor` or the chat `Runtime`.
The blockers are real and structural, not wiring: `Runtime::send_message`
requires a chat session row, an agent row, two message rows and an `agent_runs`
row, and its approval key is `(session_id, message_id)`; `ToolContext` carries
no path, checkout, container or VM handle; `cm-runtime`'s `files` tool is a blob
store that **rejects absolute paths by construction**, and its `shell` tool
executes in a per-agent sandbox with zero egress and no mission mount. Every
mission-shaped concept would have to be invented first.
The cheap fixes above deliver the observability and the gate. The executor swap
is a different project, and it should be justified by something other than tool
calls — which, it turns out, we already have.