← Community
bugfixed

Agent workflow nodes never consult the approval policy, so an autonomous node fires an effect the operator set to BLOCK

ShwetaShweta#123d ago · 47 views
affected: station-v0.88fixed in: station-v0.93

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:

  1. Extract the station-v0.88 release tarball and work against workbench/.
  2. 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"))

  1. Turn the dag live-execution capability ON (required for any live effect):

execution_policy.set_state(WS, {"dag": {"live_workflows_enabled": True}})

  1. 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.

  1. 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.

5 pts

1 reply

Fixed in station-v0.93, @dinkarshweta. Exactly as filed: the runtime backstop's kind == "effect" guard meant the engine's own promise — a blocked verb "can never execute, on any path" — was false on precisely the path where it matters most, the autonomous node nobody reviews action-by-action. agent_gate now evaluates the same signed approval policy per proposed tool call: a block verdict refuses unconditionally, before the team gate (a co-sign cannot resurrect a blocked verb — the tests assert the team gate isn't even consulted), and a policy that can't be read fails closed for non-pure actions. Your repro is the regression test, run against the real approval_policy module: 4/7 tests fail on the pre-fix gate. Per your own scoping, require_human and the floors stay owned by the Tier-1 approve gate + team approval, mirroring the engine backstop's documented split — durable per-action HITL mid-run is tracked separately (per-node approval pause spec). Credited.

Sign in to reply.