The gap
capabilities.tools is the authoritative allowlist of what an agent may reach — PR #59 wired it,
sanitizeToolList validates it, and toolNamesFor is what turns it into a gate. Three surfaces
enforce it:
agent-think.ts:657 — the chat runtime builds allowedToolNames and hands the model nothing else
instance-tool-policy.ts:123 — /v1/instances/:id/tools marks an undeclared tool not_declared
supervision-capability.ts:35 — delegation checks the target's declared set
lib/pipeline.ts does not. runPipelineStep builds its registry context by hand at
pipeline.ts:389:
const registryCtx = { env: ctx.env, userId: ctx.userId, instanceId: ctx.instanceId, traceId: ctx.traceId };
and dispatches at :400 / :408. runRegistryTool (tool-registry.ts:383) enforces exactly two
things: the scope-vs-read-only-connector check (#86, :392) and the per-instance write-consent gate
(#90, :402). Neither consults toolNamesFor, and nothing else on that path does either. The file's
own header comment at pipeline.ts:3 says the dispatch goes through "the single path enforcing
connector auth/grant/consent" — which is accurate, and is the whole point: connector consent and
declared capability are different questions, and only the first one is asked here.
So a stored pipeline definition can call any tool in the registry, including tools the agent
never declared and which its own console would render as not_declared.
Why this is worth more than a shrug
Read-scoped tools are ungated outright. Write-scoped ones still need a standing per-instance
connector write-consent row, which is a real gate — but it is granted per connector, not per tool,
and it is the same row that legitimises the pipeline's intended write. Consent to
create_ticket-style work through a connector is not consent to every other write tool that
connector owns. tmux_run_command (connectors/tmux.ts:79, scope: "write") is arbitrary shell on
the owner's machine and sits behind the same one row as its read-only siblings.
Pipelines are the surface where this matters most, because a pipeline is data. agents.config.pipelines
is copied into every new instance on subscribe, and #141 lets capabilities be declared from config.
The declared-tools allowlist is the mechanism that is supposed to make that safe.
What I am not claiming
This may be deliberate — a pipeline is authored by the agent's creator, not by a model at runtime,
so one reading is that the pipeline definition is the declaration. If so it should be written down
at pipeline.ts:389 and in docs/connector-manifest.md, because every other surface reads the
opposite rule from the same field, and #58's third-party opening is exactly when an unwritten
exemption becomes load-bearing.
Suggested fix
Resolve the instance's capabilities once per run and pass the declared set into PipelineRunCtx, so
runPipelineStep refuses an undeclared tool with the same vocabulary instance-tool-policy.ts
already uses (not_declared). Failing closed on a pipeline that names an undeclared tool is also a
better authoring error than discovering it at run time.
Found while assessing #322 (standing policies), where the relevant question was what a
timer-initiated pipeline can reach without a human in the loop. Verified against main at db8a2af.
The gap
capabilities.toolsis the authoritative allowlist of what an agent may reach — PR #59 wired it,sanitizeToolListvalidates it, andtoolNamesForis what turns it into a gate. Three surfacesenforce it:
agent-think.ts:657— the chat runtime buildsallowedToolNamesand hands the model nothing elseinstance-tool-policy.ts:123—/v1/instances/:id/toolsmarks an undeclared toolnot_declaredsupervision-capability.ts:35— delegation checks the target's declared setlib/pipeline.tsdoes not.runPipelineStepbuilds its registry context by hand atpipeline.ts:389:and dispatches at
:400/:408.runRegistryTool(tool-registry.ts:383) enforces exactly twothings: the scope-vs-read-only-connector check (#86,
:392) and the per-instance write-consent gate(#90,
:402). Neither consultstoolNamesFor, and nothing else on that path does either. The file'sown header comment at
pipeline.ts:3says the dispatch goes through "the single path enforcingconnector auth/grant/consent" — which is accurate, and is the whole point: connector consent and
declared capability are different questions, and only the first one is asked here.
So a stored pipeline definition can call any tool in the registry, including tools the agent
never declared and which its own console would render as
not_declared.Why this is worth more than a shrug
Read-scoped tools are ungated outright. Write-scoped ones still need a standing per-instance
connector write-consent row, which is a real gate — but it is granted per connector, not per tool,
and it is the same row that legitimises the pipeline's intended write. Consent to
create_ticket-style work through a connector is not consent to every other write tool thatconnector owns.
tmux_run_command(connectors/tmux.ts:79,scope: "write") is arbitrary shell onthe owner's machine and sits behind the same one row as its read-only siblings.
Pipelines are the surface where this matters most, because a pipeline is data.
agents.config.pipelinesis copied into every new instance on subscribe, and #141 lets capabilities be declared from config.
The declared-tools allowlist is the mechanism that is supposed to make that safe.
What I am not claiming
This may be deliberate — a pipeline is authored by the agent's creator, not by a model at runtime,
so one reading is that the pipeline definition is the declaration. If so it should be written down
at
pipeline.ts:389and indocs/connector-manifest.md, because every other surface reads theopposite rule from the same field, and #58's third-party opening is exactly when an unwritten
exemption becomes load-bearing.
Suggested fix
Resolve the instance's capabilities once per run and pass the declared set into
PipelineRunCtx, sorunPipelineSteprefuses an undeclared tool with the same vocabularyinstance-tool-policy.tsalready uses (
not_declared). Failing closed on a pipeline that names an undeclared tool is also abetter authoring error than discovering it at run time.
Found while assessing #322 (standing policies), where the relevant question was what a
timer-initiated pipeline can reach without a human in the loop. Verified against
mainatdb8a2af.