Skip to content

merge_guard_post mints token for the WRONG PR when approval text contains an unrelated #NNNN (false 'token does not match' rejections) #1031

Description

@michael-wojcik

Correction (this issue was initially misfiled). It first claimed the merge rejections came from the Claude Code harness ("not any PACT hook"), then from escaped-quote byte-mismatch. Both were wrong — asserted without verifying. After grepping + probing the live hooks, the rejections are from PACT's own merge-guard, and the root cause is a specific, confirmed bug in merge_guard_post.py. Body rewritten to the verified finding.

Problem

A legitimately user-approved squash-merge (PR #1029) was repeatedly rejected by merge_guard_pre.py with "Authorization token exists but does not match this operation" — succeeding only when the command was reduced to a bare form. The merge-guard is a deliberate, valuable security control (prevents API-based bypass of merge approval); this is a false-rejection bug in it, not a request to weaken it.

Root cause (confirmed by probing the live hooks)

The token's pr_number context is minted from the AskUserQuestion question prose:

# merge_guard_post.py:204  (extract_context)
pr_match = re.search(r"#(\d+)|PR\s*(\d+)|pull\s*request\s*(\d+)", question, re.IGNORECASE)

…but read/validated from the anchored gh pr merge <N> command:

# merge_guard_pre.py  (_extract_pr_number → _GH_PR_NUMBER_RE)
#   anchored to `gh pr merge|close`, with a value-taking-flag guard

These are asymmetric. The mint side grabs the FIRST #NNNN anywhere in the approval text. When that text contains an unrelated issue/PR reference before the real PR — e.g. an umbrella issue (#1028) embedded in the squash commit --subject, or a "related #NNN" — the token is minted for the wrong PR. The read side then correctly extracts the real PR from the command, the two disagree, and _token_matches_command returns False → the genuine, approved merge is rejected.

Empirical evidence (live extract_context, this session)

AskUserQuestion text (abridged) minted pr_number command targets result
…gh pr merge 1029 --squash --subject "…(#1028)" --body-file … 1028 (from #1028) 1029 MISMATCH → rejected
…squash-merge of PR #1029 … gh pr merge 1029 --squash 1029 (from #1029) 1029 match → merged

The bare form only succeeded because #1029 happened to be the first #NNNN in its text. The "use the bare command" workaround is therefore fragile coincidence, not a fix — any approval prose mentioning another #NNNN first re-triggers it.

Proposed fix

Make the mint-side extraction symmetric with the read-side:

  1. In extract_context, extract the PR number from the gh pr merge|close <N> command structure embedded in the question (reuse / mirror _extract_pr_number + _GH_PR_NUMBER_RE), not a loose #(\d+) prose match.
  2. Fallback ordering: prefer the command-anchored positional; fall back to #NNNN / PR N prose only when no gh pr merge|close <N> is present in the text.
  3. Regression test: an AskUserQuestion text whose --subject/prose contains an unrelated #NNNN before the real PR positional MUST mint a token for the real PR (assert pr_number == "1029" for the table's row 1 shape).

Sibling-asymmetry note

The same mint-vs-read asymmetry may exist on the branch and operation_type axes (post-side extract_context uses prose regexes; pre-side uses anchored command detection). Worth an audit pass alongside the PR-number fix — see below.

Also confirmed NOT bugs (by-design, keep)

  • cd <repo> && gh pr merge … → "Compound destructive command rejected" is correct: the guard intentionally authorizes one destructive op per approval. The fix is to not compound, not to relax the guard.
  • The op-type + PR-number token-matching model is sound; only the mint-side PR-number extraction is brittle.

Scope

A real, confirmed PACT code bug in merge_guard_post.py — fully within PACT's control, fixable with a symmetric-extraction change + regression test. (The earlier "platform-gate documentation" framing was wrong and is corrected here.)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    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