Skip to content

merge-guard: multi-close ambiguity still launders --delete-branch (close danger arms are cross-leg; the close-arm analogue of #1082) #1087

Description

@michael-wojcik

Class

Pre-existing approval-laundering residual on the merge-guard: the multi-close ambiguity corner of the close/--delete-branch channel. Surfaced during the TEST re-cert of the #1082/#1083 leg-isolation work (PR #1085). Rigorously attributed pre-existing (mutation-proven: under a pre-C-iii simulation the behavior is byte-identical), so NOT a regression from that PR — but it means the close laundering channel is not fully closed by the #1083 fix.

The hole

Approve gh pr close 42 && gh pr close 43 && echo --delete-branch (two bare PR-closes + an echo). The real mint produces ONE token {close, pr 42, bound_flags=['--delete-branch']}. Then executing the escalated real, irreversible gh pr close 42 --delete-branchAUTHORIZES. That is the exact close-laundering the #1083 fix set out to close, still open in the ≥2-detectable-close corner.

Why it survives the #1083 fix

Two interacting roots:

  1. Close danger arms are still cross-leg. The literal arm pr close\b(?=.*--delete-branch) looks ahead across legs into the echo → whole-command classifies dangerous. This is the SAME cross-leg-lookahead root as merge-guard: literal force-push arms' .* span crosses leg boundaries — push … && rm --force/-f … over-blocked (one form permanently) #1082, but on the close arms. merge-guard: literal force-push arms' .* span crosses leg boundaries — push … && rm --force/-f … over-blocked (one form permanently) #1082's fix converted the force-push literal arms to per-leg; it did not touch the close arms.
  2. The two-tier bind fix abstains on ambiguity. Two detectable close legs → _single_detectable_leg returns None (tier-2 abstains on ≥2) → both mint and read fall back to whole-command → both symmetrically over-bind ['--delete-branch'] → the byte-identical re-approval authorizes AND the escalated single authorizes against the [--delete-branch] token.
  3. _collect_pairs collapses both closes to a single (close, 42) key (pr-number extraction is first-match-anywhere), so the multiplicity gate passes.

Fix sketch

Convert the close literal danger arms (pr close\b(?=.*--delete-branch) + the reversed --delete-branch.*close arm) to per-leg matching over the shared leg substrate — the exact #1082 pattern (force-push arms), now applied to close. Consequence: gh pr close 42 && gh pr close 43 && echo --delete-branch classifies not-dangerous (no leg has close+flag together) → no token → runs free; the real single-command gh pr close 42 --delete-branch still gates. This also makes the single-close member (close && echo --delete-branch, #1083) not-dangerous at the danger-determination level — assess interaction with the #1083 two-tier bind fix (likely belt-and-suspenders; the architect should confirm no redundancy conflict).

CAUTION — read danger-floor edit + _collect_pairs: (a) per-leg close matching must still gate every legitimate same-leg dangerous close (gh pr close 42 --delete-branch, cd /repo && gh pr close 42 --delete-branch, -d/--delete-branch spellings, quoted separators, bash -c single-leg) — its own no-new-under-block sweep; (b) assess whether the _collect_pairs multi-key collapse needs its own fix for other shapes, or is mooted by the per-leg conversion for this case.

Also: a test-coverage gap this exposed

The #1083 canary set tested the ambiguous form as an execution against a clean []-token (correctly DENY) but NOT as an approval source (which mints the ['--delete-branch'] token). §12.9's "DENY — fail-safe retained" row has the same gap. The fix (whichever PR) should add the approval-source direction.

Provenance

Surfaced by the TEST engineer during the #1082/#1083 leg-isolation re-cert (PR #1085); mutation-proven pre-existing. The close-arm analogue of #1082; same cross-leg-lookahead-on-a-danger-arm family as #1078 (union arm), #1082 (force-push arms), and #1083 (bind surfaces).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions