fix(#47): catch gh api .../merge bypass in merge-gate hooks#54
Merged
Conversation
The three merge-gate hooks (block-unreviewed-merge.sh, block-merge-on-red-ci.sh, require-design-review-for-ui.sh) only matched `Bash(gh pr merge *)` in .claude/settings.json, and each extracted the PR number by grepping the first integer after `gh pr merge`. Merges via the raw REST-API shape `gh api repos/<owner>/<repo>/pulls/<N>/merge -X PUT` silently skipped all three gates. Incident: me2resh/curios-dog#190 was merged via `gh api` while CI was still running. Changes: - New shared helper .claude/hooks/_lib-extract-pr.sh with is_merge_command and extract_pr_number that recognise both shapes. - Refactor all three merge-gate hooks to source the helper. Also recover owner/repo from the gh api URL path so downstream `gh pr view`, `gh pr diff`, and `gh pr checks` stay repo-scoped. - Settings.json: add sibling matchers on `Bash(gh api *)` for each of the three merge-gate hooks (Option A — matchers are globs, not regex, so alternation isn't supported). - Docs: .claude/rules/pr-workflow.md gets a new "Both merge shapes are gated (#47)" subsection; .claude/hooks/README.md notes the dual registration and points future edits at the shared helper. Verified both shapes via manual hook invocation: gh pr merge 99999 --squash -> BLOCKED, PR=99999 gh api repos/me2resh/curios-dog/pulls/190/merge ... -> BLOCKED, PR=190 Ticket: #47
atlas-apex
commented
Apr 14, 2026
atlas-apex
left a comment
Collaborator
Author
There was a problem hiding this comment.
Code Review: PR #54
Commit: a44b3c5e27f3c2c3c7d30728e4badeffa49e5058
Summary
Closes the gh api .../pulls/<N>/merge -X PUT silent-bypass gap on all three merge-gate hooks (#47). Introduces _lib-extract-pr.sh as the single source of truth for PR-number parsing, adds sibling Bash(gh api *) matcher entries in settings.json, and updates docs.
Checklist Results
- Architecture & Design: Pass — single parser in
_lib-extract-pr.sh, three hooks source it, no duplication. - Code Quality: Pass — well-commented, defensive extraction (handles
|,&&,;boundaries), URL-path fallback for--reporecovery. - Testing: Pass (manual) — author's test plan covers both shapes + non-merge passthrough. Reproduced locally: classic shape blocks PR #100,
gh apishape blocks the same PR #100,gh api .../issues/42exits 0,gh pr view 123exits 0. - Security: Pass — no cross-repo marker lookup; markers correctly scoped to local repo root via
onboarding.yamlwalk. - Performance: Pass — cheap greps, no new network calls in helper.
- PR Description & Glossary: Pass — Glossary includes 7 terms covering
gh apishape, matcher semantics,_lib-convention, silent bypass, Option A/B. - Technical Decisions (AgDR): N/A — the Option A vs Option B tradeoff is narrow (globs don't support alternation, so sibling entries are forced) and documented in PR body + pr-workflow.md. Not material enough for an AgDR.
Findings
- Minor — false-positive on
gh api .../pulls/<N>/mergeGET.is_merge_commandmatches the URL path without checking for-X PUT. A GET to inspect merge status (rare but legal) would also trip the gate. Not blocking — worst case is "use a different command to inspect." Safer than the opposite failure. - Verified no self-approval:
.claude/session/reviews/54-*.approveddoes not exist. - Cross-repo resolution confirmed correct: markers read from local apexstack root regardless of the target repo in the API URL — exactly right for session-state semantics.
Follow-up (not blocking)
- File: tighten
is_merge_commandto require-X PUTor--method PUTalongside the/mergepath, to eliminate the GET false-positive.
Verdict
APPROVED
Reviewed by Rex (Code Reviewer Agent)
Reviewed commit: `a44b3c5e27f3c2c3c7d30728e4badeffa49e5058`
me2resh
approved these changes
Apr 14, 2026
atlas-apex
commented
Apr 14, 2026
atlas-apex
left a comment
Collaborator
Author
There was a problem hiding this comment.
Re-review: PR #54
Commit: 1238688be77c7654ddb38aef530e90a7b0a6a44a
Verification
- Incremental diff is exactly 4 deletions, 0 additions in
block-unreviewed-merge.sh(the deadREPO_FLAG=""/if [ -n "$CMD_REPO" ]/REPO_FLAG="--repo $CMD_REPO"/fiblock). Nothing else snuck in. - Grepped the file for
REPO_FLAG— zero remaining references.CMD_REPOis still computed for parity with the sibling hooks but the unusedREPO_FLAGis gone. No downstream logic broken:extract_pr_numberfrom_lib-extract-pr.shhandles PR-number extraction without needing the flag. - CI on new HEAD: all green —
shellcheck .claude/hookspasses, along withVerify Ticket ID,lychee,markdownlint-cli2.
Verdict
APPROVED (posted as comment — GitHub blocks self-approval)
🤖 Re-reviewed by Rex (Code Reviewer Agent)
📌 Reviewed commit: 1238688be77c7654ddb38aef530e90a7b0a6a44a
osama-abu-baker
pushed a commit
to osama-abu-baker/apexyard
that referenced
this pull request
Jun 3, 2026
…e2resh#54) * fix: catch gh api .../merge bypass in merge-gate hooks The three merge-gate hooks (block-unreviewed-merge.sh, block-merge-on-red-ci.sh, require-design-review-for-ui.sh) only matched `Bash(gh pr merge *)` in .claude/settings.json, and each extracted the PR number by grepping the first integer after `gh pr merge`. Merges via the raw REST-API shape `gh api repos/<owner>/<repo>/pulls/<N>/merge -X PUT` silently skipped all three gates. Incident: me2resh/curios-dog#190 was merged via `gh api` while CI was still running. Changes: - New shared helper .claude/hooks/_lib-extract-pr.sh with is_merge_command and extract_pr_number that recognise both shapes. - Refactor all three merge-gate hooks to source the helper. Also recover owner/repo from the gh api URL path so downstream `gh pr view`, `gh pr diff`, and `gh pr checks` stay repo-scoped. - Settings.json: add sibling matchers on `Bash(gh api *)` for each of the three merge-gate hooks (Option A — matchers are globs, not regex, so alternation isn't supported). - Docs: .claude/rules/pr-workflow.md gets a new "Both merge shapes are gated (me2resh#47)" subsection; .claude/hooks/README.md notes the dual registration and points future edits at the shared helper. Verified both shapes via manual hook invocation: gh pr merge 99999 --squash -> BLOCKED, PR=99999 gh api repos/me2resh/curios-dog/pulls/190/merge ... -> BLOCKED, PR=190 Ticket: me2resh#47 * fix: remove unused REPO_FLAG from block-unreviewed-merge.sh (shellcheck SC2034) --------- Co-authored-by: me2resh <ahmed.abdelaliem@gmail.com>
mosta7il
pushed a commit
to mosta7il/apexyard
that referenced
this pull request
Jun 8, 2026
…e2resh#54) * fix: catch gh api .../merge bypass in merge-gate hooks The three merge-gate hooks (block-unreviewed-merge.sh, block-merge-on-red-ci.sh, require-design-review-for-ui.sh) only matched `Bash(gh pr merge *)` in .claude/settings.json, and each extracted the PR number by grepping the first integer after `gh pr merge`. Merges via the raw REST-API shape `gh api repos/<owner>/<repo>/pulls/<N>/merge -X PUT` silently skipped all three gates. Incident: me2resh/curios-dog#190 was merged via `gh api` while CI was still running. Changes: - New shared helper .claude/hooks/_lib-extract-pr.sh with is_merge_command and extract_pr_number that recognise both shapes. - Refactor all three merge-gate hooks to source the helper. Also recover owner/repo from the gh api URL path so downstream `gh pr view`, `gh pr diff`, and `gh pr checks` stay repo-scoped. - Settings.json: add sibling matchers on `Bash(gh api *)` for each of the three merge-gate hooks (Option A — matchers are globs, not regex, so alternation isn't supported). - Docs: .claude/rules/pr-workflow.md gets a new "Both merge shapes are gated (me2resh#47)" subsection; .claude/hooks/README.md notes the dual registration and points future edits at the shared helper. Verified both shapes via manual hook invocation: gh pr merge 99999 --squash -> BLOCKED, PR=99999 gh api repos/me2resh/curios-dog/pulls/190/merge ... -> BLOCKED, PR=190 Ticket: me2resh#47 * fix: remove unused REPO_FLAG from block-unreviewed-merge.sh (shellcheck SC2034) --------- Co-authored-by: me2resh <ahmed.abdelaliem@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes the silent merge-gate bypass surfaced in #47. The three merge-gate hooks were only wired on
Bash(gh pr merge *), so merges performed via the raw REST-API shapegh api repos/<owner>/<repo>/pulls/<N>/merge -X PUTskipped all three gates. Incident:me2resh/curios-dog#190was merged viagh apiwhile CI was still running..claude/hooks/_lib-extract-pr.shexposesis_merge_command+extract_pr_number, recognising bothgh pr merge <N>andgh api .../pulls/<N>/merge. Sourced by all three merge-gate hooks so the parser lives in exactly one place.block-unreviewed-merge.sh,require-design-review-for-ui.sh, andblock-merge-on-red-ci.shnow call the helper instead of inline-grepping. Each also recoversowner/repofrom thegh apiURL path when--repois absent, so downstreamgh pr view/gh pr diff/gh pr checksremain scoped correctly..claude/settings.jsongets siblingBash(gh api *)matcher entries for each of the three hooks. Option A (sibling entries), because theiffield is a glob pattern, not a regex, so alternation isn't supported. The hooks themselves filter non-mergegh apicalls viais_merge_command, which specifically looks for/pulls/<N>/mergein the URL path — any othergh apicall exits 0 silently..claude/rules/pr-workflow.md § "Both merge shapes are gated (#47)"states the rule explicitly and calls out that usinggh api .../mergeto route around a gate is itself a rule violation on par with forging an approval marker..claude/hooks/README.mdnotes the dual registration.Test plan
bash .claude/hooks/block-unreviewed-merge.shstdin = classic shape → BLOCKED, PR=99999bash .claude/hooks/block-unreviewed-merge.shstdin = API shape → BLOCKED, PR=99999bash .claude/hooks/block-merge-on-red-ci.shstdin = API shape → firesgh pr checksagainst the correct repobash .claude/hooks/require-design-review-for-ui.shstdin = API shape → extracts PR, proceeds through diff checkgh pr view 123passes through silently (exit 0)gh api repos/foo/bar/issues/42passes through silently (helperis_merge_commandrequires/pulls/<N>/merge)jq . .claude/settings.json)gh apimerge attempt is blocked in a live Claude Code session (requires a PR to merge against — ideally this PR itself, after approval)Glossary
gh pr mergeshapeghsubcommand form of merging a PR, e.g.gh pr merge 123 --squash. What Claude is supposed to use.gh apishapegh api repos/owner/repo/pulls/123/merge -X PUT. Equivalent effect, different matcher — which is exactly what let it bypass the gates.iffield).claude/settings.jsonthat decides whether a hook fires on a given tool call. Globs don't support alternation, so two shapes need two matcher entries._lib-prefix.claude/hooks/that is NOT a hook itself. The harness ignores it; other hook scriptssourceit.Ticket: #47