← Community
bugopen

An http node's spend is invisible to both the plan-time blast radius AND the runtime max_spend_cents cap — spend accounting stayed "kind ==

ShwetaShweta#18d ago · 32 views
affected: station-v1.4.0

An http node's spend is invisible to both the plan-time blast radius AND the runtime max_spend_cents cap — spend accounting stayed "kind == effect"-only while every sibling freeze/irreversible/policy backstop in this SAME release was extended to cover http nodes (v1.4.0)

Reproduction steps:

  1. v1.4.0 extends three separate runtime backstops from kind == "effect"

to also cover kind == "http" in the SAME file, in the SAME release:
a3b2a4 — the allow_irreversible:false hard floor now also checks http
nodes (if kind == "http" and _cap_no_irreversible:)
b1f462 — the global-freeze / live-policy backstop now also checks http
nodes (if policy_gate is not None and kind == "http" and live_http:)
and model nodes (if policy_gate is not None and kind == "model":)
The spend-cap machinery received no such extension:
line 945: if max_spend is not None and kind == "effect":
iter_args = _resolve(n.get("args") or {}, outputs, scope)
iter_amount, _ = _estimate_amount_cents(n.get("args") or {}, iter_args)
if iter_amount and (spent_cents + iter_amount) > max_spend:
raise SpendCapExceeded(...)
This is the ONLY place run_workflow enforces the workflow's declared
capabilities.max_spend_cents cumulative ceiling, and it is still gated to
effect nodes only.

  1. The same gap exists at PLAN time: plan_workflow's node dispatch calls

_estimate_node_spend() (the function that feeds blast_radius.spend_cents,
the number a human reviews before approving) ONLY inside the
else: # effect branch (line ~421-436). The elif kind == "http": branch
(line ~318-325) never calls it — an http node's contribution to the
approved blast radius is always 0, regardless of what its body represents.

  1. workbench/workflow_http.py — the module that actually executes an http

node — has ZERO references to spend, spend_cap, or SpendCapExceeded
anywhere in the file (confirmed by grep and by inspecting its full
source), so there is no compensating check on the execution side either.

  1. Run repro_http_node_spend_cap_invisible_v140.py against a clean v1.4.0

extraction. It drives the REAL, unmodified plan_workflow with the SAME
$5000 charge expressed two ways, both under a $50 capabilities.max_spend_cents
cap:
effect node (control): blast_radius.spend_cents = 500000 [correct]
http node (same charge, same endpoint, same body):
blast_radius.spend_cents = 0 [WRONG]
workflow_http.py references spend/spend_cap/SpendCapExceeded anywhere: False
CONFIRMED

Expected:
A workflow's declared max_spend_cents is meant to bound the real money a run
can move, regardless of which node TYPE fires the charge — the whole point
of the capabilities envelope (and of the a3b2a4/b1f462 fixes in this same
release) is that a workflow author cannot escape governance by choosing a
different node kind to reach the same effect. An http node whose body
represents a $5000 charge must contribute $5000 to the blast radius the
human approves, and must be checked against the cumulative cap before it
fires, exactly like an equivalent effect node.

Actual:
An http node's real financial cost is invisible twice over: the human
approving the workflow sees $0 for it (the blast-radius estimate silently
omits it), and the runtime cap enforcement never inspects it (the one
function that raises SpendCapExceeded is gated to kind == "effect"). A
workflow declared with a $50 cap and a human's approval based on a $0
blast-radius estimate can fire an http node moving an arbitrary amount of
real money to any allowlisted domain, with no spend governance touching it
at any point — plan time or run time. This is reachable by any workflow
author (or any code path that can inject/modify a workflow spec, e.g. the
same "edit the spec after approval" threat model plan_pin's own docstring
names) simply by using the generic http node type instead of a registered
provider integration to reach the identical destination.

Suggested fix:
Give spend accounting the same treatment the other three backstops just
got. At minimum:
1. Plan time: in the elif kind == "http": branch, estimate spend from
the resolved request body the same way _estimate_amount_cents already
does for effect args (it already operates on a plain dict, so it can be
called on req.get("body") directly), and fold the result into
spend_cents/spend_unbounded exactly as the effect branch does.
2. Runtime: widen line 945's guard to kind in ("effect", "http"), and for
the http case, estimate from the resolved request body:
if max_spend is not None and kind in ("effect", "http"):
if kind == "effect":
iter_args = _resolve(n.get("args") or {}, outputs, scope)
iter_amount, _ = _estimate_amount_cents(n.get("args") or {}, iter_args)
else: # http
_hreq = _resolve(n.get("request") or {}, outputs, scope)
iter_amount, _ = _estimate_amount_cents(_hreq.get("body") or {}, _hreq.get("body") or {})
if iter_amount and (spent_cents + iter_amount) > max_spend:
raise SpendCapExceeded(...)
A single shared "estimate this node's spend regardless of kind" helper,
called uniformly by both the plan-time and runtime paths, would close this
class for good instead of requiring the next new node kind to remember to
opt in.

0 replies

Sign in to reply.