docs(research): missions DO call tools — the claim was wrong, and the truth is worse

Deep research into "missions can't call tools at all", which I wrote and
which is false. docs/TOOL-CALL-ARCHITECTURE.md has the full findings.

WHAT IS ACTUALLY TRUE

Three of the four mission paths end in `claude -p` with Claude Code's own
toolset and permissions PRE-ACCEPTED:

  solo microVM      Read Edit Write Bash Agent   --permission-mode acceptEdits
  composed microVM  same, per node               same
  direct session    Read Edit Write Bash         acceptEdits

So the 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 ours.

Observe and gate are different and both are partial. vm_tool_tap is a
PostToolUse hook: it fires AFTER the tool ran and exit-0s unconditionally,
so it is telemetry and structurally cannot gate. The direct-session tier has
no tap at all. GatePolicy has exactly one enforcement site — the chat loop —
and its approvals key on (session_id, message_id), which no mission phase
can produce.

WHY THE CONTAINER TIER LOOKED TOOL-FREE

`claude_cli` runs `claude -p --output-format json`, which returns a single
final result object, and the provider hardcodes `tool_calls: Vec::new()`.
The calls happen; the transport discards them. The comment reading that
emptiness as "§15 by construction: agents are provisioned tool-free" was
inferring a design property from a serialization choice.

Verified against the deployed Claude Code 2.1.228 rather than assumed:
`--output-format stream-json --verbose` emits `tool_use` blocks with the
tool name and `tool_result` blocks. The calls are fully observable; we ask
for the wrong format.

THE DOOR WE ALREADY BUILT AND NEVER PLUGGED IN

claude_cli.rs is OURS — upstream zeroclaw-labs/zeroclaw has no such file —
and so is 88eef99d4 "claude_cli --mcp-config + allow/disallow tools (act via
door)". The provider already accepts mcp_config (claude's own MCP client
reaches our door), tools, and disallowed_tools (lock out the natives so the
gated door is the ONLY actuator). agent.config.example.toml documents the
whole shape.

In the live runtime: clawmates-mcp.json does not exist, there is no
[providers.*] block, and every mission claw binds to claude_cli.default
which sets none of it. My earlier "claude_cli cannot reach MCP, therefore
the skills server is unreachable" was wrong in its reasoning — the
capability is built, documented by us, and never deployed.

Related: we set `agents.<alias>.mcp_bundles`, which configures ZeroClaw's
OWN MCP client for its native loop. A claude_cli agent's actuator is the
claude subprocess, which reads `mcp_config` on the PROVIDER. We were turning
a knob wired to a loop that does not run.

UPSTREAM

218 commits behind. No upstream work on claude_cli (the file is ours). ACP
already exists in the fork; the three new commits are workspace-default and
localization fixes, not new capability. The one item worth pulling is
"feat(plugins): add shared egress policy foundation (#9137)" — a network
guard with DNS pinning and metadata-address blocking, defence for the egress
problem we have not solved.

Stale claims corrected in place, in topology_exec.rs and the runtime config,
so the codebase stops asserting the thing that is false.

