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.

Activity

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 workbugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions