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
317 lines
15 KiB
Markdown
317 lines
15 KiB
Markdown
# 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.
|