Skip to content

[bug] capabilities.tools is not enforced on pipeline steps — a stored pipeline can call any registry tool the agent never declared #381

Description

@serge-ivo

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    backendBackend / Worker / API workbugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions