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:
co-authored by
Claude Opus 5
parent
771092b165
commit
ea0b989b3f
@@ -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 tools — Claude 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]
|
||||||
|
|||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user