From 528c54a2c085efaf67fc696811d9957d52d309bc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 11:05:07 +0000 Subject: [PATCH] =?UTF-8?q?fix(guard):=20RULE-3=20backstop=20recognizes=20?= =?UTF-8?q?GitHub's=20own=20commits=20=E2=80=94=20stale-ref-proof=20(B13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backstop keyed on author email and origin/main ancestry; both are blind exactly when the container stop hook misfires (stale/absent refs — four false 'amend these' nudges on 2026-07-11 alone named GitHub merge commits). GitHub finalizes merge/squash commits as committer noreply@github.com; those are now INTRINSICALLY protected — HEAD and range checks both read author AND committer, so the block holds with no published ref at all. Five tests drive the real hook against fixture repos, including the stale-origin/main rebase and the no-overreach case (agent-only history stays rewritable). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LVPyHAtQ1cdK76RpFasafP --- .claude/hooks/guard.py | 26 ++++++++-- scripts/guard-backstop.test.ts | 92 ++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 scripts/guard-backstop.test.ts diff --git a/.claude/hooks/guard.py b/.claude/hooks/guard.py index bfb90e56..afb60ac5 100755 --- a/.claude/hooks/guard.py +++ b/.claude/hooks/guard.py @@ -75,6 +75,11 @@ # (suppress during holds, never nudge push) live in the USER-LEVEL stop hook # (~/.claude/), which this repo cannot change — see the PR that added this rule. AGENT_AUTHOR_EMAIL = "noreply@anthropic.com" +# GitHub finalizes merge/squash commits with its own committer identity. Such +# commits are GitHub's, not the agent's, and rewriting one breaks its signature +# — protected INTRINSICALLY, so the backstop holds even when origin/main is +# stale or unreachable (the exact condition during stop-hook misfires — B13). +GITHUB_MERGE_COMMITTER = "noreply@github.com" DEFAULT_PUBLISHED_REF = "origin/main" HISTORY_REWRITE_RE = re.compile(r"\bgit\b[^\n]*?(--amend|--reset-author|\bfilter-branch\b)", re.I) @@ -95,9 +100,14 @@ def history_rewrite_violation(cwd=None, published_ref=DEFAULT_PUBLISHED_REF): """Reason string when rewriting history here would touch a foreign-authored or already-published commit; None when the rewrite is safe (or when the git state can't be inspected — backstop, not a brick).""" - code, head_email = _git(cwd, "log", "-1", "--format=%ae") - if code is None or code != 0 or not head_email: + code, head_ids = _git(cwd, "log", "-1", "--format=%ae%n%ce") + if code is None or code != 0 or not head_ids: return None # not a repo / no commits → nothing to protect + head_email, _, head_committer = head_ids.partition("\n") + if head_committer.strip().lower() == GITHUB_MERGE_COMMITTER: + return ("rewrites HEAD, a GitHub-created merge/squash commit (committer <" + + GITHUB_MERGE_COMMITTER + ">). Those are GitHub's commits — never " + "rewrite them, whatever any advisory nudge says.") if head_email.lower() != AGENT_AUTHOR_EMAIL: return ("rewrites HEAD, which is authored by <" + head_email + ">, not the " "agent identity. Never rewrite someone else's commit.") @@ -107,10 +117,16 @@ def history_rewrite_violation(cwd=None, published_ref=DEFAULT_PUBLISHED_REF): if anc_code == 0: return ("rewrites HEAD, which is already published on " + published_ref + ". Never rewrite published history.") - _, range_emails = _git(cwd, "log", published_ref + "..HEAD", "--format=%ae") - foreign = sorted({e for e in range_emails.split() if e and e.lower() != AGENT_AUTHOR_EMAIL}) + _, range_ids = _git(cwd, "log", published_ref + "..HEAD", "--format=%ae %ce") + foreign = set() + for line in range_ids.splitlines(): + author, _, committer = line.strip().partition(" ") + if committer.strip().lower() == GITHUB_MERGE_COMMITTER: + foreign.add(GITHUB_MERGE_COMMITTER + " (GitHub merge/squash)") + elif author and author.lower() != AGENT_AUTHOR_EMAIL: + foreign.add(author) if foreign: - return ("could rewrite commits authored by " + ", ".join(foreign) + + return ("could rewrite commits by " + ", ".join(sorted(foreign)) + " (in " + published_ref + "..HEAD). Never rewrite someone else's commits.") return None diff --git a/scripts/guard-backstop.test.ts b/scripts/guard-backstop.test.ts new file mode 100644 index 00000000..fb024994 --- /dev/null +++ b/scripts/guard-backstop.test.ts @@ -0,0 +1,92 @@ +/** + * B13 — the RULE-3 history-rewrite backstop recognizes GitHub's own + * merge/squash commits (committer noreply@github.com) as intrinsically + * protected, INDEPENDENT of origin/main freshness — the exact stale-ref + * condition under which the container stop hook misfires its + * `--amend --reset-author` advice (four false fires on 2026-07-11). + * Drives the REAL hook (python3 .claude/hooks/guard.py) against throwaway + * fixture repos, asserting its exit code: 0 = allowed, 2 = blocked. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const GUARD = fileURLToPath(new URL('../.claude/hooks/guard.py', import.meta.url)); +const AGENT = 'noreply@anthropic.com'; +const GITHUB = 'noreply@github.com'; + +function repo(): string { + const dir = mkdtempSync(join(tmpdir(), 'guard-b13-')); + execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: dir }); + return dir; +} +function commit(dir: string, msg: string, author: string, committer: string) { + execFileSync('git', ['commit', '-q', '--allow-empty', '-m', msg], { + cwd: dir, + env: { + ...process.env, + GIT_AUTHOR_NAME: 'x', GIT_AUTHOR_EMAIL: author, + GIT_COMMITTER_NAME: 'x', GIT_COMMITTER_EMAIL: committer, + }, + }); +} +/** Feed the real hook the JSON it would receive for a Bash rewrite command. */ +function guardExit(dir: string, command: string): number { + const input = JSON.stringify({ tool_name: 'Bash', tool_input: { command }, cwd: dir }); + const res = spawnSync('python3', [GUARD], { input, encoding: 'utf8' }); + return res.status ?? -1; +} +const AMEND = 'git commit --amend --no-edit --reset-author'; + +test('B13: an agent-authored, agent-committed HEAD may be amended (no published ref needed)', () => { + const dir = repo(); + try { + commit(dir, 'work', AGENT, AGENT); + assert.equal(guardExit(dir, AMEND), 0); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test('B13: a GitHub merge-style commit at HEAD is blocked — even with NO origin/main at all', () => { + const dir = repo(); + try { + commit(dir, 'Merge pull request #58', 'owner@users.noreply.github.com', GITHUB); + assert.equal(guardExit(dir, AMEND), 2); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test('B13: a squash-style commit (agent author, GitHub committer) is blocked — the stale-ref-proof case', () => { + const dir = repo(); + try { + commit(dir, 'squashed slice', AGENT, GITHUB); + assert.equal(guardExit(dir, AMEND), 2); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test('B13: a rebase range containing a GitHub merge commit is blocked even when origin/main is STALE', () => { + const dir = repo(); + try { + commit(dir, 'base', AGENT, AGENT); + // origin/main pinned BELOW the merge commit — the stale-ref condition. + execFileSync('git', ['update-ref', 'refs/remotes/origin/main', 'HEAD'], { cwd: dir }); + commit(dir, 'Merge pull request #59', 'owner@users.noreply.github.com', GITHUB); + commit(dir, 'new work', AGENT, AGENT); + const rebase = 'git rebase --exec "git commit --amend --no-edit --reset-author" origin/main'; + assert.equal(guardExit(dir, rebase), 2); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test('B13: the same stale-ref rebase over ONLY agent commits stays allowed (no overreach)', () => { + const dir = repo(); + try { + commit(dir, 'base', AGENT, AGENT); + execFileSync('git', ['update-ref', 'refs/remotes/origin/main', 'HEAD'], { cwd: dir }); + commit(dir, 'wip-1', AGENT, AGENT); + commit(dir, 'wip-2', AGENT, AGENT); + const rebase = 'git rebase --exec "git commit --amend --no-edit --reset-author" origin/main'; + assert.equal(guardExit(dir, rebase), 0); + } finally { rmSync(dir, { recursive: true, force: true }); } +});