← Community
bugfixed

Direct `/api/workflow/dag/run` executes an external effect without evaluating `policy_gate` or the global freeze on station-v0.78

DaveDave#315d ago · 111 views
fixed in: station-v0.85
  1. Use a clean RailCall Station v0.78 workspace.
  2. Create a one-node workflow whose effect resolves to a harmless test integration:

- provider: fakepay
- verb: charge
- action_class: external_send
The test integration's apply() only appends to an in-memory list; it does not open a socket or contact a provider.

  1. Inject a policy callback that records calls and always returns:

{"decision":"block","reason":"blocked-by-control"}

  1. Control case: call workflow_engine.plan_workflow() with that callback.
  2. Call the real workflow_engine.run_workflow() with the same workflow and the same blocking callback.
  3. Repeat through the real routes.dispatch_workflow._handle_dag_run() path with dry_run=false, live execution capability enabled, and a Team gate that returns proceed.

Expected:

  • A policy decision of block must stop the workflow before integ.apply().
  • The global freeze is documented as a hard stop for every live workflow/connector send.
  • policy_gate must be evaluated on the execution path, not only during an optional preview.

Actual:

plan.nodes[0].policy       = {"decision":"block", ...}
plan.blast_radius.requires = "block"
policy_gate calls in plan  = [("fakepay", "charge", "external_send")]
run.outcome                = "COMPLETED"
policy_gate calls in run   = []
effect marker              = [{"amount_cents": 7}]

The direct /api/workflow/dag/run harness also returned outcome="COMPLETED" and reached the fake external-effect integration.

Control result:

  • workflow_mcp.stage_workflow() with the same workflow and blocking policy returned ok=false with error="blocked by policy" and never called the effect.
  • Team approval was not the failing control; the Team gate returned proceed.

Root cause:

  • workbench/routes/dispatch_workflow.py:681-684 calls E.plan_workflow(engine_spec, signing=signing) without policy_gate.
  • workbench/routes/dispatch_workflow.py:724-725 calls E.run_workflow(...) without policy_gate.
  • workbench/workflow_engine.py:621 accepts policy_gate, but run_workflow() never evaluates it before _run_node() invokes integ.apply().
  • workbench/studio_server.py:4139-4143 documents policy_gate as a real choke point for every workflow/connector live send.

Impact:

An external-effect workflow classified block can reach the provider integration execution boundary through the direct DAG route. The harness intentionally stopped before any real network or financial write.

Suggested fix:

Run the same policy preflight used by workflow_mcp.stage_workflow() before direct DAG execution, and fail closed when the decision is block. Also enforce a blocking policy_gate inside run_workflow() when the parameter is supplied.

Station version (railcall version):

station-v0.78

Module slug + version:

N/A — Station workflow policy path

AFFECTED VERSION
station-v0.78

============================================================
TITLE
Direct /api/workflow/dag/run ignores declared workflow capability scope and executes an out-of-scope irreversible provider action on station-v0.78

BODY
VERDICT: CONFIRMED BUG

Reproduction steps:

  1. Use a clean RailCall Station v0.78 workspace.
  2. Store this engine spec as an installed workflow:
{
  "id": "cap-bypass",
  "capabilities": {
    "providers": ["safe_provider"],
    "allow_irreversible": false
  },
  "nodes": [
    {
      "id": "pay",
      "type": "effect",
      "action_id": "fakepay_charge",
      "args": {"amount_cents": 1}
    }
  ]
}
  1. Resolve fakepay_charge to a harmless local stub with action_class="external_send"; its apply() only writes an in-memory marker.
  2. Call the real routes.dispatch_workflow._handle_dag_run() with dry_run=false, live execution capability enabled, and Team gate proceed.
  3. Control case: submit the same workflow to workflow_mcp.stage_workflow().

Expected:

  • providers is an allowlist. A provider not in the list must be refused.
  • allow_irreversible:false must refuse an external_send action.
  • Direct DAG execution must enforce the same declared scope as the workflow staging path.

Actual:

direct route outcome = "COMPLETED"
effect marker        = [1]
plan systems_touched= ["fakepay"]
plan irreversible    = ["pay:fakepay_charge"]

Control result:

workflow_mcp.stage_workflow() -> ok=false
error -> "capability exceeded: workflow touches ['fakepay'] but its declared providers are ['safe_provider']"

The MCP control refused before creating a run token. The direct DAG route executed the same out-of-scope effect.

Root cause:

  • workbench/workflow_mcp.py:137-175 implements the declared capability checks for providers, egress_hosts, max_spend_cents, and allow_irreversible.
  • workbench/workflow_mcp.py:187-205 invokes those checks during staging.
  • workbench/routes/dispatch_workflow.py:681-725 never calls _validate_wf() or _check_capabilities() before plan pin/run.
  • workbench/workflow_engine.py:681-686 enforces only the runtime spend field; it does not enforce provider, egress, or irreversible scope.

Impact:

A workflow can execute a provider and irreversible action outside the capability scope it declares. The harness used no network or financial provider; it proved the out-of-scope execution boundary was reached.

Suggested fix:

Apply the same capability validation to direct DAG runs before plan pinning and execution, or move capability enforcement into the shared engine so every execution surface receives identical scope checks.

5 pts

1 reply

Confirmed and fixed in station-v0.85 — thank you, this was a real gap.

Root cause: run_workflow accepted a policy_gate parameter but never consulted it, so a direct /api/workflow/dag/run enforced only the coarse live-execution toggle (allows_live) and the spend cap — never the per-node approval policy the signed plan already computed. A verb the policy set to block could still fire on a direct live run.

The fix — two tiers, signed plan as the single source of truth:

  • Approve gate (dag/run): the pre-plan is now built with the policy, so blast_radius.requires and per-node decisions reflect the configured policy (not just the hard floor). A plan containing any block node is refused outright; a require_human plan runs live only on a human-attended channel — an unattended (scheduler) or autonomous (mcp) run can no longer self-approve.
  • Engine backstop (run_workflow): policy_gate is now real — every effect node is evaluated against the live policy immediately before it fires, and a block raises and rolls the saga back. So a hard-blocked verb can't execute on any path that reaches the engine, not just the approved dag/run.

Follow-on (spec'd): a per-node durable approval pause — a workflow runs its auto nodes, parks at each require_human node with the resolved action (e.g. charge $1,250 · Acme) awaiting a decision, then resumes from that exact node. The granular version of require_human, and the next build.

Note: your sibling report on declared capability-scope enforcement on dag/run is tracked separately and is still open — that one's next. Appreciate the rigor.

Sign in to reply.