Repository navigation
[bug] The tool listing reports write-gated tools as allowed=true, reason=ok — agents burn a turn discovering the consent gate #351
Description
What happened
A fresh tmux Operator instance (cda75e28-cace-4958-ac3e-6a7528e6b719) was asked to create a tmux
session. It planned the work, called the tool, and was refused:
❌ terminal_new_target — Writing via the terminal connector isn't permitted for this agent.
Correct refusal — a new subscription has no write consent (#90, migration 0051). The agent then
reported it accurately, which is the right behaviour.
The problem is what GET /v1/instances/:id/tools had told it a moment earlier:
terminal_run_command connector=terminal scope=write allowed=true reason=ok
terminal_send_keys connector=terminal scope=write allowed=true reason=ok
terminal_new_target connector=terminal scope=write allowed=true reason=ok
terminal_kill_target connector=terminal scope=write allowed=true reason=ok
A field named allowed reading true, with reason: "ok", on four tools that cannot run.
This is deliberate, and the rationale is sound
workers/api/src/lib/instance-tool-policy.ts:17-19:
Write-consent (#90) is a THIRD, separate gate enforced in runRegistryTool. It is deliberately
not merged here: consent answers "may this act on an external system as me", while this answers
"is this tool part of this agent at all". A tool can be allowed here and still be [refused].
Keeping the gates separate is right — merging them would make "is this tool part of this agent"
unanswerable while consent is off, and the fail-closed enforcement in runRegistryTool is where
it belongs. The ask is not to merge the gates. It is that the listing does not report the
second one, so its consumer cannot distinguish "you have this tool" from "you have this tool and
it will work".
The cost is small but repeated: every agent with an ungranted write connector burns a turn
discovering it, and the discovery is a failed side-effecting call rather than a read.
Fix
Add a separate, additive field to each tool in the listing — e.g.
writeConsent: "granted" | "required" | "n/a" — computed from hasConsent()
(lib/connector-consent.ts:18). Leave allowed and reason exactly as they are, so the existing
meaning and the documented separation both survive.
Then the runtime prompt can say "you have this tool but write consent is not granted; ask the
owner to enable it" before spending a call, and the console can render the state without a
second request.
Related trap found while confirming this
The instance's surface is tmux (capabilities.surfaces: ["tmux"]) but its tools come from the
terminal connector, so the consent that unblocks it must be granted against terminal.
Granting tmux write would look right and do nothing.
That is because there are two overlapping connectors in the registry:
| connector | tools | status |
|---|---|---|
terminal |
terminal_list_targets, _capture, _run_command, _send_keys, _new_target, _kill_target |
generic — tmux + kitty + iTerm2 (connectors/terminal.ts) |
tmux |
tmux_list_sessions, tmux_capture_pane, tmux_run_command, tmux_send_keys, tmux_new_session, tmux_kill_session |
tmux-only, superseded (connectors/tmux.ts) |
Same capability, two vocabularies, two consent keys. The operator agents declare only the
terminal_* set, so tmux_* is dead weight that can still be declared by mistake — and a
mismatch between the surface name and the consent key is exactly the kind of thing that gets
diagnosed as "consent is broken". Worth either retiring tmux or documenting it as an alias with
shared consent; separate decision from the field above, but they surfaced together.
Verification
- A tool that will be refused by write consent is distinguishable from one that will run, in a
single listing response. allowed/reasonkeep their current meanings; no existing consumer changes behaviour.- Granting write consent on the connector the tools actually belong to is discoverable from the
listing without reading the source.
Activity
fix(api,console): the tool listing reports the write-consent gate it …
docs(api): the tool listing's published schema says what the fields m…
fix(console): the connector a consent chip names has a switch to gran…
serge-ivo commentedon Aug 7, 2026
Landed in
1c1fb95(+f347feedocs,044f898follow-through).Claims checked, all held.
resolveToolPolicyhad no consent knowledge at all, so every write-scoped tool on an ungranted connector readallowed:true, reason:"ok". Theterminalvstmuxtrap is real too — the entry already carries the tool's registryconnector, and consent is now matched on exactly that, sotmuxwrite on a tmux-surface agent visibly does not satisfyterminal_*.Shape:
writeConsent: "n/a" | "granted" | "required" | "per_call", additive.allowed,disabledandreasonare untouched, and the gates stay separate inrunRegistryToolexactly as documented.The per-call gates. A listing that reads only the declared scope cannot see them, and there are two:
http_requestis honestlyscope:"read"andexecuteHttpRequestgates on the method resolved for each call. Detected from the tool's own schema (a caller-chosenmethodon a read tool), which is the same derivationsecurity-invariants.test.tsuses to find this set — so a new method-choosing tool is classified without anyone remembering to list it. Without connector consent it reportsper_call(reads run, a mutating verb is refused); with consent,granted.mcp_call_toolreportsper_call**even whenmcpwrite consent is granted**, because the connector row is only the outer gate and each call still needs a grant for that (endpoint, tool). Reportinggrantedoff the connector row alone would over-report in exactly the direction this ticket is about.Console: an amber chip on the tool row — "needs terminal write access" / "writes need http access" / "granted per server + tool". Amber, not red: an ungranted write tool is a switch the owner has not flipped, not a fault. Following that through exposed a second dead end and fixed it in
044f898: the write-access checkboxes were built from write-scoped tools only, sohttphad no checkbox anywhere — the chip would have named a grant with no control to give it.MCP:
list_instance_tools' description documents the new field and what each value means for a call about to be made, so the reading agent learns the gate before spending a turn on it. Injecting it into the chat prompt was deliberately left out of scope — that is a change toagent-think's context builder, not to this listing.Not addressed here, as the ticket says it is a separate decision: retiring the superseded
tmuxconnector in favour ofterminal, or aliasing their consent.CI green.
refactor(console): the Settings tab stops saying two things about one…
fix(agent): the tool list states the consent an agent HAS, not the ru…
fix(console): what a tool REACHES is a fact the server sends, so the …
fix(connectors): each write-consent checkbox says what granting IT pe…