The claim
list_instance_tools describes itself, verbatim (workers/mcp/src/instance-tools/base.ts:29):
"Returns EVERY registry tool with this instance's verdict … Use this to verify an agent is read-only before trusting it with sensitive data — a tool absent from the allowed set cannot be invoked, by chat or by call_instance_tool."
The second half is a security claim, and it is half wrong. It is true for call_instance_tool. It is false for chat.
Measured
Instance bd43f4de-ef35-4051-bdec-43f8571414a1 (Repo Coder, capabilities.tools declared), 2026-08-12:
-
GET /v1/instances/:id/tools returns 70 entries; ?allowed=true returns 22.
-
toolNamesFor() grants every agent the 19-name BASE set (workers/api/src/agent-do-tools.ts:15-…, and toolNamesFor at :203-205 starts from new Set(BASE) whenever an allowlist is declared).
-
11 of those 19 appear nowhere in the listing, allowed or otherwise:
read_memory · write_memory · delete_memory · get_tasks · create_task · update_task · fetch_url · get_activity · get_user_context · set_user_preference · configure_board
Six of the eleven write (memory create/delete, task create/update, user preference, board layout). One — fetch_url — makes outbound HTTP.
Two of them were invoked on this very instance within the audit window, both visible in agent_trace:
2026-08-11 21:45:52 — ❌ **fetch_url** HTTP 401 Unauthorized: … (it ran; the remote refused)
2026-08-12 03:22:21 — ✅ **write_memory** Stored memory: fact:Staging environment:decision. All memory keys: …
So an auditor who did exactly what the tool's description tells them to do — call list_instance_tools, confirm nothing that writes is allowed — would conclude this agent cannot write to its own memory. It wrote to its own memory nineteen hours later.
Mechanism
Two layers, each correct in isolation:
- The listing enumerates the registry only.
resolveToolPolicy (workers/api/src/lib/instance-tool-policy.ts:118-143) maps over registryTools() — [...REGISTRY.values()] (lib/tool-registry.ts:467). The DO's own catalog (lib/tools.ts AGENT_TOOLS, lib/storage-tools.ts STORAGE_TOOLS) is a different collection and is never walked. That is why start_work, check_work, get_behaviour, run_pipeline, record_feedback and get_stats DO appear — they happen to be registry tools — while write_memory and fetch_url do not.
- The gate the description describes is real, and it is only on one route.
POST /v1/instances/:id/tools/:name calls getRegistryTool(name) and 404s a non-registry name (routes/tools.ts:141-146), so call_instance_tool genuinely cannot reach them. The chat runtime uses toolNamesFor + buildAgentToolDefinitions, which include them.
instance-tool-policy.ts's own header states the invariant this breaks: "'This agent is read-only' has to be a property of the INSTANCE, not of one surface's prompt-building." The policy module honours that for the registry; the listing built on it silently reports on one collection and is described as reporting on the agent.
What to do, cheapest first
- Fix the sentence. One-line docs change, ships today, removes the false assurance:
"Returns every connector/registry tool… A tool absent from the allowed set cannot be invoked through call_instance_tool. This listing does not cover the agent's built-in facilities (memory, tasks, board, fetch_url), which every agent has; use it to audit an agent's reach into external systems, not to establish that it cannot write at all."
Do this even if (2) is taken, because (2) is a behaviour change and this is a correctness fix to a security claim.
- Make the listing exhaustive. Include the BASE/storage tools with a
tier field (base vs connector) so a caller can still ask the narrow question. ToolDef already carries tier: "connector" (see repo-local.ts:362), so the vocabulary exists. resolveToolPolicy takes its tool list as a parameter — the change is at the call site in instanceToolPolicy, not in the pure function.
- Decide whether
disabledTools should cover them. PUT /v1/instances/:id/tools/:name also gates on getRegistryTool, so the owner's veto — described as "the one control a creator's declaration must not outrank" — cannot currently switch off write_memory or fetch_url. If (2) ships without this, the listing will show a tool the owner is shown no way to disable.
Open question for the owner
Is (2) wanted, or is the honest scoping in (1) enough? I would take (1) now and (2) next. The listing's audience is an auditor asking "what can this thing reach", and fetch_url — arbitrary outbound HTTP, SSRF-guarded but not consent-gated — is squarely inside that question. But (2) drags in (3), which is a real permissions decision, and the false sentence should not wait for it.
Alternatives rejected
- Leave it; the base tools are "universal so they don't count." The description does not say that, it says the opposite, and it says it in the context of trusting an agent with sensitive data. A universal capability is still a capability an auditor has to know about.
- Remove the security sentence entirely. It is answering a real question and the answer is nearly right; scoping it is better than deleting it.
Acceptance
- The description no longer asserts that absence from the list implies the tool cannot be invoked by chat.
- If (2):
GET /v1/instances/:id/tools for bd43f4de-…-shaped instance includes all 19 BASE names, and a test asserts toolNamesFor(caps) ⊆ {names in the listing} for a representative capability set — the invariant that would have caught this.
Regression risk
resolveToolPolicy is consumed by the agent runtime, the REST invoker and MCP (its header says all three share it). Adding entries changes what ?allowed=true returns, which the console's tool list and explainWriteConsent render — a base tool with no connector resolves to writeConsent: "n/a" by writeConsentOf's first branch, so it must not start rendering as ungated external access. contract.test.ts and tools.integration.test.ts are the guards.
Related: #351 (the last time this listing was confidently wrong in the permissive direction), #493 (the agent's own tool list never stated to be exhaustive — same failure, one surface over).
The claim
list_instance_toolsdescribes itself, verbatim (workers/mcp/src/instance-tools/base.ts:29):The second half is a security claim, and it is half wrong. It is true for
call_instance_tool. It is false for chat.Measured
Instance
bd43f4de-ef35-4051-bdec-43f8571414a1(Repo Coder,capabilities.toolsdeclared),2026-08-12:GET /v1/instances/:id/toolsreturns 70 entries;?allowed=truereturns 22.toolNamesFor()grants every agent the 19-nameBASEset (workers/api/src/agent-do-tools.ts:15-…, andtoolNamesForat:203-205starts fromnew Set(BASE)whenever an allowlist is declared).11 of those 19 appear nowhere in the listing, allowed or otherwise:
read_memory·write_memory·delete_memory·get_tasks·create_task·update_task·fetch_url·get_activity·get_user_context·set_user_preference·configure_boardSix of the eleven write (memory create/delete, task create/update, user preference, board layout). One —
fetch_url— makes outbound HTTP.Two of them were invoked on this very instance within the audit window, both visible in
agent_trace:2026-08-11 21:45:52—❌ **fetch_url** HTTP 401 Unauthorized: …(it ran; the remote refused)2026-08-12 03:22:21—✅ **write_memory** Stored memory: fact:Staging environment:decision. All memory keys: …So an auditor who did exactly what the tool's description tells them to do — call
list_instance_tools, confirm nothing that writes is allowed — would conclude this agent cannot write to its own memory. It wrote to its own memory nineteen hours later.Mechanism
Two layers, each correct in isolation:
resolveToolPolicy(workers/api/src/lib/instance-tool-policy.ts:118-143) maps overregistryTools()—[...REGISTRY.values()](lib/tool-registry.ts:467). The DO's own catalog (lib/tools.tsAGENT_TOOLS,lib/storage-tools.tsSTORAGE_TOOLS) is a different collection and is never walked. That is whystart_work,check_work,get_behaviour,run_pipeline,record_feedbackandget_statsDO appear — they happen to be registry tools — whilewrite_memoryandfetch_urldo not.POST /v1/instances/:id/tools/:namecallsgetRegistryTool(name)and 404s a non-registry name (routes/tools.ts:141-146), socall_instance_toolgenuinely cannot reach them. The chat runtime usestoolNamesFor+buildAgentToolDefinitions, which include them.instance-tool-policy.ts's own header states the invariant this breaks: "'This agent is read-only' has to be a property of the INSTANCE, not of one surface's prompt-building." The policy module honours that for the registry; the listing built on it silently reports on one collection and is described as reporting on the agent.What to do, cheapest first
tierfield (basevsconnector) so a caller can still ask the narrow question.ToolDefalready carriestier: "connector"(seerepo-local.ts:362), so the vocabulary exists.resolveToolPolicytakes its tool list as a parameter — the change is at the call site ininstanceToolPolicy, not in the pure function.disabledToolsshould cover them.PUT /v1/instances/:id/tools/:namealso gates ongetRegistryTool, so the owner's veto — described as "the one control a creator's declaration must not outrank" — cannot currently switch offwrite_memoryorfetch_url. If (2) ships without this, the listing will show a tool the owner is shown no way to disable.Open question for the owner
Is (2) wanted, or is the honest scoping in (1) enough? I would take (1) now and (2) next. The listing's audience is an auditor asking "what can this thing reach", and
fetch_url— arbitrary outbound HTTP, SSRF-guarded but not consent-gated — is squarely inside that question. But (2) drags in (3), which is a real permissions decision, and the false sentence should not wait for it.Alternatives rejected
Acceptance
GET /v1/instances/:id/toolsforbd43f4de-…-shaped instance includes all 19 BASE names, and a test assertstoolNamesFor(caps) ⊆ {names in the listing}for a representative capability set — the invariant that would have caught this.Regression risk
resolveToolPolicyis consumed by the agent runtime, the REST invoker and MCP (its header says all three share it). Adding entries changes what?allowed=truereturns, which the console's tool list andexplainWriteConsentrender — a base tool with noconnectorresolves towriteConsent: "n/a"bywriteConsentOf's first branch, so it must not start rendering as ungated external access.contract.test.tsandtools.integration.test.tsare the guards.Related: #351 (the last time this listing was confidently wrong in the permissive direction), #493 (the agent's own tool list never stated to be exhaustive — same failure, one surface over).