Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 21 additions & 5 deletions .claude/hooks/guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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.")
Expand All @@ -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

Expand Down
92 changes: 92 additions & 0 deletions scripts/guard-backstop.test.ts
Original file line number Diff line number Diff line change
@@ -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 }); }
});
Loading