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.
The guarantee #381 bought, and the door it left open
b11a740states the point of the pre-flight check plainly: the rule is applied "as the same rule askedof 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
toolfield.ToolNamingSteps(
workers/api/src/lib/pipeline-tool-policy.ts) is literally:But three step handlers dispatch a connector tool from inside themselves, without it appearing
anywhere in the definition:
steps.ts:663—fan_outinpagesmode →runRegistryTool("http_request", ctx, pageReq)steps.ts:786—geocode→runRegistryTool("http_request", ctx, {…}), unconditionallysteps.ts:706—enrich→runRegistryTool(tool, …)wheretoolcame from the step's inputsAll three step names are part of the step library, so they are exempt by the derived rule (no
connectorfield ⇒ nothing a creator could declare ⇒ never gated). The pre-flight therefore sees adefinition naming only exempt steps and passes it.
runRegistryToolthen refuseshttp_requestatdispatch, 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.
enrichis 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 inrunRegistryTool. That argument is about security and it holds. This issue is about the other half —geocodeandfan_outname no tool anywhere, not even in their inputs, so no author could know theirpipeline 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_finderchain containsgeocode. Oncehttp_requestis declared, that path is fine. But thenext author who builds a
geocode-only pipeline gets a green attach, a green kick, and a half-spentfailed 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"]) thatpipelineDeclarationErrorunions with the step names it already reads.
enrich's tool is dynamic and cannot be fully resolvedstatically, but when its
inputs.toolis 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 —
"
geocodeneedshttp_request, which this agent does not declare" is actionable; "http_requestisnot 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
geocodefails 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
mainat21ecf79by reading the handlers and thepre-flight interface, not by running a pipeline.