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_workflow → policy_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"
}