Skip to content

[design] Capability constraints — declare what an agent may PASS, and enforce it at the tool boundary #404

Closed

Description

@serge-ivo

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

  • 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

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

  1. serge-ivo commented on Aug 8, 2026

    @serge-ivo
    ContributorAuthor

    Shipped in d8eb4f8. Deployed. The primitive plus its first consumer, per the direction doc.

    1. Vocabulary — surfaceOptions extended, no sibling invented. CONNECTOR_CONSTRAINTS is the closed table; a SurfaceSpec entry may carry constraints, written flat ({terminal:{backends:["tmux"]}}). One deliberate inversion, documented in the file: optionsFor gates on surfaces because an option must never switch a surface on, but constraintsFor does not gate, because a constraint only ever narrows and honouring it is the safe direction.

    2. Sanitiser. sanitizeDeclaredCapabilities needed no new branch — it already routes surfaceOptions through parseSurfaceOptions, 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, since optionsFor'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 backends ceiling. Out-of-scope backend refused, and out-of-scope target prefix (iterm2:1:1:1) refused too — without that the ceiling is decorative, since prefixed targets are how the tool is actually driven. An omitted/wildcard backend is narrowed to the permitted value rather than refused: the default is all, 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 callRunner was never called and that the message is not the consent one. Also pinned: an agent declaring nothing still sends backend: "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:

  2. serge-ivo commented on Aug 8, 2026

    @serge-ivo
    ContributorAuthor

    Verified against the code. Closing.

    Re-checked d8eb4f8 (ancestor of 52741d0) criterion by criterion, reading the code and finding the
    specific test for each rather than taking the close-out comment as evidence. All five are met.

    # Acceptance Verdict Code Test
    1 Declared, sanitised, persisted, resolved ✅ surface-options.ts:85,122-132,144-159, agent-capabilities.ts:409-411,471-473 agent-capabilities.test.ts:401,408,419; surface-options.test.ts:145-177
    2 Subscriber may narrow, may not widen ✅ (see note) surface-options.ts:231-247, agent-capabilities.ts:561-584 agent-capabilities.test.ts:463 (partial and wholly out-of-scope widen); surface-options.test.ts:220,226
    3 Refused in runRegistryTool, message names the constraint ✅ tool-registry.ts:466-486 (after consent :430-442, before handler :496); message built at surface-options.ts:291 tool-registry.test.ts:257 asserts the message contains `terminal.backends` (tmux); terminal.test.ts:161 asserts callRunner never called
    4 Absent constraints ⇒ byte-identical ✅ surface-options.ts:285 returns the caller's object by identity surface-options.test.ts:242 (identity); terminal.test.ts:200 (still sends backend:"all", still captures iTerm2); agent-capabilities.test.ts:424
    5 Consent granted + argument out of scope ⇒ refused ✅ same gate, outside the scope === "write" branch terminal.test.ts:177 — asserts callRunner untouched, message is not the consent one (not.toMatch(/isn't permitted/i)), and is the constraint one

    All seven of the close-out's specific claims also hold: executor-resolved via consentInstanceOf
    (tool-registry.ts:468, test tool-registry.test.ts:263 asserts asked === ["sub-1"] for
    onBehalfOf: "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 with
    env: {} so nothing could be read); wildcard/omitted backend narrowed not refused; out-of-scope
    target 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_CONSTRAINTS has exactly one field (terminal.backends), pinned by
    surface-options.test.ts:333. targets: "single" and the subscriber-bound target are #402's, as
    this ticket's own "Blocks/serves" line says. Not a gap.

    Two residuals worth recording, neither blocking

    1. The gate is skipped when there is no authority. tool-registry.ts:467-469 enters the
      constraint block only if (authority) — i.e. an empty instanceId and userId bypasses the
      ceiling 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.
    2. A multi-value ceiling refuses an already-permitted prefixed target. With
      backends: ["tmux","kitty"], terminal_capture({target: "tmux:main"}) passes the prefix check
      (surface-options.ts:295-308) and is then refused at :309-332 with "pass backend explicitly",
      because allowed.length > 1. Harmless while every real ceiling is single-valued, untested in
      either 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 run was green on the implementing commit and CI
    has been green since.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    backendBackend / Worker / API workenhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions