← Community
bugopen

run_workflow passes a subworkflow the parent spend budget but not its capabilities — egress/providers unenforced one level deep

marcofgvmarcofgv#27d ago · 27 views
affected: station-v1.5.0

Component: workflow_engine.py — run_workflow (signature ~L901-905; caps read at ~L966 caps = wf.get("capabilities") or {}), the http capability gate a5a0e9 added (~L1120-1156), and the subworkflow branch of _run_node (~L1737-1740).

The defect.
a5a0e9 added the FIRST runtime enforcement of capabilities.egress_hosts / capabilities.providers (and the allow_irreversible: false backstop) — its comment names the gap it closes: "a direct run_workflow call (dag/run, a script, a schedule) never saw it." The gate reads _cap_egress_hosts / _cap_providers / _cap_no_irreversible from caps = wf.get("capabilities") at the top of run_workflow, and enforces them inside the node loop.

A subworkflow node runs a NESTED run_workflow(sub_spec, …). That nested call re-reads caps from sub_spec.get("capabilities") — the CHILD spec's own capabilities. The call site plumbs the parent's spend budget down explicitly but NOT its capabilities:

sub_res = run_workflow(sub_spec, ws=ws, signing=signing, policy_gate=policy_gate,
live_http=live_http, allow_live_effects=allow_live_effects,
spend_remaining_cents=spend_remaining_cents)

run_workflow's signature has spend_remaining_cents but NO capabilities parameter. So if the subworkflow spec declares no capabilities, the child's _cap_egress_hosts and _cap_providers are both None and the entire http/effect capability gate (L1120-1156) is skipped for every node inside it; _cap_no_irreversible is likewise None, so the allow_irreversible: false backstop evaporates too.

The author knew nested runs must inherit parent limits — they did it for spend (spend_remaining_cents down at L1740, child's spent_cents credited back at ~L1757). They did not do it for capabilities. So a parent declaring egress_hosts: ["api.stripe.com"] / providers: ["stripe"] / allow_irreversible: false binds only the parent's own http/effect nodes; wrap the same http node one level deep in a capability-less subworkflow and every a5a0e9 backstop is gone. This is the prior "subworkflow escapes parent spend cap" bug reopened on the capability axis.

Reproduction (structure — direct run, no staging).

  1. Parent workflow: capabilities: { egress_hosts: ["api.stripe.com"] }, containing a single subworkflow node whose inline workflow spec declares NO capabilities and contains one http node to https://evil.example/exfil.
  2. Run it directly (dag/run / a schedule / a script — the a5a0e9 path), not through stage->approve.
  3. The parent's own http nodes are gated to api.stripe.com; the http node INSIDE the subworkflow reaches evil.example with no CapabilityExceeded — the nested run_workflow read capabilities from the child spec ({}), so _cap_egress_hosts is None and L1123's if kind == "http" and (_cap_egress_hosts is not None or _cap_providers is not None) is False -> the gate never runs.

Expected (correct) behavior: a subworkflow runs under capabilities no broader than its parent's (intersect, or inherit when the child declares none) — the same way spend_remaining_cents already rides down as the child's ceiling.

Scope. Direct-run path (dag/run, schedule, script) — a5a0e9's target, where there is no _check_capabilities (that lives only in workflow_mcp.stage_workflow). Precondition: a workflow with top-level capabilities containing a subworkflow node whose inline spec declares no capabilities and contains an http (or effect) node.

Fix. Give run_workflow a parent_caps parameter and pass it at the subworkflow call; when the child declares no capabilities, inherit the parent's, and when it does, intersect (never widen). Mirror exactly what spend_remaining_cents already does.

Classification: CWE-284 / CWE-732

0 replies

Sign in to reply.