Repository navigation
Description
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:
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).- A browser agent's permission scope ([browser][trust] Trust & permission model for browser agents (design prerequisite) #75) is a domain allowlist and an action allowlist — both arguments to tools the agent legitimately has.
- "This instance drives ONE tmux session" — tool allowed, one argument value allowed.
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
- A creator declares a constraint; it is sanitised, persisted, and resolved with capabilities.
- A subscriber can narrow or bind within it and cannot widen it — with a test for the widen attempt.
- A call carrying an out-of-scope argument is refused in
runRegistryTool, with a message naming which constraint applied — tested at the dispatcher, not at the console. - Absent constraints leave every existing agent behaving identically.
- Consent and constraints compose: consent granted + argument out of scope = refused.
Explicitly not
- Not a policy engine. Closed vocabulary per connector, reviewed like every other extension point. Free-form rules would hand a creator a language the platform cannot reason about — the objection [design] Standing policies — let the Lead keep repos true instead of reporting that they aren't #322 raised against free-text standing policies.
- Not a replacement for consent. Consent asks may this instance mutate at all; a constraint asks within what.
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).
Activity
docs: capability constraints — declaring what an agent may PASS, not …
feat(capabilities): an agent declares what it may PASS, and the dispa…
serge-ivo commentedon Aug 8, 2026
Shipped in
d8eb4f8. Deployed. The primitive plus its first consumer, per the direction doc.1. Vocabulary —
surfaceOptionsextended, no sibling invented.CONNECTOR_CONSTRAINTSis the closed table; aSurfaceSpecentry may carryconstraints, written flat ({terminal:{backends:["tmux"]}}). One deliberate inversion, documented in the file:optionsForgates onsurfacesbecause an option must never switch a surface on, butconstraintsFordoes not gate, because a constraint only ever narrows and honouring it is the safe direction.2. Sanitiser.
sanitizeDeclaredCapabilitiesneeded no new branch — it already routessurfaceOptionsthroughparseSurfaceOptions, so validation came free with the extension, which is itself the argument for extending the map rather than adding one beside it. A full-vocabulary declaration is dropped, so "a ceiling exists" always means "narrower than the platform"; junk reads as absent, never as "allow nothing".3. Enforcement is in
runRegistryTool, at dispatch, beside the write-consent gate — the genuinely new point, sinceoptionsFor's four existing call sites are all pre-dispatch. It resolves against the executor (consentInstanceOf), so a supervisor cannot widen a subordinate; it applies to read tools too, because listing every iTerm2 window is the #402 disclosure and no consent gate covers a read; it fails closed when the ceiling cannot be read; and it skips the lookup entirely for a connector with no vocabulary, so it costs nothing outside terminal.4. First consumer — the terminal
backendsceiling. Out-of-scopebackendrefused, and out-of-scopetargetprefix (iterm2:1:1:1) refused too — without that the ceiling is decorative, since prefixed targets are how the tool is actually driven. An omitted/wildcardbackendis narrowed to the permitted value rather than refused: the default isall, and refusing a defaulted argument would leave a tmux-only agent unable to list its own sessions.Every acceptance box has a test, including the two most often skipped: the subscriber widen attempt (two of them), and consent granted + argument out of scope = refused asserted at the dispatcher, checking
callRunnerwas never called and that the message is not the consent one. Also pinned: an agent declaring nothing still sendsbackend: "all"and still captures iTerm2 — the "absent constraints change nothing" regression that would otherwise be invisible. Plus a drift test tying the constraint table to all six tools' JSON-Schema enums.No migration; constraints live in the config JSON.
Left undone, deliberately:
targets: "single"binding — out of scope. The primitive is shaped for it: one more field in theterminalvocabulary entry plus a subscriber-bound value.agent_instances.config.surfaceOptions; nothing writes it yet. That belongs with [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's binding UI — the server-side boundary is complete without it.surfaceOptions.terminal.backendson them is what finally makes those three names true, and it is now purely a config change — which is the point of the ticket.lintAgentClaimsdoes not read constraints yet, and the tool schema shown to the model still advertises all three backends for a constrained agent (the in-band refusal names the constraint, so it self-corrects). Both are outside [design] Capability constraints — declare what an agent may PASS, and enforce it at the tool boundary #404's acceptance list; [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 wants the first.serge-ivo commentedon Aug 8, 2026
Verified against the code. Closing.
Re-checked
d8eb4f8(ancestor of52741d0) criterion by criterion, reading the code and finding thespecific test for each rather than taking the close-out comment as evidence. All five are met.
surface-options.ts:85,122-132,144-159,agent-capabilities.ts:409-411,471-473agent-capabilities.test.ts:401,408,419;surface-options.test.ts:145-177surface-options.ts:231-247,agent-capabilities.ts:561-584agent-capabilities.test.ts:463(partial and wholly out-of-scope widen);surface-options.test.ts:220,226runRegistryTool, message names the constrainttool-registry.ts:466-486(after consent:430-442, beforehandler:496); message built atsurface-options.ts:291tool-registry.test.ts:257asserts the message contains`terminal.backends` (tmux);terminal.test.ts:161assertscallRunnernever calledsurface-options.ts:285returns the caller's object by identitysurface-options.test.ts:242(identity);terminal.test.ts:200(still sendsbackend:"all", still captures iTerm2);agent-capabilities.test.ts:424scope === "write"branchterminal.test.ts:177— assertscallRunneruntouched, message is not the consent one (not.toMatch(/isn't permitted/i)), and is the constraint oneAll seven of the close-out's specific claims also hold: executor-resolved via
consentInstanceOf(
tool-registry.ts:468, testtool-registry.test.ts:263assertsasked === ["sub-1"]foronBehalfOf: "sup-1"); applies to read tools; fails closed on an unreadable ceiling(
tool-registry.ts:471-481); skips the lookup for a vocabulary-less connector (:467, tested withenv: {}so nothing could be read); wildcard/omittedbackendnarrowed not refused; out-of-scopetarget prefixes refused; and the drift test tying the table to all six tools' schema enums
(
terminal.test.ts:53).Criterion 2's "or bind" clause is deferred, and that is correct
CONNECTOR_CONSTRAINTShas exactly one field (terminal.backends), pinned bysurface-options.test.ts:333.targets: "single"and the subscriber-bound target are #402's, asthis ticket's own "Blocks/serves" line says. Not a gap.
Two residuals worth recording, neither blocking
tool-registry.ts:467-469enters theconstraint block only
if (authority)— i.e. an emptyinstanceIdanduserIdbypasses theceiling entirely, where an unreadable ceiling refuses. Every real call path supplies an instance,
so this is theoretical today, but it is an asymmetry with the documented fail-closed posture and
there is no test either way.
backends: ["tmux","kitty"],terminal_capture({target: "tmux:main"})passes the prefix check(
surface-options.ts:295-308) and is then refused at:309-332with "passbackendexplicitly",because
allowed.length > 1. Harmless while every real ceiling is single-valued, untested ineither direction.
Neither is in #404's acceptance list, so I have not fixed them here. If either is wanted, it should
be its own ticket rather than an unticketed follow-on.
I did not run the suites in this pass — this was a read-only verification, so "the test asserts X" is
from the test source, not a green run.
pnpm vitest runwas green on the implementing commit and CIhas been green since.
feat(capabilities): an operator drives ONE terminal, chosen by its su…
fix(connectors): a folder grant is refused for an agent that cannot r…
docs(constraints): the capability gate's fail-closed posture is writt…
feat(capabilities): tmux gets a constraint vocabulary, so the one PUB…
fix(mcp): my_instances carries the words people search the full insta…