Every node type the DAG engine supports has an explicit branch in
plan_workflow()'s per-node dispatch (transform / http / merge / subworkflow
/ wait), each contributing its real spend/systems/egress/irreversible
signal to the aggregate blast radius a human approves once before a
workflow is allowed to run live. The new type: "agent" node (shipped
v0.72, the governed LLM tool-use loop) has no branch at all -- it falls
into the catch-all else meant for ordinary effect nodes, which doesR.resolve_node(n) expecting an action_id/provider an agent node
doesn't have. That returns None, so the else-branch raises KeyError, which
is caught by the per-node try/except and recorded as `policy: {"decision":
"block", ...}` for that one node -- but the node contributes NOTHING else:
no spend estimate, no systems_touched entry, no egress_domains entry, no
irreversible entry, despite declaring its own tools/`budget.
max_spend_cents` right there in the node body.
The per-node "block" decision this produces IS visible in the response
(blast_radius.requires), but nothing on the live-run path actually reads
it before executing. routes/dispatch_workflow.py's live-run handler
(_handle_dag_run) re-plans and passes the result to primitives/plan_pin.py
check(), which gates a live run purely on whether the plan's root hash and
spend_cents match a previously-approved (pinned) plan -- it never looks atrequires/decision at all. Since spend_cents is 0 for any agent-node
plan (the crash never contributed a real number), the first-ever live run
auto-pins this empty-looking plan as "approved" (the documented migration
path for a never-before-pinned workflow), and every subsequent run matches
it structurally forever after -- an approver never sees, and is never
asked to see, what the agent is actually authorized to do.
Reproduction steps:
- Extract a clean station-v0.73 tarball, sys.path.insert(0, "workbench").
- import workflow_engine as WE
- Build a workflow with one node shaped exactly per this station's own
docs/agent_nodes_spec.md example:
{"id": "n1", "type": "agent", "model": "primary",
"system": "You are an autonomous purchasing agent.",
"tools": ["stripe.charge_create"],
"budget": {"max_spend_cents": 500000, "max_tool_calls": 12,
"max_steps": 8, "timeout_s": 120},
"disposition": "auto"}
- plan = WE.plan_workflow(wf, signing=<any object with a .sign() method>)
Expected: the blast radius reflects that this workflow contains an agent
authorized to spend up to $5000 through a live payment action -- at minimum
contributing to spend_cents/systems_touched/irreversible the way a static
effect node with the same tools would, and forcing requires to
require_human/block for real (and having that actually consulted before a
live run, the way the existing "hard floor" comment in plan_workflow()
already promises for every other node type).
Actual: blast_radius == {"systems_touched": [], "egress_domains": [],
"irreversible": [], "spend_cents": 0, "spend_unbounded": false, "requires":
"block"} -- reads as a completely inert, zero-consequence workflow. The
"block" is never enforced by the only code that actually gates a live run.
Root cause: workbench/workflow_engine.py, plan_workflow()'s per-node
dispatch (~line 267-331) has explicit branches for "transform", "http",
"merge", "subworkflow", "wait", and an else for ordinary effect nodes --
no branch for "agent". The KeyError from R.resolve_node(n) returning None
is swallowed by the surrounding try/except (~line 332-334) instead of
being a real per-node classification, so it silently reports as
zero-consequence rather than propagating any signal a caller enforces.
Suggested fix: give plan_workflow() a real branch for kind == "agent"
that reports the node's declared tools/budget.max_spend_cents in the
blast radius (systems touched = the providers of every granted tool,
spend_cents = the node's max_spend_cents, since an agent's actual runtime
spend can't be known at plan time), and forces requires to at least
require_human. Separately, have the live-run path
(routes/dispatch_workflow.py _handle_dag_run) actually check
blast_radius.requires/the per-node decision before calling run_workflow(),
not only plan_pin's structural hash/spend comparison.