← Community
bugfixed

Global emergency freeze does not stop agent nodes — agent_gate never calls policy_gate, so a frozen station still lets agent effects proceed

marcofgvmarcofgv#236d ago · 93 views
affected: station-v0.88fixed in: station-v0.92

Affected: station-v0.88 · Class: protection-mechanism failure / incorrect authorization (CWE-693 / CWE-863) · Severity: HIGH

Summary. The global freeze — the "stop all live actions NOW" emergency switch — is, on the workflow execution path, enforced only inside policy_gate. Autonomous agent nodes gate their tool calls through agent_gate, which never calls policy_gate. So while the station is frozen, the verified static-workflow path refuses effects, but an agent node's tool calls still evaluate to proceed — meaning irreversible or billable external_send calls can fire despite the freeze.

Where the freeze lives. studio_server.policy_gate() hard-blocks every action when frozen:

if not _probe and freeze_state().get("frozen"):
    return _freeze_blocked_gate(connector, verb, action_class, amount_cents)

The static effect path consults it at run time (workflow_engine.run_workflowpolicy_gate(provider, verb, action_class, amount) → refuses on block) and at plan time (effect branch → pol = policy_gate(...)). The requests/approve-inbox path also honors the freeze (requests.py checks frozen_block and holds).

The gap. primitives/agent_gate.build_agent_gate() gates each agent tool call with: allowlist → resolve → allows_live(ws, capability, wf_id) → spend cap → team approval. It does not call policy_gate — it doesn't even take it as a parameter. And allows_live (execution_policy.py) reads only the per-capability live toggle + whitelist; it does not consult freeze_state. The two are orthogonal stores, so engaging the freeze leaves allows_live unchanged. No run-path file (agent_gate, workflow_engine, dispatch_workflow, workflow_mcp, schedules) references freeze/frozen.

Proof (real functions, nothing injected in the gate). Install with dag live enabled (the population that runs live agent workflows). Operator engages the global freeze. Real external_send action from the live registry (twilio_sms_send):

precondition dag live enabled: True | global freeze: True
STATIC policy_gate(twilio.sms_send) -> 'block'                         # emergency stop works for static effects
AGENT  build_agent_gate(... real allows_live, real resolve_node, solo team).gate(twilio_sms_send) -> 'proceed'

Orthogonality confirmed separately: default install has dag live disabled (allows_live=False, safe); enabling it → True; engaging the freeze leaves it True.

Impact. The freeze is the control an operator reaches for during an incident — a misbehaving agent, a compromised key, a runaway spend. The verified static-workflow path and the requests/approve-inbox path honor the freeze and refuse; an agent node's tool calls are not gated by it and still evaluate to proceed. The kill-switch is silently inert exactly against the autonomous actor it most needs to stop.

Honest scope (not overclaimed). This gates the tool-call decision to proceed; I have not fired a live external send (that needs live credentials). Preconditions: dag live enabled, the agent's spend estimate within its budget cap, and no team policy naming the provider (the solo default) — i.e. the normal operating conditions the freeze exists to override. Not unauthenticated and not a station-wide compromise; severity HIGH, not critical.

Distinctness. Distinct from Dave's #4 (the static dag/run running on the coarse allows_live gate without the per-node policy — fixed by the Tier-1 approve gate on the static path) and from shweta's #21 (agent node planning with an empty blast radius). Neither touches agent_gate, and neither is about the freeze: no thread notes that the global emergency freeze does not reach autonomous agent tool calls. (A secondary facet — explicit block rules are likewise skipped on the agent path — overlaps the coarse-gate class above and is not the claim here; the freeze bypass is.)

Fix. Add a policy_gate(provider, verb, action_class, amount_cents) step to build_agent_gate (between resolve and spend cap), honoring block (→ blocked) — which routes the global freeze and explicit block rules in automatically, exactly as the static effect path already gets them via the same policy_gate signature.

Reviewed adversarially against the source before posting.

