This release enforces the signed approval policy's per-action verdict
(auto_approve / require_human / block) on the static effect path in two new
places: a plan/approve gate that refuses a run outright when any node's sealed
policy decision is "block", and a runtime backstop in the workflow engine that,
immediately before an effect fires, evaluates the live policy for that
provider/verb and raises a fail-closed error on a "block" verdict so the saga
rolls back. The engine's own comment states the intent plainly: a blocked verb
"can never execute, on any path".
It can. The runtime backstop is guarded by kind == "effect", so it never runs
for the agent node kind. An agent node executes real effects through its own
autonomous tool-use loop, and its per-action gate (primitives/agent_gate.py,
build_agent_gate) checks only: (1) tool allowlist, (2) resolves to a real
integration, (3) execution_policy.allows_live — a COARSE capability on/off
toggle — (4) the node spend cap, and (5) team approval. It never evaluates the
approval policy at all. So a provider/verb the operator has set to "block" — the
policy's strictest verdict, documented as "never fires, period" — is fully
refused for a static effect node but silently executed when the same verb is
chosen by an agent node.
The agent path also never sees the policy's require_human rules or its
hard floors (e.g. "never auto-approve an irreversible action"); "block" is
simply the cleanest, unarguable demonstration, since no up-front approval is
supposed to make a blocked verb fire.
An agent node is exactly the node kind for which an absolute block matters most:
it is the autonomous money-mover the operator cannot review action-by-action.
An operator who blocks, say, an outbound-email or a refund verb, then grants
that verb to an agent node's tool allowlist, gets no enforcement of the block.
Reproduction steps:
- Extract the station-v0.88 release tarball and work against workbench/.
- Create a scratch workspace WS. Write an approval policy that blocks a real
external-send action — e.g. resend.email_send:
doc = {"version": 1, "rules": {"auto_approve": [], "require_human": [],
"block": [{"connector": "resend", "verb": "email_send"}]}}
doc["integrity"] = approval_policy._integrity(doc)
json.dump(doc, open(WS + "/approval_policy.json", "w"))
- Turn the dag live-execution capability ON (required for any live effect):
execution_policy.set_state(WS, {"dag": {"live_workflows_enabled": True}})
- Confirm the STATIC path refuses the action — evaluate the loaded policy for
(resend, email_send, external_send): decision == "block". The engine's
runtime backstop raises on this and rolls back.
- Build the real agent gate and ask it to run the SAME action:
gate = agent_gate.build_agent_gate(ws=WS, run_id="r",
tools=["resend_email_send"], max_spend_cents=100000,
capability="dag", workflow_id="wf")
verdict, info = gate("resend_email_send", {"to": "...", "subject": "...",
"body": "..."}, 0, 1)
Expected: the agent gate refuses the action ("blocked"), because the live
approval policy blocks resend.email_send exactly as it does on the static
effect path.
Actual: the agent gate returns "proceed". An agent node whose model selects a
blocked tool executes it; the block verdict is never consulted on the agent
path — neither at plan time (an agent node only ever plans as a single blanket
"require_human", never per-tool "block") nor at runtime (the engine backstop is
effect-only, and agent_gate consults only the coarse allows_live toggle).
Root cause:
- workflow_engine.py, runtime policy backstop: guarded by
if policy_gate is not None and kind == "effect" and allow_live_effects.
The kind == "effect" clause excludes agent nodes; the effect an agent fires
through its execute() closure (integ.apply) never reaches this check.
- primitives/agent_gate.py, build_agent_gate/gate: enforces allowlist +
resolve + execution_policy.allows_live + spend cap + team approval, but never
calls the approval policy (approval_policy.evaluate / the station's
policy_gate). Its docstring claims it "re-runs the SAME stack the static
effect path uses" — that is no longer true now that the static path also
enforces the per-action block/require_human policy.
- workflow_engine.py, plan_workflow agent branch: assigns the whole node a
single "require_human" decision ("approve its tool blast radius") and never
evaluates the policy per granted tool, so a blocked tool in the allowlist is
not surfaced as a "block" node decision for the plan/approve gate to catch.
Suggested fix: give the agent path the same per-action policy consultation the
static effect path now has. Concretely, thread the run's policy_gate into
build_agent_gate and, inside gate(), between the allows_live check and the
spend-cap check, evaluate the approval policy for (provider, verb, action_class,
estimated_amount): return "blocked" on a "block" verdict, and route a
"require_human" verdict through the same pending/approval path a static
require_human effect uses rather than proceeding autonomously. Additionally, in
plan_workflow's agent branch, evaluate each granted tool against the policy so a
blocked tool is reflected as a node-level "block" decision the plan/approve gate
refuses up front.