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
15 KiB
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
BashandWritewith 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 0s 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 outBash,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_guardcannot see a mission's tool calls. SeeUPSTREAM-SCAN.mdandMISSION-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 inzeroclaw-infra::net_guardwith 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
--settingsonclaude_cli— forkbe9c34b1ccontainer_tool_hookswrites both hook scripts and one settings document into the mission containerset_claude_cli_settingspoints the provider at itphase_runner::drain_finished_container_phasescollects the tap intomission_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)
claude_cli to stream-jsonIn 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.