v0.71's team quorum-approval gate dedupes actions by
(provider, action_class), so a request labeled with ONE verb silently
covers every OTHER verb sharing that class -- an approver authorizes more
than they were shown
Reproduction steps:
- Extract a clean station-v0.71 tarball, sys.path.insert(0, "workbench").
- import routes.team as T; import primitives.team_approval as ta
- Monkeypatch ta.gate to a stub that records every call it receives and
returns ("pending", {...}).
- gate_fn = T.build_workflow_team_gate(ws)
- Call gate_fn(wf, run_id) on a two-node workflow where both nodes hit the
SAME provider with the SAME action_class but DIFFERENT verbs -- e.g. Slack
(primitives/integration_registry.py ~line 434-557): message_post,
message_update, reaction_add, channel_create, and channel_history are
ALL action_class="reversible":
n1: {"type": "effect", "provider": "slack", "verb": "message_post",
"action_id": "slack.message_post", "args": {"channel": "#eng", "text": "hi"}}
n2: {"type": "effect", "provider": "slack", "verb": "channel_create",
"action_id": "slack.channel_create", "args": {"name": "new-channel"}}
Expected: an approver's approval should cover exactly the action(s) they were
shown a summary for. If n1 and n2 are meaningfully different actions
(posting a message vs. creating a channel), either both get their own
approval request, or the one request's summary discloses everything it is
actually authorizing.
Actual: exactly ONE ta.gate() call is made, for n1 only:
verdict: pending
number of approval requests actually created: 1
-> {'provider': 'slack', 'verb': 'message_post', 'action_class': 'reversible',
'summary': 'DAG run wf_multi -> slack.message_post (reversible)'}
n2 (channel_create) never generates its own request. Once an approver
satisfies the single pending request for "slack.message_post (reversible)",
team_gate() returns ("proceed", ...) for the WHOLE plan -- n2 executes too,
even though the human who approved never saw "channel_create" mentioned
anywhere in what they signed off on.
Root cause: workbench/routes/team.py, build_workflow_team_gate() ->
team_gate() (~line 404-422):
for provider, verb, action_class in _iter_actions(wf):
key = (provider, action_class) # line 408 -- verb NOT in the key
if key in seen:
continue # line 409-410 -- silently skipped
seen.add(key) # line 411
action_hash = "sha256:" + _h.sha256(_canon({
"run_id": run_id, "wf": wf.get("id"), "plan": plan_digest,
"provider": provider, "action_class": action_class}).encode()).hexdigest()
verdict, info = _ta.gate(
ws, action_hash=action_hash, provider=provider, verb=verb,
action_class=action_class,
summary="DAG run %s -> %s.%s (%s)" % (wf.get("id"), provider, verb, action_class),
...)
The dedup key (provider, action_class) is coarser than the action_hash and
the human-readable summary, both of which are only computed for the FIRST
verb encountered for that (provider, action_class) pair. seen exists to
avoid asking for N redundant approvals when a plan repeats the identical
action (e.g. the same for_each-driven slack.message_post firing five times)
-- a reasonable goal -- but the key it dedupes on conflates "identical
action, asked twice" with "different action, happens to share a risk
class." primitives/integration_registry.py assigns action_class per verb
independently of what the verb actually does (slack alone has 5 distinct
verbs all mapped to "reversible"), so this collision is not an edge case --
it's the normal shape of that registry.
Suggested fix: dedupe (and compute action_hash / summary) on
(provider, verb, action_class) instead of (provider, action_class) --
matching the granularity _iter_actions already yields and _ta.gate()
already accepts as separate parameters. If the intent is genuinely "one
approval per risk class is enough," the summary must instead be built from
every distinct verb folded into that request (e.g. "slack.message_post,
slack.channel_create (reversible)"), not just the first one seen, so the
approver's signature actually covers what it authorizes.