This release enforces the signed approval policy's per-action verdict on the
static effect path in two tiers: a plan/approve gate that refuses a run when the
plan's blast radius requires "block" or any top-level node's sealed decision is
"block", and a runtime backstop in the workflow engine that, immediately before
an effect fires, evaluates the live policy and raises a fail-closed error on a
"block" verdict so the saga rolls back.
A subworkflow node runs another workflow as a nested unit. A policy-blocked
effect placed one subworkflow level down escapes BOTH tiers:
- Runtime backstop: the engine's subworkflow branch recurses into run_workflow
and forwards the live-effect gate and the http-live flag, but does NOT forward
the policy gate. Inside the sub-run the policy gate is therefore absent, and
the runtime backstop (which only acts when a policy gate is present and the
node kind is "effect") never evaluates the nested effect. The branch's own
comment states the opposite intent — "a nested effect must not escape the
opt-in the parent ran under" — but the policy gate is exactly what is dropped.
- Plan/approve gate: at plan time the subworkflow branch aggregates the nested
blast radius (systems, egress, irreversible list, spend) into the parent, but
never sets the subworkflow node's own policy decision from the nested plan's
verdict — it stays the per-iteration default "auto_approve" — and the nested
effect nodes are not flattened into the parent plan's node list. So the parent
plan records no "block": its blast_radius.requires is at most "require_human"
(via the hard floor, because the nested effect is irreversible), and no
top-level node carries decision "block". The approve gate refuses only on a
"block", so it lets the run proceed; a "require_human" plan is runnable by any
interactive session/CLI human.
Net: a verb the operator set to "block" — the policy's strictest verdict, "never
fires, period" — is correctly refused as a top-level effect but silently fires
when the identical verb sits inside a subworkflow. Composition launders a
forbidden action past a control that is supposed to be absolute.
Reproduction steps:
- Extract the station-v0.88 release tarball and work against workbench/.
- Register (or pick) a real external-send action and create a scratch WS whose
approval policy blocks it:
doc = {"version": 1, "rules": {"auto_approve": [], "require_human": [],
"block": [{"connector": "<provider>", "verb": "<verb>"}]}}
doc["integrity"] = approval_policy._integrity(doc)
json.dump(doc, open(WS + "/approval_policy.json", "w"))
Build policy_gate = approval_policy.evaluate(approval_policy.load(WS), action).
- Confirm a TOP-LEVEL effect node using that verb is refused:
run_workflow({"id":"top","nodes":[<effect node>]}, ws=WS, signing=signing,
policy_gate=policy_gate, allow_live_effects=True)
-> the effect does not fire (runtime backstop blocks it).
- Wrap the SAME effect node in a subworkflow and run the parent:
nested = {"id":"sub","nodes":[<effect node>]}
parent = {"id":"parent","nodes":[{"id":"sw","type":"subworkflow",
"workflow": nested}]}
run_workflow(parent, ws=WS, signing=signing, policy_gate=policy_gate,
allow_live_effects=True)
- Inspect plan_workflow(parent, policy_gate=policy_gate).blast_radius / nodes.
Expected: the nested effect is refused exactly like the top-level one, and the
parent plan surfaces the block (blast_radius.requires == "block" or a node with
decision "block") so the approve gate refuses the run.
Actual: the nested effect FIRES. The parent plan reports
blast_radius.requires == "require_human" with zero blocked nodes, so the approve
gate does not refuse it and an interactive human run executes the blocked verb.
Root cause:
- workflow_engine.py, run_workflow subworkflow branch: the recursive
run_workflow(sub_spec, ...) call forwards live_http and allow_live_effects but
omits policy_gate, so the runtime backstop is disabled for every nested
effect.
- workflow_engine.py, plan_workflow subworkflow branch: it never assigns the
subworkflow node's policy decision from the nested plan's worst verdict (the
node keeps the default "auto_approve"), and nested effect nodes are not
represented individually in the parent plan, so the plan/approve gate's
block/blocked-node checks cannot see a nested "block".
Suggested fix: forward policy_gate into the recursive run_workflow call so the
runtime backstop governs nested effects (it already forwards the other live
gates). And at plan time, propagate the nested plan's decision onto the
subworkflow node — set the node's policy to the nested plan's worst verdict (so a
nested "block" makes the subworkflow node itself decision "block") and roll the
nested requires into the parent's worst, so the approve gate refuses a run that
contains a blocked node at any depth.