You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
merge-guard: value-carrying privileged-flag (--repo/-R) approval over-blocks — mint glues the option's closing backtick onto the flag value, breaking the #1042 set-equality bind (#1042 follow-up) #1059
An authorization token exists but its approved operation does not match this command. The approved command and the executed command must be identical.
…even though the executed command is byte-identical to the approved one. The operator recovers only by re-running the minimal commandgh pr merge 1058 --squash (dropping the privileged flags), which authorizes cleanly.
This is the read-side mirror of #1052's mint-side ergonomics theme, but it is a structurally distinct, mint-succeeded-then-mismatch case — see "Why a sibling of #1052" below.
Root cause (verified live against the shipped modules)
The mint and read sides extract bound_flags from differently-delimited input, and the flag tokenizer strips no delimiters:
MINT (merge_guard_post.py_mint_context_from_bundle, ~L381-391) resolves the command region cleanly via locate_command_regions, but then calls extract_command_context(..., flag_scan_text=" ".join(selected_option_texts)). That flag_scan_text is the full selected-option prose surface — Merge PR 1058 Run `gh pr merge 1058 --squash --delete-branch --repo Synaptic-Labs-AI/PACT-Plugin` — with the markdown backtick delimiters still attached (this widened scan was introduced by security: --admin and other privileged flags ride past merge-guard auth binding #1042 for the "flag after a quoted arg" symmetry case).
extract_privileged_flags (shared/merge_guard_common.py, L495-626) tokenizes with a bare command.split() (L552) and never strips a surrounding quote/backtick from a value. Because --repo is value-carrying and its value is the last token inside the backtick-quoted literal, .split() glues the closing backtick onto the value and the value-capture branch (L585-586) emits `--repo=Synaptic-Labs-AI/PACT-Plugin``.
READ (merge_guard_pre.py_token_matches_command, L1025-1093) scans the bare executed command (no flag_scan_text, no delimiters), yielding the clean --repo=Synaptic-Labs-AI/PACT-Plugin.
The two bound_flags sets then differ by exactly one trailing backtick, and the #1042 never-escalate axis (a2, L1074: set(context["bound_flags"]) != set(cmd["bound_flags"]) -> return False) refuses. operation_type and pr_number are identical on both sides; --delete-branch (boolean, not the last token) is identical; --squash is not privileged. The minimal command worked because it carries zero privileged flags, collapsing both sets to empty (trivially equal).
Trigger (general): any value-carrying privileged flag (--repo/-R, --match-head-commit) — or a trailing boolean privileged flag like --admin (which becomes `--admin``, fails the exact denylist lookup at L569, and is silently dropped from the mint set, also yielding a mismatch) — positioned as the LAST token before the closing markdown backtick that delimits the embedded command literal.
Safety classification: OVER-BLOCK-ONLY (the #1042 bind is doing its job)
This is an ergonomics / legibility bug in the safe #1031 direction — NOT a security/under-block. The read side authoritatively recomputes bound_flags from the actual executed command and refuses on ANY set difference in EITHER direction. The backtick artifact only ever corrupts the MINT (token) side — appending a stray char to a value or dropping a mangled flag. Neither can let the executed command carry MORE privilege than was approved while still matching: an execution-time escalation (an extra privileged flag in the real command) makes the read set a strict superset of the token set → still REFUSES. The never-escalate invariant is preserved; the only failure is REFUSING a legitimate, truly-identical command. Tokens also expire in 5 min (TOKEN_TTL=300), bounding any residual. The fix must not weaken the bind — it must make the two sides tokenize identical input.
Kinship is real (shared read-vs-mint symmetry / self-teaching spirit; #1052 is the mint-side mirror), but four axes make it a distinct sibling rather than a #1052 sub-case:
Note an internal inconsistency this exposes: the existing deny message advises re-approving "with the literal command embedded" (L1150-1157), and the read-side comment says "NEVER suggest running the bare command or simplifying the question" (L1147-1149) — yet for THIS bug re-embedding the full literal re-introduces the backtick artifact and fails again; the only recovery is to simplify (drop the flags). That reinforces that the real fix is mint-side tokenization, not message wording.
Fix direction (NEVER weaken the #1042 set-equality bind)
Primary — make the mint scan the SAME undelimited input the read side scans. Either pass the located command region (not the full option prose) as flag_scan_text in _mint_context_from_bundle, or strip the wrapping markdown-backtick/quote delimiters before/within extract_privileged_flags so a trailing delimiter never leaks into a value-carrying flag's value. Result: mint bound_flags set-equals read bound_flags for a legitimate, truly-identical command → it authorizes.
Secondary (read-side legibility) — name the mismatching axis. When bound_flags is the sole divergence, the deny message should say e.g. "the approval bound flags {X} but the command carries {Y}" instead of the generic "approved operation does not match" (the bound_flags axis is the subtlest: op-type + PR-number look identical to the operator while a --repo value silently differs). This edit is in merge_guard_pre.py and must land in a separate change from PR B (PR A's file).
Acceptance criteria
Mint and read tokenize identical input for privileged-flag extraction: extract_privileged_flags("--repo X", "merge") == extract_privileged_flags("--repo X", "merge")(no delimiter leaks into the value). Cover the-Rshort form,--match-head-commit, and a trailing boolean --admin` (currently silently dropped from the mint set).
End-to-end: a single AskUserQuestion approval embedding the literal gh pr merge <N> --squash --delete-branch --repo <owner/repo> mints a token whose bound_flags set-equals the executed command's, so _token_matches_command returns True (authorizes) without dropping the privileged flags.
Over-block-only confirmation: the fix only flips refuse→authorize for truly-identical commands; it never flips refuse→authorize for a command carrying MORE privilege than approved (no under-block introduced).
(Legibility) When bound_flags is the sole mismatching axis, the read-side deny message names that axis specifically ("the approval bound flags {X} but the command carries {Y}"). This change is scoped to merge_guard_pre.py and is NOT part of PR B.
Summary
A merge approved via AskUserQuestion whose embedded command carries a value-carrying privileged flag as the last token — e.g.
is refused at execution time with:
…even though the executed command is byte-identical to the approved one. The operator recovers only by re-running the minimal command
gh pr merge 1058 --squash(dropping the privileged flags), which authorizes cleanly.This is the read-side mirror of #1052's mint-side ergonomics theme, but it is a structurally distinct, mint-succeeded-then-mismatch case — see "Why a sibling of #1052" below.
Root cause (verified live against the shipped modules)
The mint and read sides extract
bound_flagsfrom differently-delimited input, and the flag tokenizer strips no delimiters:merge_guard_post.py_mint_context_from_bundle, ~L381-391) resolves the command region cleanly vialocate_command_regions, but then callsextract_command_context(..., flag_scan_text=" ".join(selected_option_texts)). Thatflag_scan_textis the full selected-option prose surface —Merge PR 1058 Run `gh pr merge 1058 --squash --delete-branch --repo Synaptic-Labs-AI/PACT-Plugin`— with the markdown backtick delimiters still attached (this widened scan was introduced by security:--adminand other privileged flags ride past merge-guard auth binding #1042 for the "flag after a quoted arg" symmetry case).extract_privileged_flags(shared/merge_guard_common.py, L495-626) tokenizes with a barecommand.split()(L552) and never strips a surrounding quote/backtick from a value. Because--repois value-carrying and its value is the last token inside the backtick-quoted literal,.split()glues the closing backtick onto the value and the value-capture branch (L585-586) emits `--repo=Synaptic-Labs-AI/PACT-Plugin``.merge_guard_pre.py_token_matches_command, L1025-1093) scans the bare executed command (noflag_scan_text, no delimiters), yielding the clean--repo=Synaptic-Labs-AI/PACT-Plugin.The two
bound_flagssets then differ by exactly one trailing backtick, and the #1042 never-escalate axis (a2, L1074:set(context["bound_flags"]) != set(cmd["bound_flags"]) -> return False) refuses.operation_typeandpr_numberare identical on both sides;--delete-branch(boolean, not the last token) is identical;--squashis not privileged. The minimal command worked because it carries zero privileged flags, collapsing both sets to empty (trivially equal).Trigger (general): any value-carrying privileged flag (
--repo/-R,--match-head-commit) — or a trailing boolean privileged flag like--admin(which becomes `--admin``, fails the exact denylist lookup at L569, and is silently dropped from the mint set, also yielding a mismatch) — positioned as the LAST token before the closing markdown backtick that delimits the embedded command literal.Minimal repro (shipped functions)
Safety classification: OVER-BLOCK-ONLY (the #1042 bind is doing its job)
This is an ergonomics / legibility bug in the safe #1031 direction — NOT a security/under-block. The read side authoritatively recomputes
bound_flagsfrom the actual executed command and refuses on ANY set difference in EITHER direction. The backtick artifact only ever corrupts the MINT (token) side — appending a stray char to a value or dropping a mangled flag. Neither can let the executed command carry MORE privilege than was approved while still matching: an execution-time escalation (an extra privileged flag in the real command) makes the read set a strict superset of the token set → still REFUSES. The never-escalate invariant is preserved; the only failure is REFUSING a legitimate, truly-identical command. Tokens also expire in 5 min (TOKEN_TTL=300), bounding any residual. The fix must not weaken the bind — it must make the two sides tokenize identical input.Why a sibling of #1052, not a fold
Kinship is real (shared read-vs-mint symmetry / self-teaching spirit; #1052 is the mint-side mirror), but four axes make it a distinct sibling rather than a #1052 sub-case:
merge_guard_post.pymain()ONLY when_mint_context_from_bundlereturns context-is-None (no token minted). Here a token DID mint (context is not None) — just with a corrupted value — so merge-guard mint ergonomics: silent no-mint on format mismatch + multi-prompt approval (UX hardening) #1052's advisory provably never reaches this case. The denial happens later inmerge_guard_pre.py_token_matches_command(L1074) → deny branch (L1144-1157).merge_guard_pre.py(the read-side deny message), which is PR A / merge-guard: is_dangerous_command false-positive —gh issue commentbody quoting a dangerous command is rejected (substring-scan brittleness) #1037 territory and which PR B must not touch. The tokenization fix lives in the sharedextract_privileged_flagsand/or the mint'sflag_scan_textargument. Folding would collide with PR A's file and re-open the ratified, in-flight cluster plan (PR A already shipped as v4.4.45).--adminand other privileged flags ride past merge-guard auth binding #1042, not merge-guard mint ergonomics: silent no-mint on format mismatch + multi-prompt approval (UX hardening) #1052. This sharpens thebound_flagsaxis security:--adminand other privileged flags ride past merge-guard auth binding #1042 introduced.Note an internal inconsistency this exposes: the existing deny message advises re-approving "with the literal command embedded" (L1150-1157), and the read-side comment says "NEVER suggest running the bare command or simplifying the question" (L1147-1149) — yet for THIS bug re-embedding the full literal re-introduces the backtick artifact and fails again; the only recovery is to simplify (drop the flags). That reinforces that the real fix is mint-side tokenization, not message wording.
Fix direction (NEVER weaken the #1042 set-equality bind)
flag_scan_textin_mint_context_from_bundle, or strip the wrapping markdown-backtick/quote delimiters before/withinextract_privileged_flagsso a trailing delimiter never leaks into a value-carrying flag's value. Result: mintbound_flagsset-equals readbound_flagsfor a legitimate, truly-identical command → it authorizes.bound_flagsis the sole divergence, the deny message should say e.g. "the approval bound flags {X} but the command carries {Y}" instead of the generic "approved operation does not match" (the bound_flags axis is the subtlest: op-type + PR-number look identical to the operator while a--repovalue silently differs). This edit is inmerge_guard_pre.pyand must land in a separate change from PR B (PR A's file).Acceptance criteria
extract_privileged_flags("--repo X", "merge") == extract_privileged_flags("--repo X", "merge")(no delimiter leaks into the value). Cover the-Rshort form,--match-head-commit, and a trailing boolean--admin` (currently silently dropped from the mint set).gh pr merge <N> --squash --delete-branch --repo <owner/repo>mints a token whosebound_flagsset-equals the executed command's, so_token_matches_commandreturns True (authorizes) without dropping the privileged flags.--adminand other privileged flags ride past merge-guard auth binding #1042 set-equality bind atmerge_guard_pre.pyL1074 is unchanged.bound_flagsis the sole mismatching axis, the read-side deny message names that axis specifically ("the approval bound flags {X} but the command carries {Y}"). This change is scoped tomerge_guard_pre.pyand is NOT part of PR B.Cross-references
--adminand other privileged flags ride past merge-guard auth binding #1042 — introduced thebound_flagsnever-escalate axis and the widened mintflag_scan_textscan; this is its direct follow-up.merge_guard_pre.pyedits into post.py-only work.force-push:{--no-verify}decline-veto fix interacts with the same flag-binding path.gh issue commentbody quoting a dangerous command is rejected (substring-scan brittleness) #1037 / v4.4.45 — ownsmerge_guard_pre.py(PR A); the read-side legibility edit must coordinate with it, not PR B.