A LIVE job_offer addressed to "role:worker" (fan-out) executes independently on EVERY current worker-role member — team_jobs.py's own documented "live jobs go to exactly one named worker" invariant is enforced only in the client-side offer_job() wrapper, never on the receiving/execution side (v1.5.0)
Reproduction steps:
- workbench/primitives/team_jobs.py's requester-side convenience function
offer_job() (line 172) refuses to construct a live (non-dry-run) job
addressed to a role:
if to.startswith("role:") and not dry_run:
# Two workers both executing a LIVE job is double-charging /
# double-sending. Live jobs go to exactly one named worker.
raise ValueError("live jobs must target ONE worker pubkey, not a role fan-out")
The docstring states the safety model in plain language: "Send a job to
a worker pubkey or 'role:worker' (fan-out — first worker to run it
wins; v1 accepts the duplicate-execution risk of multi-worker fan-out
ONLY for dry runs, and refuses live fan-out)."
- This is the ONLY place that invariant is checked. team_mesh.mint_envelope()
(team_mesh.py line 110) signs whatever to/body it is given — it has
no awareness of "job_offer" semantics, let alone dry_run vs. live. Its
sibling team_mesh.verify_envelope() (line 189) checks schema, expiry,
signature, sender membership, and the sender's role against
KIND_SENDER_ROLE (job_offer requires only "operator", team_mesh.py line
62) — it never inspects to_pubkey at all, so a role-addressed
envelope verifies identically to a named one.
- team_jobs.handle_job_offer() (line 294) — the actual WORKER-side
handler every team member with the worker role runs on receipt — never
checks whether the envelope it is holding was addressed by role or by
name before treating a job as live. Its only live-path safeguard is
per-station, at-most-once deduplication on job_id (line 364-379,
"393ab1: a LIVE job is executed AT MOST ONCE per job_id" via
_claim_live_job), which claims jobs_in/<job_id>.json in THIS
worker's OWN workspace — it says nothing about, and does nothing to
prevent, a DIFFERENT worker independently claiming and running the
identical job_id in its own separate workspace.
- Any current team member holding just the "operator" role — the only
role job_offer requires to mint — can therefore bypass offer_job()'s
guard entirely by calling the lower-level primitive directly:
team_mesh.mint_envelope(ws, kind="job_offer", to="role:worker",
body={"job_id": ..., "dry_run": False, ...})
The Relay fans a role-addressed envelope out to every current
worker-role member (this is the documented, intended behavior for dry
runs — "fan-out — first worker to run it wins"). Each worker
independently receives the SAME envelope, and it passes verify_envelope
and reaches the runner on all of them, since nothing server-side
distinguishes "this arrived via role fan-out" from "this arrived
addressed to me by name" for the live/dry_run decision.
- Run repro_team_jobs_role_fanout_live_duplication_v150.py against a
clean v1.5.0 extraction. It drives the REAL, unmodified
team_mesh.mint_envelope, team_mesh.verify_envelope, and
team_jobs.handle_job_offer against three independent real Ed25519
station identities (one attacker/operator, two workers) under one real
signed team manifest all three adopt. The attacker mints ONE live
job_offer envelope addressed to "role:worker" (bypassing offer_job()
by calling mint_envelope directly), and each worker independently runs
it through the real verification + handler with a stub runner standing
in for the governed DAG execution path:
attacker minted a LIVE job_offer addressed to 'role:worker':
to_pubkey: role:worker dry_run: False job_id: job_ce1bcbfa416406a76fa5bf26
worker_1: verify_envelope -> ok=True reason='ok'
worker_2: verify_envelope -> ok=True reason='ok'
stations that independently EXECUTED the same live job_id:
['worker_1', 'worker_2']
CONFIRMED
Expected:
A live job_offer should execute on at most one worker station total, per
team_jobs.py's own documented invariant — enforced the same way every
other trust decision in this file is enforced: server-side, on the
receiving station, not merely as a courtesy in the requester's own client
wrapper that a requester is free not to call.
Actual:
A single envelope, minted once, causes independent live execution on every
worker-role member of the team. For a real governed workflow this means a
job that moves money or sends a real message fires once per worker in the
mesh from one malicious (or simply buggy/modified) client — duplicate
charges, duplicate sends, and a runner call count that no single station's
job_id dedup can ever see, because each worker's claim store is local to
itself.
Suggested fix:
Enforce the invariant where it actually matters — on the receiving side,
using data already inside the signed envelope, not on trusting the sender
to have gone through offer_job():
def handle_job_offer(ws, env, member, *, runner):
...
live = not bool(body.get("dry_run", True))
if live and env.get("to_pubkey", "").startswith("role:"):
return answer({"ok": False, "denied":
"a live job addressed to a role, not a named worker "
"pubkey, is refused — live jobs must target exactly one "
"worker (role fan-out is only valid for dry runs)"})
...
Since to_pubkey is inside the signed envelope (team_mesh.canonical_bytes
covers every field but sig), this is tamper-evident: a sender cannot
mint a role-addressed envelope and then claim at the receiver that it was
named. Consider also teaching team_mesh.verify_envelope() this rule
directly (keyed on kind == "job_offer" and body.dry_run, which would
require verify_envelope to see the decrypted body for the sealed-box
case) so the same mistake cannot recur in a future kind that shares this
shape.