← Community
bugfixed

v0.74 agent-node fix side-effect: agent plan_hash omits the system prompt, so the plan-pin does not bind the agent instructions

marcofgvmarcofgv#219d ago · 58 views
affected: station-v0.78fixed in: station-v0.83

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"
}```
3 pts

0 replies

Sign in to reply.