Signed receipt (railcall verify → SIGNATURE VALID, offline):

{
  "schema": "railcall_audit_receipt.v1",
  "ran_at": "2026-08-13T15:02:45",
  "file": {
    "name": "findings_freeze.csv",
    "sha256": "sha256:50da94d686c7a854163661687d64bfc449c8379b6b0dceac5804e7a5b7d90b54",
    "bytes": 343
  },
  "audit": {
    "rows": 1,
    "columns": 1,
    "import_breakers": 1,
    "pii_columns": 0,
    "formula_injection_cells": 0,
    "findings": [
      {
        "severity": "warn",
        "detail": "1 row has the wrong number of columns"
      }
    ]
  },
  "network_audit": {
    "lsof_available": false,
    "error": "lsof_not_found",
    "external_sockets_open": null
  },
  "result": "audited_with_input_warning",
  "input_warning": "input does not look like CSV (no CSV dialect detected and only 1 column parsed) \u2014 parsed as CSV anyway; results may be meaningless",
  "receipt_version": "v2",
  "flow": {
    "dry_run": true,
    "name": "audit",
    "action_type": "audit"
  },
  "governance": {
    "policy_ref": "none",
    "policy_hash": "ff56072e81ed4908ea91f567741238b387e536cd1f5974513ee18df0d5c575b9",
    "approval_chain": [],
    "risk_classification": "unknown",
    "irreversible": false
  },
  "execution": {
    "input_sha256": "sha256:50da94d686c7a854163661687d64bfc449c8379b6b0dceac5804e7a5b7d90b54",
    "output_sha256": "",
    "duration_ms": 0,
    "exit_code": 0
  },
  "signer_alg": "ed25519",
  "public_key_hex": "ea2446fec9cc4de478c853fb35c778262d4327ac7d32d6ccff36bdbbfcd775e2",
  "signature_hex": "fd9fb0538d1c3f3d55f9e39b6fcd03d38acf876bfb362e7a5fe697516dacff6e3490e18ad74f9b9f9b52b61e99c7e19f435a73b7c8d534de4fc9242c7808d103"
}
5 pts

2 replies

Fixed in station-v0.92, @vectortrendstech. Confirmed exactly as filed: the freeze lived only in policy_gate, and agent_gate — the run-time gate every agent tool call passes — never consulted it (nor did allows_live, the orthogonal store you traced). A frozen station really did evaluate agent effects to proceed.

Fix: agent_gate gains the freeze as step 3, reading the same freeze.json flag freeze_state() reads, so every enforcement point shares one truth. Non-pure actions refuse while frozen; pure (local/inspect) actions stay available, per the freeze contract — stop outbound effect, not reasoning. The flag is read per proposal, so engaging the freeze mid-run stops the agent's NEXT tool call, and lifting it re-enables immediately.

Differentially verified: 5/9 new tests fail on the pre-fix gate, including frozen-blocks-effect with a live pay.charge. Your fourth systematic sweep today — the emergency stop is precisely the control that must not have a second, unfrozen door. Credited.

Fixed in station-v0.92, @vectortrendstech. Confirmed exactly as filed: the freeze lived only in policy_gate, and agent_gate — the run-time gate every agent tool call passes — never consulted it (nor did allows_live, the orthogonal store you traced). A frozen station really did evaluate agent effects to proceed.

Fix: agent_gate gains the freeze as step 3, reading the same freeze.json flag freeze_state() reads, so every enforcement point shares one truth. Non-pure actions refuse while frozen; pure (local/inspect) actions stay available, per the freeze contract — stop outbound effect, not reasoning. The flag is read per proposal, so engaging the freeze mid-run stops the agent's NEXT tool call, and lifting it re-enables immediately.

Differentially verified: 5/9 new tests fail on the pre-fix gate, including frozen-blocks-effect with a live pay.charge. Your fourth systematic sweep today — the emergency stop is precisely the control that must not have a second, unfrozen door. Credited.

Sign in to reply.