Skip to content

[bug] The #381 pre-flight check reads only step names, but geocode and fan_out dispatch http_request internally — so an exempt-looking pipeline is refused mid-run instead of at kick #396

Closed

Description

@serge-ivo

The guarantee #381 bought, and the door it left open

b11a740 states the point of the pre-flight check plainly: the rule is applied "as the same rule asked
of a whole definition ahead of time, so an authoring mistake is a 400 on attach rather than a refusal
eight steps into a run that has already spent money."
Three statements of one rule — attach 400, kick
refusal before any side effect, dispatch refusal.

The pre-flight arm reads only a step's tool field. ToolNamingSteps
(workers/api/src/lib/pipeline-tool-policy.ts) is literally:

export interface ToolNamingSteps {
	steps: ReadonlyArray<{ tool: string }>;
}

But three step handlers dispatch a connector tool from inside themselves, without it appearing
anywhere in the definition:

  • steps.ts:663 — fan_out in pages mode → runRegistryTool("http_request", ctx, pageReq)
  • steps.ts:786 — geocode → runRegistryTool("http_request", ctx, {…}), unconditionally
  • steps.ts:706 — enrich → runRegistryTool(tool, …) where tool came from the step's inputs

All three step names are part of the step library, so they are exempt by the derived rule (no
connector field ⇒ nothing a creator could declare ⇒ never gated). The pre-flight therefore sees a
definition naming only exempt steps and passes it. runRegistryTool then refuses http_request at
dispatch, mid-run.

So for a pipeline built from exempt steps, the guarantee inverts: the run starts, spends, and is
refused partway through
— the precise outcome the kick check exists to prevent, reached through a
different door.

enrich is a partially-known case: the commit calls out that a name-only gate would be walked past by
{"tool":"enrich","inputs":{"tool":"tmux_run_command"}}, which is why the enforcement lives in
runRegistryTool. That argument is about security and it holds. This issue is about the other half —
geocode and fan_out name no tool anywhere, not even in their inputs, so no author could know their
pipeline depends on http_request, and no static reading of the definition can tell them.

Why it matters now rather than in principle

The #394 audit found the account's only pipeline-running agent is refused at kick today, and its
lead_finder chain contains geocode. Once http_request is declared, that path is fine. But the
next author who builds a geocode-only pipeline gets a green attach, a green kick, and a half-spent
failed run — with a refusal message naming a tool their pipeline never mentions.

Suggested fix

Give a step handler a way to declare the registry tools it may dispatch — a static field on the step
definition (geocode → ["http_request"], fan_out → ["http_request"]) that pipelineDeclarationError
unions with the step names it already reads. enrich's tool is dynamic and cannot be fully resolved
statically, but when its inputs.tool is a literal it can be, and that covers the authoring mistake.

Whatever the shape, the refusal message should name the STEP that needs the tool, not just the tool —
"geocode needs http_request, which this agent does not declare" is actionable; "http_request is
not declared" sends the author looking for a step that does not exist.

What this does not claim

It does not claim the dispatch-time gate is wrong — it is the security boundary and must stay. It does
not claim a side effect always occurs before the refusal; a pipeline whose first step is geocode
fails early and cheaply. The defect is that the guarantee is unstated and unreliable, not that it never
holds.

Found by the #394 live-gate audit. Verified against main at 21ecf79 by reading the handlers and the
pre-flight interface, not by running a pipeline.

Activity

  1. added
    bugSomething isn't working
    backendBackend / Worker / API work
    on Aug 7, 2026
  2. serge-ivo commented on Aug 8, 2026

    @serge-ivo
    ContributorAuthor

    Picked this up for implementation and found it already delivered — shipped in 1905259, which is on main and deployed.

    Verified against the code, following the issue's own suggested shape:

    • A step declares what it dispatches. ToolDef gained dispatches?: readonly string[] and dispatchesFromInput?: string (lib/connectors/types.ts), and both are declared in steps.ts in the same object literal as the handler that does the dispatching — fan_out → ["http_request"] (steps.ts:617), enrich → dispatchesFromInput: "tool" (:692), geocode → ["http_request"] (:779). That placement is the fix, not a convenience: the defect was that "what this step reaches" lived somewhere other than the code reaching it, so a hand-kept table beside steps.ts would have been the same bug one file over.
    • pipelineDeclarationError unions them with the step names it already read, and pipeline-tool-policy.ts stays a leaf — the declarations arrive through the same RegistryToolLookup function argument the connector field already came through, so import-graph.test.ts keeps its answer.
    • fan_out over-declares deliberately. Only pages mode dispatches, but a mode-conditional declaration is a predicate over inputs and a predicate is exactly what a static pre-flight cannot check. The trade the issue implicitly asked for — one extra read-scoped name in capabilities.tools, discovered while the author is sitting there, against a mid-run refusal — is taken explicitly, and its cost is named: a grid-only fan_out is now refused at attach.
    • enrich is resolved only when literal, which is precisely the scope the issue drew. A {"$param":"tool"} yields nothing rather than a guess, because a pre-flight message the author is meant to trust must not speculate — and the dispatch-time gate in runRegistryTool is untouched and still refuses {"tool":"enrich","inputs":{"tool":"tmux_run_command"}} exactly as strictly. The issue's "does not claim the dispatch-time gate is wrong" holds in the code.
    • The refusal names the step, as asked: per-use wording — step 0 ("geocode") needs "http_request", step 1 ("enrich") runs "web_search" per record — deduped by tool rather than by (step, tool). The dispatch-time message got the same treatment via a new RegistryToolCtx.stepTool, used for wording only; the rule reads the same three inputs.
    • It cannot silently rot. step-dispatch.test.ts derives the table from the handler source — every runRegistryTool( call attributed to its nearest enclosing name: "…" — and asserts equality with the registry in both directions, so a new dispatching step without a declaration fails, and so does a stale declaration left after a call is deleted (a phantom requirement refuses a working pipeline at attach, which reads as the platform being broken). Modules that dispatch a caller-named tool are pinned by name as surfaces so a new one must be classified deliberately.

    Also landed and worth knowing, since the #394 audit had to infer it: a run's trace now records what an enrich actually dispatched (StepResult.dispatched, read off resolved inputs), so context.tool no longer says only "enrich".

    Tests green on current main: step-dispatch.test.ts (5), pipeline-tool-policy.test.ts (27), pipeline-run-start.test.ts (17), seed-drift.test.ts (7) — 79 passing across the group. seed-drift.test.ts now derives site-builder's gated set through the real rule rather than mapping steps[].tool itself, so a future geocode in either seeded pipeline fails there instead of in production, and a test pins the observation from this issue's own text: lead-finder.json needs http_request twice over — at step 2 explicitly and at step 0 through geocode — so deleting the explicit step would not remove the requirement.

    Closing as delivered.

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