Recommended order, cheapest first: stream-json for observability; the
PreToolUse hook for a real gate (it FIRES under claude -p per vm_stop_gate,
and has zero call sites); then deploy the door. The executor swap is NOT
recommended — the blockers are structural, not wiring, and the cheap fixes
deliver what it was wanted for.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-19 13:06:59 -07:00
co-authored by Claude Opus 5
parent 771092b165
commit ea0b989b3f
3 changed files with 199 additions and 11 deletions
+21 -8
View File
@@ -7,11 +7,22 @@
//! gateway, opens `/ws/chat?agent=<alias>`, sends the role+task+context prompt, //! gateway, opens `/ws/chat?agent=<alias>`, sends the role+task+context prompt,
//! and streams the turn's events back into a [`TurnOutcome`]. //! and streams the turn's events back into a [`TurnOutcome`].
//! //!
//! **§15 by construction:** the agents are provisioned tool-free (every //! **These agents are NOT tool-free.** That claim stood here for months and is
//! sensitive capability is a gated Clawmates MCP tool — the "door"), so a turn //! false — see `docs/TOOL-CALL-ARCHITECTURE.md`. It was inferred from a frame
//! takes no sandbox-leaving action here. If the gateway nonetheless emits an //! stream that carried no tool events, and the emptiness has a different cause:
//! `approval_request`, we record it as a **blocked** `GatedAction` and end the //! `claude_cli` runs `claude -p --output-format json`, which returns a single
//! turn — we never auto-approve. //! final result object, and the provider hardcodes `tool_calls: Vec::new()`.
//! The agent calls Claude Code's own tools; the transport discards them.
//! `--output-format stream-json` emits `tool_use`/`tool_result` blocks —
//! verified against the deployed Claude Code 2.1.228.
//!
//! The door-shaped provider that WOULD make this true (`--mcp-config` +
//! `--disallowedTools` on the natives) is built and documented in
//! `agent.config.example.toml`, and is not deployed: mission claws bind to
//! `claude_cli.default`, which sets none of it.
//!
//! If the gateway emits an `approval_request` we still record it as a
//! **blocked** `GatedAction` and end the turn — we never auto-approve.
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
@@ -136,9 +147,11 @@ pub struct ToolTrace {
/// `chunk`, `done` and `session_start` and no tool frames at all. That is /// `chunk`, `done` and `session_start` and no tool frames at all. That is
/// not a protocol mismatch — `tool_call` is in the deployed binary /// not a protocol mismatch — `tool_call` is in the deployed binary
/// (`zeroclaw-gateway/src/ws.rs` emits `{"type":"tool_call","id","name", /// (`zeroclaw-gateway/src/ws.rs` emits `{"type":"tool_call","id","name",
/// "args"}`) — it is §15: these agents are provisioned tool-free behind the /// "args"}`) — and it is NOT that the agents are tool-free, which is what
/// MCP door, so they call nothing. The histogram is what let us tell those /// this comment used to say. `claude_cli` asks for `--output-format json`,
/// two apart, which was its whole purpose. /// so the subprocess's tool calls never reach the gateway to be framed.
/// The histogram still does its job: it distinguishes "no frames" from
/// "frames we do not recognise", and the answer was the former.
pub unmatched: std::collections::BTreeMap<String, u32>, pub unmatched: std::collections::BTreeMap<String, u32>,
} }
@@ -257,9 +257,13 @@ servers = ["clawmates_skills"]
# public_base_url. Ingress throttle: CLAWMATES_A2A_POLICY=deny (kill switch), # public_base_url. Ingress throttle: CLAWMATES_A2A_POLICY=deny (kill switch),
# CLAWMATES_A2A_RATE_LIMIT=<n>/hour. # CLAWMATES_A2A_RATE_LIMIT=<n>/hour.
# NOTE: `claude_cli` is a TEXT-ONLY provider`claude -p` doesn't surface # NOTE: `claude_cli` agents DO call toolsClaude Code's own — and they can
# tool-calls back to ZeroClaw, so claude_cli agents reason but can't invoke the # invoke the door too, via `mcp_config` (see [providers.models.claude_cli.door]
# door. An agent that ACTS through the door needs a tool-capable provider # above). What `--output-format json` does not do is surface those calls BACK to
# ZeroClaw: it returns one final result object, so the provider reports no tool
# calls even when the agent made several. `--output-format stream-json` emits
# `tool_use`/`tool_result` blocks (verified on Claude Code 2.1.228).
# See docs/TOOL-CALL-ARCHITECTURE.md. An agent that ACTS through the door needs a tool-capable provider
# (groq/anthropic/openai). Example actor agent (uncomment + provide groq creds # (groq/anthropic/openai). Example actor agent (uncomment + provide groq creds
# via env ZEROCLAW_providers__models__groq__default__{model,api_key}): # via env ZEROCLAW_providers__models__groq__default__{model,api_key}):
# [providers.models.groq.default] # [providers.models.groq.default]
+171
View File
@@ -0,0 +1,171 @@
# 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
| Path | Has tools | We observe | We gate |
|---|---|---|---|
| Solo microVM | yes | **yes**`vm_tool_tap` | no |
| Composed microVM | yes | **yes** — same tap | no |
| Direct session | yes | **no mechanism at all** | no |
| Container / ZeroClaw | yes (see below) | mechanism exists, receives nothing | no |
`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.
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 already exists, and we never plugged it 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:
- **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.
## What to do, cheapest first
### 1. Observability — switch `claude_cli` to `stream-json` *(small, proven)*
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.