feat(mt#1228): Hook blocks gh api PR-merge calls with merge_method != merge#761
Conversation
… merge Extends the block-git-gh-cli PreToolUse hook to reject gh api PUT calls against the PR-merge endpoint when merge_method is anything other than merge (including absent). Closes a structural enforcement gap documented by three squash-merges that landed on main on 2026-04-24 against explicit policy: mt#1174/PR#732, mt#1203/PR#753, and mt#1131/PR#748 all used merge_method=squash despite the bypass memory feedback_gh_api_bypass.md being cited in every commit message. Behavioral fix via documentation failed; escalating to structural enforcement via hook. The hook exports three helpers (findGhApiMethod, findGhApiEndpoint, findGhApiField) so the detection logic is unit-tested independently of the denial wiring. Endpoint regex anchors on /pulls/N/merge specifically, so /pulls/N/reviews/ID/dismissals and /pulls/N/merges and generic /pulls paths pass through unchanged. Tests: 138 pass (9 new enforcement cases + 3 helper-level). Typecheck clean, lint clean. Also documents the policy in docs/pr-workflow.md as a new Merge method policy section so the rule is discoverable from the PR-flow doc, not only from a memory file, and updates feedback_gh_api_bypass.md with a highlighted Merge method policy subsection.
There was a problem hiding this comment.
Independent adversarial review (Chinese-wall)
Reviewer: minsky-reviewer[bot] via openai:gpt-5
Tier: unknown
Findings
[BLOCKING] .claude/hooks/block-git-gh-cli.ts: findGhApiMethod() returns method verb case-sensitively; ghDenials rule compares against "PUT" only
- Evidence: In findGhApiMethod (around lines ~320-336), the function returns the raw token following -X/--method without normalization:
export function findGhApiMethod(args: string[]): string {
for (let i = 1; i < args.length; i++) {
if (args[i] === "-X" || args[i] === "--method") {
return args[i + 1] ?? "GET";
}
}
return "GET";
}
And the denial rule (ghDenials entry added around ~180-215) checks:
const method = findGhApiMethod(args);
if (method !== "PUT") return false; - Failure mode: A caller can pass lowercase or mixed-case: gh api -X put repos/.../pulls/42/merge -f merge_method=squash. findGhApiMethod returns "put"; the rule does not match (requires "PUT"); the call will not be blocked.
- Impact: Silent bypass of the core enforcement.
- Request: Normalize method to uppercase in findGhApiMethod (e.g., return (args[i + 1] ?? "GET").toUpperCase()) and/or compare case-insensitively in the rule. Add tests for -X put and --method put.
[BLOCKING] .claude/hooks/block-git-gh-cli.ts: findGhApiField() fails when the KEY=VALUE token is shell-quoted; leads to false denials for legitimate uses
- Evidence: In findGhApiField (around lines ~360-372), the code matches any arg.startsWith(
${key}=). However parseSegment (earlier in file; see tests at .claude/hooks/block-git-gh-cli.test.ts “parseCommands” section) is not quote-aware and keeps quotes in tokens (example test shows "'hello'" preserved). Therefore a common invocation like -f "merge_method=merge" will produce a token starting with a quote character, not with merge_method=, and findGhApiField returns null. - Failure mode: A valid command with -f "merge_method=merge" (quoted out of habit or by a generator) is treated as absent and thus blocked by the rule (mergeMethod !== "merge"). This is an over-block of allowed behavior.
- Request: Strip surrounding single/double quotes when scanning for KEY=VALUE tokens, or specifically parse -f/--field/--raw-field value tokens (recognize and unquote them) before comparing. Add a unit test: gh api -X PUT repos/o/r/pulls/42/merge -f "merge_method=merge" should be allowed.
[BLOCKING] .claude/hooks/block-git-gh-cli.ts: findGhApiEndpoint() is not robust to quoted values with spaces when flags precede the endpoint; this creates an easy bypass
- Evidence: findGhApiEndpoint (around lines ~334-359) skips a flag-value pair by doing i += 2 when it sees a value-taking flag (e.g., -f, --field). Because the upstream tokenizer (parseSegment) is not quote-aware, a token like -f commit_title="My PR Title (#42)" is split into multiple tokens: ["-f", 'commit_title="My', 'PR', 'Title', '(#42)"', ...]. The function only skips the first value token after -f, then immediately returns the next non-flag token as the “endpoint.” If the endpoint actually appears after such a -f, findGhApiEndpoint will incorrectly "extract" a fragment of the quoted commit_title as the endpoint, which will not match PR_MERGE_ENDPOINT_RE. The rule then fails to trigger.
- Reproduction scenario: gh api -X PUT -f commit_title="My PR Title (#42)" repos/o/r/pulls/42/merge -f merge_method=squash. With the current code, findGhApiEndpoint likely returns 'PR' (or similar), not "repos/.../merge"; PR_MERGE_ENDPOINT_RE.test(endpoint) fails; the rule allows a prohibited squash-merge.
- Impact: Silent bypass by reordering args to put a quoted -f value before the endpoint (a perfectly valid gh api invocation pattern).
- Request: Make endpoint extraction resilient to quoted values (either implement a minimal quote-aware scan, or specifically recognize -f/--field/--raw-field and skip tokens until the next token that begins with - or until the next value-taking flag). At minimum, add tests covering: endpoint after -f commit_title="...". Right now, this enforcement can be bypassed trivially.
[BLOCKING] .claude/hooks/block-git-gh-cli.ts: Quoted endpoint string is not recognized as PR merge endpoint
- Evidence: findGhApiEndpoint returns the first positional token as-is. If a user quotes the endpoint (e.g., "repos/o/r/pulls/42/merge" or '/repos/o/r/pulls/42/merge'), the returned token includes leading/trailing quotes. PR_MERGE_ENDPOINT_RE = /(^|/)pulls/\d+/merge$/ requires a bare string ending in pulls/N/merge; a leading quote causes the test to fail.
- Failure mode: gh api -X PUT "repos/o/r/pulls/42/merge" -f merge_method=squash will not be blocked (endpoint regex fails due to quotes).
- Request: Strip surrounding quotes from the positional endpoint before applying PR_MERGE_ENDPOINT_RE. Add a test case for a quoted endpoint.
[NON-BLOCKING] NEEDS VERIFICATION: equals-form flags likely aren’t handled (e.g., --method=PUT, -XPUT), and could allow bypass
- Evidence: findGhApiMethod only recognizes a separate value token after -X/--method. If gh supports --method=PUT or -XPUT, current parsing returns default GET and rule does not fire. Similarly, GH_API_VALUE_FLAGS will not cause findGhApiEndpoint to skip a combined "-XPUT" token since it checks exact flag strings.
- Request: Confirm gh api supports/doesn’t support equals-form/combined short flags. If supported, extend parsing to handle equals-form (split on =, or detect -XPUT). Add tests.
[NON-BLOCKING] findGhApiField() doesn’t scope to field flags, scans entire args for key=
- Evidence: The function loops all args and matches any arg starting with prefix = "merge_method=". While unlikely to cause an issue, it could produce false matches if an unrelated token contains “merge_method=” (e.g., accidental inclusion in a quoted blob not intended as a field value).
- Request: Optionally restrict scanning to values that directly follow -f/--field/--raw-field (and after unquoting).
[NON-BLOCKING] Denial reason references an out-of-repo memory path
- Evidence: In ghDenials reason string (around ~200-215), the message points to feedback_gh_api_bypass.md which, per the PR description, lives outside this repo.
- Impact: For human readers browsing the repo, this isn’t actionable. Keeping docs/pr-workflow.md is sufficient; consider referencing only in-repo docs in the denial reason, or clarify “operator memory file” in the message.
- Request: Tweak denial text to reference only docs/pr-workflow.md (or ensure a stable in-repo pointer).
[NON-BLOCKING] Test coverage misses realistic edge cases that matter for enforcement reliability
- Evidence: Tests added for helpers and basic allow/block paths are good, but missing:
- -X put / --method put (lowercase)
- -f "merge_method=merge" and -f 'merge_method=merge'
- Endpoint quoted with "..."
- Endpoint appearing after a quoted -f value with spaces (e.g., commit_title)
- Possibly --method=PUT or -XPUT (if gh supports)
- Request: Add the above tests to prevent regressions and to backstop the fixes requested.
[PRE-EXISTING] parseSegment/splitOnShellOperators are explicitly not quote-aware; the new enforcement depends on reliable arg parsing
- Evidence: The file documents and tests this limitation. However, the new enforcement logic introduces security-policy semantics that are invalidated by this limitation (see BLOCKING findings above).
- Note: While the non-quote-aware parser predates this PR, relying on it for policy enforcement substantially raises the risk and warrants tightening parsing around the specific “gh api …” cases.
Spec verification table
- Task spec: N/A (no task spec provided)
Documentation impact
- docs/pr-workflow.md was updated appropriately with a Merge method policy section and explicit bypass instructions.
- Consider:
- Adjusting the PreToolUse denial reason text to reference only in-repo docs for durability.
- If there are any hook architecture docs (e.g., .claude/hooks/SPEC.md), add a brief note about the new gh api merge-method rule and its rationale.
Conclusion
REQUEST_CHANGES
The current implementation can be bypassed or can over-block due to parsing/normalization gaps:
- Case-sensitive method check (“PUT” vs “put”)
- Quoted field values and quoted endpoints not handled
- Endpoint extraction fails when quoted -f values precede the endpoint
Please address the blockers and add tests for these scenarios to make the enforcement reliable.
…ardening All 4 blocking review findings were genuinely valid bypass or over-block vulnerabilities in the merge-method enforcement. - Case-insensitive method check. findGhApiMethod now uppercases the returned method so -X put and --method put are caught. Tests added for both forms. - Equals form (--method=PUT) and combined short form (-XPUT) now parse correctly. Cobra-style flag syntax that gh accepts. Tests added. - Quote-stripping in findGhApiField. A valid invocation like -f quoted merge_method=merge was previously treated as absent and over-blocked because the upstream tokenizer is not quote-aware. stripSurroundingQuotes helper strips matched single- or double-quote pairs before prefix matching. - Quoted endpoint recognition. findGhApiEndpoint now unquotes the returned positional so a quoted endpoint arg is recognized. The denial rule now uses findGhApiPrMergeEndpointToken which scans ALL tokens (not just the first positional) for a PR-merge endpoint pattern — bypass-proof against quote-splitting of preceding -f values like -f commit_title=quoted My PR that confused positional extraction. Scenario and regression guard tested. - Non-blocking: denial reason no longer references the out-of-repo memory file; it now points only at docs/pr-workflow.md Merge method policy. Regression test asserts the reason text. Tests: 164 pass up from 138. Typecheck, lint, format all clean.
|
Round 1 addressed ( All 4 BLOCKING findings were genuinely valid — real bypass or over-block vulnerabilities in the enforcement. Fixed: [BLOCKING] Case-insensitive method check — [BLOCKING] Quoted [BLOCKING] Endpoint extraction not robust to quote-split [BLOCKING] Quoted endpoint not recognized — [NON-BLOCKING] Equals-form and combined short-form method flags — confirmed gh/Cobra accepts both, so also covered: [NON-BLOCKING] Denial reason references out-of-repo memory path — removed; now points only at [NON-BLOCKING] findGhApiField scans all args for key= — acknowledged; scoping to specifically Tests: 164 pass (up from 138). Typecheck clean, lint clean, format clean. Every BLOCKING finding has a regression guard; every allowed-form case has an explicit test; the bypass scenario from the review ( |
There was a problem hiding this comment.
Review: mt#1228 round-2 — gh api merge-method hook hardening
CI status: 2/2 checks passing (build + Prevent Placeholder Tests)
BLOCKING finding verdict
All 4 BLOCKING findings from round-1 review (4172019976) are substantively addressed. Detailed walkthrough:
BLOCKING 1 (case-sensitive method check — -X put bypass) — CLOSED
findGhApiMethod calls .toUpperCase() on every return path (separate token, equals form, combined short form). The denial rule compares against the uppercased string "PUT". Regression tests: "uppercases lowercase -X values", "uppercases --method values", "uppercases equals form --method=put", "parses combined short form with lowercase -Xput". The bypass via -X put is closed.
BLOCKING 2 (quoted -f "merge_method=merge" over-block) — CLOSED
findGhApiField now calls stripSurroundingQuotes(arg) on every token before the startsWith(prefix) comparison. "merge_method=merge" strips to merge_method=merge and correctly returns "merge". Integration test "allows quoted -f merge_method=merge (over-block regression guard)" exercises the full checkDenial path. Single-quote variant also covered.
BLOCKING 3 (positional scan confusable by quote-split -f values — bypass) — CLOSED
The denial rule no longer calls findGhApiEndpoint. It calls findGhApiPrMergeEndpointToken, which scans ALL tokens without any positional logic. A quote-split commit title like -f commit_title="My PR Title" arriving as ['commit_title="My', 'PR', 'Title"', 'repos/o/r/pulls/42/merge'] still has the real endpoint present in the list and findGhApiPrMergeEndpointToken finds it. Regression test 'blocks endpoint after a quote-split -f commit_title="My PR Title" (bypass regression guard)' covers the exact scenario from round 1.
BLOCKING 4 (quoted endpoint not recognized) — CLOSED
findGhApiPrMergeEndpointToken calls stripSurroundingQuotes(arg) before the regex test. "repos/o/r/pulls/42/merge" strips to the bare endpoint and matches /(^|\/)pulls\/\d+\/merge$/. Integration test "blocks quoted endpoint (regression guard for quote-stripping)" confirms.
Denial reason text
The reason string points exclusively to docs/pr-workflow.md §Merge method policy. It does NOT reference feedback_gh_api_bypass.md. The comment block in the source (line 184 of block-git-gh-cli.ts) references feedback_gh_api_bypass.md — that is a developer-facing code comment, not user-facing denial text. The test "denial reason does not reference out-of-repo memory paths" enforces this distinction at test time.
New bypass / over-block scenarios introduced by round-2 code
findGhApiPrMergeEndpointToken all-tokens scan — false-positive edge case (NON-BLOCKING)
Scanning all tokens rather than the positional slot means a -f field value whose unquoted form happens to end in /pulls/N/merge would be recognized as a PR-merge endpoint. For example, -f commit_title=my-branch-about-pulls/42/merge with a different actual endpoint. This would cause a false-positive block (over-block, not bypass — failing safe). In practice agent commit titles do not end with /pulls/N/merge. Note only.
findGhApiField scans all tokens (NON-BLOCKING)
The field extractor scans every arg, not just tokens following -f/--field. A header token like -H "X-merge_method=merge" would NOT match because after quote-stripping the token starts with X-merge_method=, not merge_method=. No bypass vector identified.
Duplicate -f fields (NON-BLOCKING)
findGhApiField returns the value of the first matching token. -f merge_method=merge -f merge_method=squash would be allowed (first match wins). This requires deliberately duplicating a field — not an accidental failure mode, and out of scope for this fix.
Checked and clear
.claude/hooks/block-git-gh-cli.ts— all 4 bypass/over-block vectors from round 1 closed; new helpers correctly structured;findGhApiEndpointretained but NOT used in the denial rule enforcement path (usesfindGhApiPrMergeEndpointTokeninstead, as documented in JSDoc).claude/hooks/block-git-gh-cli.test.ts— regression guards are genuine (they test the exact scenarios described in round-1 findings, not just happy paths); 19 new test cases across 7 describe blocksdocs/pr-workflow.md §Merge method policy— section added with both normal path and bypass path documented; denial reason correctly references itPR_MERGE_ENDPOINT_RE = /(^|\/)pulls\/\d+\/merge$/— verified correct: does not match/merges,/merge-upstream,/reviews/..., or partial paths; anchored with$so no partial suffix bypassesfeedback_gh_api_bypass.md— already has "Merge method policy" bold-callout subsection; not part of this diff but criterion is satisfied
Spec verification
Task: mt#1228
| Criterion | Status | Evidence |
|---|---|---|
block-git-gh-cli.ts rejects PUT /merge calls with squash/rebase/absent merge_method |
Met | ghDenials rule at lines 183–212; three block tests in checkDenial — gh api PR-merge endpoint describe block |
| Existing tests still pass; new positive/negative cases added | Met | CI green; 19 new test cases across 7 describe blocks |
feedback_gh_api_bypass.md has "Merge method policy" bold-callout subsection |
Met | Present as ## Merge method policy — **ALWAYS merge_method=merge, NEVER squash** (pre-existing update, outside this diff) |
docs/pr-workflow.md has bypass-path note mentioning merge_method=merge |
Met | Lines 254–276 added in this PR |
| Verify locally that hook blocks mock squash-merge invocation | N/A | No live-target; CI test suite is the verification signal |
Documentation impact
Updated docs/pr-workflow.md in this PR — new ## Merge method policy section covering normal path, bypass path, and structural rationale.
Verdict: ready to merge. All 4 BLOCKING findings from round 1 are closed with regression tests. No new BLOCKING issues introduced.
(Had Claude look into this — AI-assisted adversarial review of round 2)
Round 2 (d72c2a2) substantively addresses all 4 BLOCKING findings: case-normalized findGhApiMethod, quote-stripping in findGhApiField, all-tokens scan in findGhApiPrMergeEndpointToken, and quote-stripped endpoint matching. Verified by Chinese-wall reviewer subagent (review 4177184547) — APPROVE with regression guards confirmed for each scenario. R1 was on f15cb77; bot did not post a fresh review against d72c2a2 (webhook-missed class, second confirmed instance per mt#1110 calibration).
…+ post-mortem docs (mt#1372)
## Summary
Implements the rescoped mt#1372 forward instrumentation: every webhook delivery received by the reviewer service is now persisted to a `reviewer_webhook_events` Postgres table, enabling forensic analysis of missed-review incidents that would otherwise leave no trace after Railway log and GitHub App delivery-history retention windows expire.
**What ships:**
- **Drizzle schema** (`src/db/schemas/webhook-events-schema.ts`): `reviewer_webhook_events` table with `delivery_id` UNIQUE, `event_type`, `headers` (jsonb subset), `body` (jsonb), `outcome` (pgEnum, 8 values), `error_details` (jsonb), `received_at`, `processed_at`; three indexes
- **SQL migration** (`migrations/pg/0001_webhook_events.sql`): creates enum type and table; journal updated
- **Persistence module** (`src/webhook-events.ts`): `recordWebhookReceipt` (ON CONFLICT DO NOTHING — first-delivery wins, terminal outcomes preserved on re-delivery), `updateOutcome` (sets `processedAt` on terminal outcomes), `pruneOldRows` (returns count or -1), `extractPersistedHeaders` (truncates HMAC signature to 12-char prefix)
- **server.ts wiring**: synthesizes `synthetic-${crypto.randomUUID()}` for missing x-github-delivery (R2 fix); `recordWebhookReceipt` on every POST regardless of header presence (R1 fix #3); `updateOutcome` at each pipeline stage (reviewer_called, review_submitted, failed_at_reviewer, skipped, failed_at_signature, failed_at_tier_resolve), 24h in-process retention pruner, `log.error("webhook_processing_failed", ...)` OperatorNotify on dispatch/reviewer failures
- **Unit tests** (`src/webhook-events.test.ts` + `src/server.test.ts`): 19 + 9 tests, including 3 pinned regression tests for R1/R2 BLOCKING findings
- **Smoke script** (`scripts/smoke-webhook-events.ts`): POSTs a signed webhook, queries the DB 500ms later via raw SQL (decoupled from src/ TS imports — R1 fix #2), asserts row shape; skips gracefully when env vars are absent (exit 0 SKIP)
- **Post-mortem guide** (`docs/incidents/reviewer-webhook-investigation.md`): 6 SQL queries, investigation workflow, Railway log correlation, retention policy reference
**Scope boundary** (per task spec): no changes to review-worker.ts, providers.ts, sweeper.ts, prior-review-summary.ts, mcp-client.ts, tier-routing.ts, config.ts, logger.ts.
## R1 + R2 review findings addressed
- [R1 BLOCKING] UPSERT overwrites terminal outcomes on re-delivery → onConflictDoUpdate → onConflictDoNothing (commit `9d6268792`)
- [R1 BLOCKING] Smoke script imports TS schema from src/ → switched to raw SQL via postgres-js tagged template (commit `9d6268792`)
- [R1 BLOCKING] POSTs missing x-github-event are skipped → persist every POST with "unknown" sentinel (commit `9d6268792`)
- [R2 BLOCKING] Missing x-github-delivery collapses all malformed POSTs into one row → synthesize `synthetic-${crypto.randomUUID()}` per request (commit `5ca07ab11`)
## Test plan
- [x] `bun test src/webhook-events.test.ts src/server.test.ts` — 28/28 pass (see Execution evidence below)
- [x] `bun run scripts/smoke-webhook-events.ts` — exits 0 (SKIP) when env vars absent
- [ ] Post-deploy: run smoke script with `MINSKY_REVIEWER_WEBHOOK_SECRET`, `MINSKY_SESSIONDB_POSTGRES_URL`, and `SMOKE_REVIEWER_BASE_URL` set against the Railway environment — asserts row in `reviewer_webhook_events` with correct fields
### Execution evidence:
```
$ cd services/reviewer && bun test --preload ../../tests/setup.ts --timeout=15000 src/webhook-events.test.ts src/server.test.ts
(pass) extractPersistedHeaders > extracts all four canonical headers when present
(pass) extractPersistedHeaders > omits absent headers rather than storing null/undefined
(pass) extractPersistedHeaders > partial headers — only present ones are included
(pass) extractPersistedHeaders > signature prefix is always 12 chars even for short signatures
(pass) recordWebhookReceipt > calls db.insert with correct arguments
(pass) recordWebhookReceipt > does not throw when DB insert fails
(pass) recordWebhookReceipt > handles non-JSON body by wrapping in { raw } object
(pass) recordWebhookReceipt > uses ON CONFLICT DO NOTHING (R1 BLOCKING #1 regression — re-delivery must not overwrite terminal outcomes)
(pass) recordWebhookReceipt > accepts 'unknown' sentinel eventType (R1 BLOCKING #3 regression — every POST is persisted)
(pass) updateOutcome > calls db.update and db.where with the delivery ID
(pass) updateOutcome > does NOT set processedAt for non-terminal outcomes
(pass) updateOutcome > includes errorDetails when provided
(pass) updateOutcome > does not throw when DB update fails
(pass) updateOutcome > skipped is a terminal outcome
(pass) updateOutcome > received is a non-terminal outcome
(pass) pruneOldRows > calls db.delete and returns the row count
(pass) pruneOldRows > returns -1 when DB delete fails
(pass) pruneOldRows > returns 0 when no rows are deleted
(pass) pruneOldRows > uses default retention of 90 days when not specified
(pass) webhook handler > responds 200 immediately for pull_request.opened, before runReview finishes
(pass) webhook handler > responds 400 when signature header is absent
(pass) webhook handler > responds 401 when signature is invalid
(pass) webhook handler > skips draft PRs — returns 200 without scheduling a review
(pass) webhook handler > /health returns inflightCount
(pass) webhook handler > missing x-github-delivery synthesizes a unique synthetic-<uuid> per request (R2 BLOCKING regression)
(pass) gracefulShutdown > logs shutdown_drain_start with correct inflightCount, then shutdown_drain_complete
(pass) gracefulShutdown > shutdown_drain_start carries inflightCount=0 when no reviews in flight
(pass) gracefulShutdown > review errors do NOT prevent graceful shutdown from completing
28 pass
0 fail
55 expect() calls
Ran 28 tests across 2 files. [320.00ms]
```
## Concurrency analysis
(N/A — no check-then-act pattern introduced. ON CONFLICT DO NOTHING is structurally atomic in Postgres; per-request UUID synthesis is intra-process and non-shared.)
## Forensic context
Three confirmed instances of missed reviews (PR #677, #748, #761, 2026-04-23/24) could not be diagnosed because GitHub App delivery history (14-day retention) and Railway logs were both expired. This table captures all webhook events from deployment of this PR onward. See `docs/incidents/reviewer-webhook-investigation.md` for investigation queries.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: minsky-ai[bot] <minsky-ai[bot]@users.noreply.github.com>
Summary
Extends the
block-git-gh-cliPreToolUse hook to rejectgh apiPUT calls against the PR-merge endpoint whenmerge_methodis anything other thanmerge(including absent). Closes a structural enforcement gap that allowed three squash-merges to land on main on 2026-04-24 against documented policy.Incident that triggered this
In a single session on 2026-04-24, three PRs were merged via
gh apiwithmerge_method=squash:def6e00e2d03bbc2cd17badb8c6Each commit message cited
~/.claude/projects/.../memory/feedback_gh_api_bypass.mdas justification for the bypass. That memory file's line 23 explicitly shows-f merge_method=mergein the canonical bypass invocation. The agent read the file, paraphrased the policy, and still typedsquash— three times in a row. Memory-as-fix lost to default-behavior gravity.Retrospective root cause:
squashis the GitHub / generic-OSS default;mergeis the Minsky-specific choice. When the agent constructs agh apibody under load, the default wins unless structure prevents it. The prior fix (policy in memory) was behavioral; per the retrospective-skill's escalation rule, behavioral fixes that fail once will fail again. This PR applies structural enforcement.Implementation
block-git-gh-cli.ts— newgh apiarg helpers:findGhApiMethod(args)— extracts the HTTP method, defaultGET.findGhApiEndpoint(args)— first positional after flag/value pairs are stripped; handles-X,--method,-H,-f,--field,--raw-field, etc.findGhApiField(args, key)— extracts a-f KEY=VALUEvalue.New denial rule in
ghDenialsthat fires when ALL of:args[0] === "api"findGhApiMethod === "PUT"/(^|\/)pulls\/\d+\/merge$/(exact — not/merges, not/merge-upstream)findGhApiField("merge_method") !== "merge"(blockssquash,rebase, AND absent — absent is ambiguous intent)Denial reason cites the policy and points at
docs/pr-workflow.mdandfeedback_gh_api_bypass.mdfor guidance.docs/pr-workflow.md— new## Merge method policysection with the rule in bold, the normal path (mcp__minsky__session_pr_merge, which already enforces correctly), and the bypass path with the required-f merge_method=merge. Explains why the hook exists so a future reader understands the structural-over-behavioral reasoning.~/.claude/projects/.../memory/feedback_gh_api_bypass.md— already updated out-of-repo to add a highlighted## Merge method policy — ALWAYS merge_method=merge, NEVER squashsubsection above the bypass pattern.Test plan
-f merge_method=squash,-f merge_method=rebase, nomerge_method,--method PUTlong-form with squash.-f merge_method=merge,/reviews/ID/dismissals(different endpoint), GET on/merge(not a merge op), genericgh api repos/o/r.merge_method=mergeand references the policy docs.validate_typecheck: 0 errors.validate_lint: 0 errors, 0 warnings.format:check: clean.Out of scope
mcp__minsky__session_pr_merge— that MCP tool already enforcesmerge_method=mergeby default; this hook covers thegh apiescape hatch that bypasses the MCP layer.Verification against the original incident
Walking through mt#1131's merge with this hook in place: my
gh api --method PUT /repos/edobry/minsky/pulls/748/merge -f merge_method=squash -f commit_title=...invocation would hit the Bash tool's PreToolUse hook, match the new denial rule, and reject with a message pointing atfeedback_gh_api_bypass.mdand telling me to retry with-f merge_method=merge. Same for #753 and #732. No quiet bypass possible.Why this fix succeeds where the prior one failed: the prior fix required the agent to read a memory file and remember to type a specific value at invocation time. This fix makes typing the wrong value fail loudly with a clear retry instruction. Structure over memory.