Reproduction steps:
- Author a workflow that DECLARES it performs no irreversible actions:
"capabilities": {"allow_irreversible": false}
(or a scoped "providers": [...] allowlist).
- Give it an
httpnode that POSTs to an allowed domain — a mutating method, so
action_class is "external_send" (irreversible):
{"type":"http","policy":{"allow_methods":["POST"],"allow_domains":["data.example.com"]},
"request":{"method":"POST","url":"https://data.example.com/mutate","body":{...}}}
- Run it. Contrast with an
effectexternal_send node under the same declaration. - Run repro_allow_irreversible_capability_skips_http_node_v099.py against a clean
v0.99 extraction:
http POST node : executed=True ; capability_block=False
effect node : capability_block=True (correctly refused)
Expected:
A workflow that declares allow_irreversible:false is asserting a safety property —
"this rail performs no irreversible actions." An http POST/PUT/DELETE is
irreversible (external_send), so it must be refused under that declaration, the
same as an irreversible effect node. Likewise a node reaching outside the declared
providers allowlist must be refused whatever its kind.
Actual:
The declared-capability backstop (workflow_engine.py:882) is guarded to one node
kind:
if kind == "effect" and (_cap_providers is not None or _cap_no_irreversible):
_ci = R.resolve_node(n)
... if _cap_no_irreversible and _ci.action_class == "external_send": raise CapabilityExceeded
kind = n.get("type", "effect"), so an http node (type=="http") and a model
node never reach this check. An http POST — action_class external_send — executes
its outbound mutation with no capability check, while the identical guarantee
correctly refuses an effect external_send node (the repro shows exactly this). The
backstop was added specifically (comment #a960d0) because providers/allow_irreversible "were checked at the staging surface only, so a direct run
executed out-of-scope irreversible actions the declaration forbade" — but the
engine-side fix is effect-only, so that exact gap (a direct run performing a
forbidden irreversible action) remains open for http/model nodes.
Note (distinctness): this shares its ROOT with finding #20 (the run_workflow node
loop gates multiple controls on kind=="effect", so http/model nodes bypass them),
but it is a DIFFERENT control with a different guarantee — #20 is the operator's
emergency global freeze; this is the workflow's OWN declared safety scope
(allow_irreversible / providers), violated with no freeze involved. A maintainer
fix should make the node loop cover every outbound kind for BOTH.
Suggested fix:
Apply the declared-capability backstop to every outbound node kind, not only
effect. For an http node, derive the action_class the same way plan_http does
(external_send for a mutating method) and refuse it under allow_irreversible:false;
map its target host/domain against the providers allowlist. Better, compute each
node's (action_class, target) uniformly and run ONE capability/providers/spend/
freeze gate over every node regardless of kind, so a new node type cannot silently
escape the workflow's declared scope.