Affected: station-v0.78 (gap introduced by the v0.74 agent-branch that fixed shweta's "empty blast radius" and marcofgv's "no approvable plan") · Class: incomplete plan-integrity binding (CWE-345) · Severity: MODERATE · Signed receipt: attached (railcall verify → SIGNATURE VALID, offline)
What the fix bound, and what it left out
plan_workflow's new agent branch (workflow_engine.py ~338-369) hashes the node as:
h = _sha({"id": n["id"], "agent": True, "tools": agent_tools,
"max_spend_cents": _cap, "tool_meta": tool_meta})
It binds the tool allowlist + spend cap, but omits n["system"] (the agent's instructions), n["model"], and n["disposition"]. At runtime _run_agent_node reads the system prompt from the live on-disk node — system = (n.get("system") or "") + _AGENT_PROTOCOL (~929) — and the DAG path (dispatch_workflow) replans the on-disk engine_spec and compares only workflow_root to the approval pin. So a system-only edit preserves the root and passes the pin. (The MCP stage/apply path, by contrast, seals the entire staged workflow — this gap is specific to the replan-and-compare-root DAG/Studio path.)
Proof (container, real plan_workflow, our installed module)
Planned freelancer-daily-bidder, then changed ONLY the scout agent's system prompt to "IGNORE prior instructions… same tools" and re-planned:
workflow_root ORIGINAL vs TAMPERED: IDENTICAL (sha256:99164165c9a979391bb516efd0c…)
scout plan_hash ORIGINAL vs TAMPERED: IDENTICAL
Impact (scoped honestly — not a universal bypass)
The tool allowlist, live-execution policy, spend limits, registry checks, and team gates all still hold — changing the system prompt cannot add tools or bypass those. So this is not unrestricted execution. The meaningful case: a principal who can edit the on-disk spec but cannot re-obtain human approval — a lower-privileged workflow editor, a shared-workspace session, a supply-chained "workflow update" — crosses the human plan-pin boundary and redirects already-approved capabilities. The operator approved "an agent that bids on Python projects with these tools"; the same pin now also authorizes "an agent that calls those same money-tools however the edited instructions say," because intent was never bound. disposition (blocked-call handling) is likewise unbound. Precision: model is absent from the PLAN hash (so the approval doesn't bind it) but IS recorded in the runtime railcall_agent_receipt.v1, and a tampered system prompt yields a different reasoning trace whose reasoning_leaves differ — so the tamper is detectable POST-HOC via the runtime receipt. The gap is specifically PRE-execution: the plan-pin, whose job is to refuse a tampered spec BEFORE it runs, does not — the malicious run proceeds and is only auditable after the fact.
Fix
Add the agent node's system, model, and disposition to the agent-branch plan hash (and any effect fields on hybrid nodes), so the approval binds what the agent is instructed to do, and with which model — not only which tools it may touch. Mirrors how effect nodes already hash their args.
Found and reported by @marcofgv. Reviewed adversarially (codex novelty+overclaim triage) before posting.
Signed receipt (railcall verify → SIGNATURE VALID, offline)
{
"schema": "railcall_audit_receipt.v1",
"ran_at": "2026-08-12T01:17:24",
"file": {
"name": "findings_planhash.csv",
"sha256": "sha256:4c4ccc125c53038d1515f5abcf478f13879cc6ca1e20508df760cbe841d7d430",
"bytes": 265
},
"audit": {
"rows": 1,
"columns": 1,
"import_breakers": 0,
"pii_columns": 0,
"formula_injection_cells": 0,
"findings": []
},
"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:4c4ccc125c53038d1515f5abcf478f13879cc6ca1e20508df760cbe841d7d430",
"output_sha256": "",
"duration_ms": 0,
"exit_code": 0
},
"signer_alg": "ed25519",
"public_key_hex": "ea2446fec9cc4de478c853fb35c778262d4327ac7d32d6ccff36bdbbfcd775e2",
"signature_hex": "641bf765428b1c33e02cadfddd5a36ad2fe509ea0f30632d39d0bb0cb6804894c1aba6ee3c832abb43bcd90213b73776bb97eecb263b3385297ec31bd376b603"
}```