The primitive underneath #402 and #75. Direction written up in docs/capability-constraints.md (this ticket is the build).
The gap in one line
An agent's capabilities can say which tools it has. They cannot say what it may pass to a tool it has — and every existing constraint is enforced by taking the tool away.
| Declaration |
Enforced by |
capabilities.tools |
toolNamesFor — undeclared tool not offered, refused if called |
surfaceOptions.coding.drive: false |
agent-do-tools.ts:200 removes send_to_cli/read_terminal from the set |
surfaceOptions.coding.repos: "single" |
the console hides add-repo |
| write consent (#90) |
runRegistryTool refuses at dispatch |
Withholding is a complete answer only while "may this agent do X" and "does it have tool T" are the same question. They stop being the same the moment one tool serves several resources:
Build
1. Vocabulary — extend surfaceOptions, do not invent a sibling. lib/surface-options.ts is already the creator-declared, sanitised, persisted, surface-gated constraint map, and its header argues the case for that shape. Add per-connector constraint specs; absent = today's behaviour byte-for-byte.
2. Resolution — with the rest of capabilities. sanitizeDeclaredCapabilities validates against a closed vocabulary per connector (the same discipline as steps, behaviour fields, stats sources, board actions). A creator may narrow; a subscriber may never widen — the ceiling is a catalog claim and lintAgentClaims (#362) checks descriptions against it.
3. Enforcement — runRegistryTool, at dispatch. Beside the write-consent gate, which is already the place a call is refused for reasons the model does not control.
This point is genuinely new, not a repair: optionsFor is consulted in exactly four places today (agent-do-tools.ts:200,215, agent-self-description.ts:162, coding-brains.ts:185,204) and none of them is runRegistryTool — because every existing constraint is applied before dispatch is reached.
It must be server-side. Two independent reminders this month that a boundary a model can talk around is not a boundary: the agent that wrote its own tool results and was believed (#395), and the mute guard whose first proposed fix would have deleted the capability it protected (ADR 0001). A prompt is a request; a gate is a fact.
4. First consumer in the same PR — terminal backends. Per the extraction rule this codebase already follows (work-card.ts: "extracted when the second domain arrived, not before"). Do not merge the primitive with no consumer.
Acceptance
Explicitly not
Blocks/serves: #402 (terminal ceiling + single-target binding), #75 (browser domain/action allowlist — second consumer, demand-gated; it should inherit this rather than grow its own).
The primitive underneath #402 and #75. Direction written up in
docs/capability-constraints.md(this ticket is the build).The gap in one line
An agent's capabilities can say which tools it has. They cannot say what it may pass to a tool it has — and every existing constraint is enforced by taking the tool away.
capabilities.toolstoolNamesFor— undeclared tool not offered, refused if calledsurfaceOptions.coding.drive: falseagent-do-tools.ts:200removessend_to_cli/read_terminalfrom the setsurfaceOptions.coding.repos: "single"runRegistryToolrefuses at dispatchWithholding is a complete answer only while "may this agent do X" and "does it have tool T" are the same question. They stop being the same the moment one tool serves several resources:
terminal_list_targetstakesbackend: "all"|"tmux"|"kitty"|"iterm2", defaulting toall— a tmux-only agent cannot be expressed by withholding, because the tool it needs is the one that also reaches iTerm2 ([design] A "tmux Operator" can drive iTerm2 — a creator cannot declare which backend an agent may touch, or that it owns exactly one terminal #402, [chore] The tmux, kitty and iTerm2 Operators are one agent wearing three names — declare backend-exclusive tools (no code) #403).Build
1. Vocabulary — extend
surfaceOptions, do not invent a sibling.lib/surface-options.tsis already the creator-declared, sanitised, persisted, surface-gated constraint map, and its header argues the case for that shape. Add per-connector constraint specs; absent = today's behaviour byte-for-byte.2. Resolution — with the rest of capabilities.
sanitizeDeclaredCapabilitiesvalidates against a closed vocabulary per connector (the same discipline as steps, behaviour fields, stats sources, board actions). A creator may narrow; a subscriber may never widen — the ceiling is a catalog claim andlintAgentClaims(#362) checks descriptions against it.3. Enforcement —
runRegistryTool, at dispatch. Beside the write-consent gate, which is already the place a call is refused for reasons the model does not control.This point is genuinely new, not a repair:
optionsForis consulted in exactly four places today (agent-do-tools.ts:200,215,agent-self-description.ts:162,coding-brains.ts:185,204) and none of them isrunRegistryTool— because every existing constraint is applied before dispatch is reached.It must be server-side. Two independent reminders this month that a boundary a model can talk around is not a boundary: the agent that wrote its own tool results and was believed (#395), and the mute guard whose first proposed fix would have deleted the capability it protected (ADR 0001). A prompt is a request; a gate is a fact.
4. First consumer in the same PR — terminal
backends. Per the extraction rule this codebase already follows (work-card.ts: "extracted when the second domain arrived, not before"). Do not merge the primitive with no consumer.Acceptance
runRegistryTool, with a message naming which constraint applied — tested at the dispatcher, not at the console.Explicitly not
Blocks/serves: #402 (terminal ceiling + single-target binding), #75 (browser domain/action allowlist — second consumer, demand-gated; it should inherit this rather than grow its own).