Class: CWE-345 (insufficient verification of data authenticity) — the signed plan_root binds an http node's plan-time RESOLVED-and-REDACTED request, so both a repointed bound body and a swapped concrete secret-header value are non-tamper-evident.
Component: workflow_engine.py — the kind == "http" PLAN leaf (~L318-325, the branch that computes the node's plan_hash), sealed into workflow_root and pinned by primitives/plan_pin.py; runtime at ~L1596-1608.
The defect
Every other effect-bearing leaf was hardened to bind the RAW, pre-resolution template so a post-approval edit is tamper-evident: the effect leaf (community bug "shweta", ~L439-445: "an approved effect ('charge {{ctx.item}}') could have its bound args swapped without changing the plan_root, and the plan-pin waved it through" → fix binds args_template = n.get("args")), the model leaf (~L365-375), and the agent leaf (~L413-417). The http leaf is the one that was NOT fixed. It binds only the RESOLVED request's hash:
elif kind == "http":
req = _resolve(n["request"], outputs, ctx) # {{bindings}} -> values
plan = WH.plan_http(req, n.get("policy"), ...) # hashes the resolved+redacted req
h = plan["integrity_hash"] # raw n["request"] NEVER bound
Two consequences, both proven below, both closed by the same one-line fix:
(1) BOUND BODY — repointable (parameterized/dynamic case). _resolve maps an unresolved binding to None (its own comment ~L200-202: "A miss is None"), and plan_http seals the body as body_sha over the RESOLVED body (workflow_http.py ~L131, folded into integrity_hash ~L143). So a bound body {"amt": "{{ctx.X}}"} resolves to {"amt": null} for EVERY X at plan time → one body_sha → one integrity_hash → one workflow_root. Repointing the body's binding SOURCE after approval leaves the signed root identical.
(2) CONCRETE SECRET HEADER — swappable unconditionally (no binding needed). plan_http hashes header NAMES (headers_present, ~L129) and a preview that REDACTS _SECRETY-named values to <redacted> (headers_preview, ~L130 via _redact_headers ~L73; _SECRETY includes authorization/token/api-key/cookie ~L31). The RAW header dict is carried as _headers (~L139), and _-prefixed keys are EXCLUDED from the hash (~L143). So swapping a concrete Authorization: Bearer sk_live_LEGIT → Authorization: Bearer sk_live_ATTACKER keeps the same name + same <redacted> preview → identical integrity_hash, with no None-at-plan requirement at all.
At runtime the node RE-resolves n["request"] with the live scope, re-plans, and fires — no comparison to the approved root anywhere (workflow_engine.py ~L1596-1599; apply_http_plan re-hashes the runtime plan against ITSELF, workflow_http.py ~L192-195; the pinned-run gate re-plans and checks only the body-blind root, routes/dispatch_workflow.py ~L880-1006). So an attacker with the capability plan_pin's own seal assumes — editing the pinned plan / workflow spec — repoints the body (to a secret from a runtime upstream node, or an attacker-controlled per-run input) or swaps the auth header, and the run proceeds unattended and sends it to the approved, allowlisted endpoint.
Reproduction
Standalone; workbench/ on sys.path. The governance gate itself accepts the tamper:
import tempfile
from workbench import workflow_engine as WE
from primitives import plan_pin as pp
def spec(body=None, headers=None):
req = {"url": "https://api.stripe.com/v1/pay", "method": "POST"}
if body is not None: req["body"] = body
if headers is not None: req["headers"] = headers
return {"id": "payflow", "context": {}, # empty ctx -> body binding UNRESOLVED at plan time
"nodes": [{"id": "send", "type": "http", "request": req,
"policy": {"allow_domains": ["api.stripe.com"], "allow_methods": ["POST"]}}]}
ws, wf = tempfile.mkdtemp(), "payflow"
approved = WE.plan_workflow(spec(body={"amt": "{{ctx.invoice_amount}}"}), signing=None)
pp.pin(ws, wf, approved, approved_by="human:operator")
# (1) repoint the bound body to a runtime upstream node's output (None at plan time)
swapped = WE.plan_workflow(spec(body={"amt": "{{nodes.charge.token}}"}), signing=None)
ok, why, _ = pp.check(ws, wf, swapped)
assert ok is True and why == "matches the approved plan" # BYPASS — pin waves the swap through
# (2) swap a CONCRETE Authorization header value — unconditional
a = WE.plan_workflow(spec(headers={"Authorization": "Bearer sk_live_LEGIT"}), signing=None)["workflow_root"]
b = WE.plan_workflow(spec(headers={"Authorization": "Bearer sk_live_ATTACKER"}), signing=None)["workflow_root"]
assert a == b # signed root identical
Output:
(1) pin(approved) then check(body-swapped): ok=True | matches the approved plan
(2) concrete Authorization value swap -> root EQUAL: True
End-to-end: running the swapped spec fires POST https://api.stripe.com/v1/pay with the runtime-resolved body — an upstream secret (sk_live_…) reaches the approved endpoint, outcome=COMPLETED. NOTE: the body case needs the repoint target to be None at plan time — a per-run ctx key, or an upstream effect/model/agent/http node (whose output is not stored at plan time; only transform/for_each outputs are, so a transform-upstream body swap does NOT reproduce — it changes the root). The header case has no such precondition.
Expected
Repointing a bound body, or swapping a concrete header value, is a change to what the approved request sends, so it must change the signed workflow_root and invalidate the pin — as the equivalent effect-args edit does after the shweta fix.
Scope
Precondition is the one plan_pin's seal already defends (plan_pin.py ~L17-20 names it: "Approve payroll_run Monday, edit it Wednesday to add a Stripe charge") — the ability to edit the pinned plan / workflow spec. plan_pin's other gates don't cover it: manifest_facets (~L311-321) applies only to module action_id nodes; approved_caps_fingerprint (~L328-337 over _CAP_ENVELOPE_FIELDS ~L68 = max_spend/providers/allow_irreversible/egress_hosts) is unchanged (same allowlisted host); the spend ceiling doesn't move on a body/header edit — so check() returns ok=True (proven). Verified on the pinned/scheduler unattended path (routes/dispatch_workflow.py _handle_dag_run, ~L880-1006); I did not examine the workflow_mcp consent-token staging path, so this is not a claim about every run path. Bonus: the http receipt (~L1603-1606) records only method/host/action_class/status + an opaque delta_integrity — legit and swapped receipts are byte-identical but for that non-comparable hash, so the repoint leaves no human-legible audit trace.
Fix
Bind the raw request TEMPLATE into the http leaf hash, mirroring the shweta fix. At ~L325:
- h = plan["integrity_hash"]
+ h = _sha({"id": n["id"], "http": plan["integrity_hash"],
+ "request_template": n["request"]}) # raw: pre-resolution body + UNREDACTED headers
Raw n["request"] carries the pre-resolution body AND the unredacted header values (its sha is one-way, so it commits to them without exposing them), closing both gaps in one change. Drift-safe: the pin check already plans the pre-injection _pin_spec deepcopy (dispatch ~L784), so per-run context injection does not spuriously break the pin.
Classification: CWE-345