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 }); } +});