Skip to content

feat(tools): guard the public history against private addresses and names - #4

Merged
kschlt merged 5 commits into
mainfrom
claude/project-thread-82nj9p
Sep 23, 2026
Merged

kschlt merged 5 commits into
mainfrom
claude/project-thread-82nj9p

Conversation

@claude

@claude claude Bot commented Sep 22, 2026

Copy link
Copy Markdown

Before: the repository is now public, including every commit's author, committer and message. Only attention and a one-off grep kept personal email addresses and private names out, and a pushed commit stays retrievable by SHA even after a rewrite, so one slip is permanent.

After: tools/public_guard.py blocks non-noreply email addresses in tracked files, commit identities and commit messages, and, locally with a private deny file, private names. It runs in CI over the full history, in the configured quality command, and through optional commit-message and pre-push hooks (git config core.hooksPath tools/hooks). CONTRIBUTING.md, CLAUDE.md and the README say so.

How

A stdlib-only script in the style of tools/boundary_lint.py, with four rules: P1 no email in a tracked file, P2 no author or committer email, P3 no email in a commit message, each unless allowlisted (GitHub noreply addresses, noreply@anthropic.com for co-author trailers, reserved example domains); P4 no term from a deny file, only when --deny-file is passed. Findings are printed masked to their first and last character, because a public repository's CI logs are public too.

Key trade-offs:

  • The deny list stays out of this repository, because a list of names that must not appear would itself publish them. CI therefore runs P1 to P3 only; P4 runs locally.
  • A pre-push hook alone only protects people who enable it, so CI is the backstop, and it checks out full history (fetch-depth: 0) so every commit is read.
  • No allowlist file for outside contributors' addresses: they are asked to use their GitHub noreply address instead.

Acceptance List

Derived from the acceptance criteria of GRD.

  • python3 tools/public_guard.py exits 0 on the current tree and full history, and exits 1 naming the file and line when a tracked file contains a non-allowlisted email — proof: eval:guard-email-tree (seed a file with a private-looking address in a scratch copy, run, expect exit 1 and the location) — Verify: run python3 tools/public_guard.py . --commits --all and read 0 finding(s); then in a scratch clone add an address on a non-reserved domain to a tracked file, commit nothing, rerun python3 tools/public_guard.py . and read a P1 line with file:line and exit 1; an @example.com address in the same place passes
  • A commit whose author email is not allowlisted makes python3 tools/public_guard.py --commits <range> exit 1 naming the commit — proof: eval:guard-commit-author (scratch commit with a private author email) — Verify: in a scratch clone run git -c user.email=<address on a non-reserved domain> commit --allow-empty -m test, then python3 tools/public_guard.py . --commits HEAD and read a P2 line naming the short SHA, exit 1
  • With --deny-file, a deny-listed term in a tracked file or a commit message in range makes the guard exit 1, and the deny-list content is never printed to CI logs because CI does not pass a deny file — proof: eval:guard-deny-file (scratch deny file with a harmless test term) — Verify: in a scratch clone write zebracorn to a scratch deny file, commit a message containing Zebracorn, run python3 tools/public_guard.py . --commits HEAD --deny-file <file> and read a masked P4 line (Z*******n), exit 1; then look at .github/workflows/lint.yml and see the guard step passes no --deny-file
  • CI runs the guard over the full history on push and pull request, and the boundary lint still passes — proof: boundary-lint (python3 tools/boundary_lint.py .) and the CI workflow run on the PR — Verify: the lint.yml diff sets fetch-depth: 0 and adds a Public-history guard step running --commits HEAD, under the existing push/pull_request triggers; the workflow run on this PR is green; python3 tools/boundary_lint.py . reports 2 finding(s), 0 new
  • CONTRIBUTING.md and CLAUDE.md state that the repository is public, that every commit is public history, and how the guard is run; the instance config runs the guard with the private deny-list — proof: eval:docs-and-config-review — Verify: read the one new bullet in each of CONTRIBUTING.md (noreply address, CI command, git config core.hooksPath tools/hooks) and CLAUDE.md (full history public, guard command with the deny file); the instance config's quality command lives in the private state repository, so check it locally with python3 -c "import json; print(json.load(open('.aos/config.json'))['commands']['quality'])"
  • The optional commit-msg and pre-push hooks in tools/hooks/ run the guard on the message and identity about to be committed and on every commit about to be pushed, using the local deny file when one exists — Verify: in a scratch clone run git config core.hooksPath tools/hooks; a commit whose message contains an address on a non-reserved domain is refused, a clean one goes through; a push of a commit with a private author email is refused
  • Findings print the matched text masked to its first and last character, and a missing deny file or an unknown option exits 2 — Verify: read mask() in tools/public_guard.py; run python3 tools/public_guard.py . --deny-file /nonexistent; echo $? and python3 tools/public_guard.py . --bogus; echo $?, both print 2

What Was Tested

  • The guard exits 0 on the current tree and on the full history of all refs, with and without the private deny file; the boundary lint still reports 2 findings, 0 new.
  • In a scratch clone: an address on a non-reserved domain in a tracked file fails with P1 and file:line, while an example.com address passes; a commit with a private author email fails with P2 naming the commit; a test term in a scratch deny file fails with P4 on a commit message, case-insensitively; a message file with an address fails with P3.
  • The commit-msg hook blocks a message containing an address and a private committer email, and lets a clean commit through; the pre-push hook blocks a push with a private author email and lets clean pushes through.
  • A missing deny file and an unknown option exit 2.

Risks

  • The guard cannot see pull request titles, bodies or review comments; those still need care by hand.
  • In the commit-msg hook, the tracked-file scan reads the working tree, not the index, so it can flag an unstaged edit, or miss an address that is staged but already removed from the working tree.
  • Outside contributors whose commits use a personal address will fail CI until they switch to their noreply address.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Kkcu5mQ2JpAcSWUwskrSJw


Generated by Claude Code

…ames

The repository is about to become public. From then on every commit is
public history that cannot be taken back: its files, its message, and its
author and committer identity. A pushed commit stays retrievable by SHA even
after a rewrite. Until now only human attention and a one-off grep kept
personal email addresses and private names out. This adds an automatic guard
that runs before commit, before push, and in CI.

tools/public_guard.py is stdlib-only, in the style of tools/boundary_lint.py,
and has four rules:
- P1: no email address in a tracked file unless allowlisted.
- P2: no commit author or committer email outside the allowlist.
- P3: no email address in a commit message unless allowlisted.
- P4: no term from an optional private deny file (--deny-file) in tracked
  files, commit messages, or author and committer names.

The allowlist covers GitHub noreply addresses (*@users.noreply.github.com,
noreply@github.com), noreply@anthropic.com for co-author trailers, and the
reserved example domains (example.com/.org/.net, *.example, *.invalid).
--commits REV checks the commits reachable from REV. --message-file checks a
message about to be committed plus the current identity from git var.
Findings are printed masked to their first and last character, because the
CI logs of a public repository are public too. Exit codes: 0 clean,
1 findings, 2 usage error.

The two hooks in tools/hooks/ run the guard, with the local deny file if
there is one. Enable them with `git config core.hooksPath tools/hooks`.
The lint workflow now checks out full history and runs the guard with
--commits HEAD after the boundary lint. CONTRIBUTING.md says the repository
and its full history are public, asks contributors to commit with their
GitHub noreply address, and says how to enable the hooks. CLAUDE.md tells
agents the same and to run the guard with the private deny file before every
commit. README's tools/ row mentions the guard.

Alternatives considered:
- Keeping the deny list in the repository: rejected, because a list of names
  that must not appear would itself publish them. It stays in the private
  workflow state, and CI runs P1 to P3 only.
- A pre-push hook alone: rejected, because it only runs where someone has
  enabled it. CI is the backstop.
- An allowlist file for outside contributors' addresses: left out.
  Contributors are asked to use their noreply address.

Tested: the guard exits 0 on the current tree and on the full history of
all refs, with and without the private deny file, and the boundary lint
still reports 2 findings, 0 new. In a scratch clone:
- A private-looking address in a tracked file fails with P1 and file:line.
  An example.com address passes.
- A commit with a private author email fails with P2 naming the commit.
- A test term in a scratch deny file fails with P4 on a commit message,
  case-insensitively.
- A message file with an address fails with P3.
- The message hook blocks a message containing an address and a private
  committer email, and lets a clean commit through.
- The pre-push hook blocks a push with a private author email and lets clean
  pushes through.
- A missing deny file and an unknown option exit 2.

Risks: the guard cannot see pull request titles, bodies or review comments,
so those still need care by hand. In the message hook, the tracked-file scan
reads the working tree, not the index. Outside contributors whose commits
use a personal address will fail CI until they switch to their noreply
address.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kkcu5mQ2JpAcSWUwskrSJw
@claude
claude Bot requested a review from kschlt September 22, 2026 22:05
@claude claude Bot assigned kschlt Sep 22, 2026
@claude

claude Bot commented Sep 22, 2026

Copy link
Copy Markdown
Author

PR Review Verdict

Result: PASS

Deterministic: lint: success (CI check run on the PR head; boundary lint + public-history guard over full history) — Tree: kschlt/earnedshape (the PR's own tree; CI) — Head: 9fd45df

Acceptance Criteria

  • [met] The guard exits 0 on the current tree and full history, and exits 1 naming file and line for a non-allowlisted email in a tracked file — Evidence: in a scratch clone at 9fd45df, --commits --all printed 0 finding(s), exit 0; an address on a non-reserved domain appended to README.md gave README.md:84: P1 ..., exit 1; an @example.com address in the same place gave 0 findings, exit 0
  • [met] A commit with a non-allowlisted author email makes --commits <range> exit 1 naming the commit — Evidence: scratch commit with a private-domain identity; --commits HEAD printed P2 for author and committer with the short SHA, exit 1 (note: the verify step's git -c user.email=... is silently overridden when GIT_AUTHOR_EMAIL / GIT_COMMITTER_EMAIL are set in the environment, so the stated command can pass for the wrong reason)
  • [met] With --deny-file, a deny term in a tracked file or commit message exits 1, and CI passes no deny file — Evidence: scratch deny file zebracorn; a commit message Zebracorn gave masked Z*******n, exit 1; the same term in a tracked file gave masked Z*******N, exit 1; lint.yml runs --commits HEAD with no --deny-file
  • [met] CI runs the guard over full history on push and pull request, and the boundary lint still passes — Evidence: lint.yml diff adds fetch-depth: 0 and the Public-history guard step under unfiltered push/pull_request triggers; both check runs on the PR are success; boundary_lint.py . reports 2 finding(s), 0 new
  • [met] CONTRIBUTING.md and CLAUDE.md state the repository and its full history are public and how the guard runs; the instance config runs the guard with the private deny list — Evidence: one new bullet in each file as described; the instance quality command chains the boundary lint and the guard with the private deny list; run locally at 9fd45df over --commits --all with that deny list: 0 findings, exit 0
  • [met] The optional commit-msg and pre-push hooks run the guard on the message, identity and pushed commits — Evidence: with core.hooksPath tools/hooks in a scratch clone: a message containing a private-domain address was refused (P3), a clean commit went through, --author with a private address was refused (P2), a push of a private-author commit to a scratch bare remote was refused, a clean push succeeded
  • [met] Findings are masked to first and last character; a missing deny file or unknown option exits 2 — Evidence: mask() read; every finding printed during this review was masked; --deny-file /nonexistent and --bogus both exit 2

Findings

  • [advisory][reproduced] Nothing in CI shows that the guard detects anything: CI runs it only over a clean tree, so a change that turns detection off still goes green. Changing allowed() to return True still gave 0 finding(s) and exit 0 for the CI command. The eval ids named as proofs (eval:guard-email-tree, eval:guard-commit-author, eval:guard-deny-file) exist nowhere in the repository, so the seeded failures were checked once, by hand. A small seeded self-check in CI (fixtures in a temporary repository, expecting exit 1) would close this.
  • [advisory][reproduced] Git errors are not caught: --commits nosuchrev, or a repo_root that is not a git work tree, ends in a CalledProcessError traceback and exit 1, the code for "violations found", although the docstring promises exit 2 for usage errors. The traceback also prints the absolute repository path. It fails closed, but a typo reads as a finding.
  • [advisory][reproduced] P1 flags common text that is not an email address: an SSH remote git@github.com:owner/repo.git and a retina asset name icon@2x.png are both reported. Neither is in the tree today, so the first document that contains one will fail CI.
  • [advisory][reasoned] The module docstring says CI "repeats it for everything that reaches GitHub", but the guard reads only git content. PR titles, bodies, review comments and issue text are not covered. The PR's Risks section says so, but the docstring does not.

Not Checked

  • Merge commits created by GitHub for other accounts: whether their author identity is a noreply address depends on each merger's GitHub email settings; only the existing history was checked, and it passes
  • The CI job log output (only the check-run conclusions were read)
  • Behaviour under a non-UTF-8 locale, or with a non-UTF-8 deny file (read_text() uses the locale default)
  • Deny-file re: entries, and terms containing # (which the comment rule truncates)
  • Tracked symlinks that point outside the repository (is_file() follows them)
  • The hooks under non-Linux shells (macOS/BSD sh, Windows)
  • Obfuscated or non-ASCII-domain addresses, judged out of scope for the stated rules

Routed

none

Judgment Dimensions

  • Code smell: none introduced. One stdlib script in the conventions of tools/boundary_lint.py, plus two short hooks.
  • Complexity: modest (186 lines), and worth it: it replaces a manual discipline, where one slip is permanent, with a CI gate.
  • Need-to-build: reasoned, not surveyed. Common secret scanners target credentials, not personal addresses plus a private name list with masked output. A small in-repo script that matches the existing lint is proportionate.
  • Condensed better, not worse: yes. The tree is in better shape, with the guarantee enforced in CI and documented where contributors read.

…alse positives

Addresses the four advisories from review round 1 on PR #4.

Self-test: CI only ran the guard over a clean tree, so a guard whose
allowed() always returned true would still have passed. `--self-test`
runs P1 to P4 and the masking against built-in samples (each bad sample
must fire, each good one must stay quiet), and CI runs it as a step
before the full-history check. The bad sample addresses are assembled
at run time from an `at` variable, so the guard's own source holds no
literal address and the tree scan stays clean.

Git errors: a failing git call crashed with a traceback, exited 1 (which
means "violations found") and printed the full local path. It now prints
"git error: git <subcommand> failed: <first stderr line>" and exits 2; a
missing --message-file prints only the file name and exits 2. The
docstring now says exit 2 covers usage and git errors.

False positives: git@github.com (the SSH clone host) is allowlisted, and
matches ending in a common asset extension (.png .jpg .jpeg .gif .svg
.webp .pdf, as in icon@2x.png) are not treated as addresses.

Docstring: it claimed CI covers "everything that reaches GitHub"; it now
says every commit, and that pull request titles, bodies and comments are
outside the guard's reach.

Alternatives: a separate unittest file was rejected because the
repository has no test suite, and a self-test inside the tool keeps it
stdlib-only and runnable in one CI line. Seeding a bad commit in CI was
rejected as heavier and slower than in-memory samples.

Tested: --self-test passes (0 of 8 checks failed). Mutation checks:
making allowed() always return true fails the self-test on P1, P2 and
P3; disabling deny matching fails it on P4. `--commits nosuchref` and a
non-git directory each print a one-line git error and exit 2; a missing
message file exits 2. The guard with the private deny file over all
refs reports 0 findings. Boundary lint: 2 findings, 0 new.

Risks: the asset-extension exclusion could hide a real address at a
domain ending in one of those extensions; none of them is a top-level
domain in use for mail.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kkcu5mQ2JpAcSWUwskrSJw
CLAUDE.md said commits use a GitHub noreply address only, while agent
commits in this repository are authored as noreply@anthropic.com, which
the public-history guard allowlists. The rule now names both: the GitHub
noreply address for people and noreply@anthropic.com for agents, so an
agent reading the rule is not told its own commits break it.

Tested: the guard with the private deny file finds nothing over HEAD;
the boundary lint reports 2 findings, 0 new.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kkcu5mQ2JpAcSWUwskrSJw
@claude

claude Bot commented Sep 22, 2026

Copy link
Copy Markdown
Author

Review round 1 (9fd45df, PASS): all four advisories are fixed in 27eaf89. e6a1267 adds one clarification.

  • CI couldn't show that the guard still catches anything: there is a new --self-test that runs P1–P4 and the masking against built-in samples, and CI now runs it before the full-history check. Making allowed() always return true now fails the self-test on P1–P3, and disabling deny matching fails it on P4. The bad samples are assembled at run time, so the tool's own source stays clean.
  • Git errors: a failing git call now prints a one-line git error and exits 2 instead of crashing with a traceback. A missing message file also exits 2 and prints only the file name.
  • False positives: git@github.com is now allowlisted, and asset names such as icon@2x.png are no longer read as addresses.
  • Docstring: it now says CI covers every commit, and that PR titles, bodies and comments are outside the guard's reach.
  • CLAUDE.md (e6a1267) now names both allowed identities: the GitHub noreply address for people, and noreply@anthropic.com for agent commits.

Generated by Claude Code

@claude

claude Bot commented Sep 22, 2026

Copy link
Copy Markdown
Author

PR Review Verdict

Result: PASS

Deterministic: lint: success (both CI check runs on the PR head, push and pull_request; boundary lint, public-history guard self-test, and the guard over full history) — Tree: kschlt/earnedshape (the PR's own tree; CI) — Head: e6a1267

Acceptance Criteria

  • [met] The guard exits 0 on the current tree and full history, and exits 1 naming file and line for a non-allowlisted email in a tracked file — Evidence: round 2, delta-scoped re-check in a scratch clone at e6a1267: --commits --all printed 0 finding(s), exit 0; a staged file with an address on a non-reserved domain gave a P1 line naming the file and line (sh.md:2), exit 1; git@github.com:o/r.git, icon@2x.png and logo@3x.webp on tracked lines gave no finding
  • [met] A commit with a non-allowlisted author email makes --commits <range> exit 1 naming the commit — Evidence: scratch commit with a private-domain identity, run with the author and committer environment variables unset so -c user.email takes effect; two P2 lines (author and committer), exit 1
  • [met] With --deny-file, a deny term in a tracked file or commit message exits 1, and CI passes no deny file — Evidence: scratch deny file zebracorn, commit message Zebracorn test: masked Z*******n, exit 1; lint.yml at e6a1267 still passes no --deny-file to either guard step
  • [met] CI runs the guard over full history on push and pull request, and the boundary lint still passes — Evidence: lint.yml keeps fetch-depth: 0 and unfiltered push/pull_request triggers and adds a --self-test step before the full-history step; both check runs on e6a1267 are success; boundary_lint.py . reports 2 finding(s), 0 new
  • [met] CONTRIBUTING.md and CLAUDE.md state the repository and its full history are public and how the guard runs; the instance config runs the guard with the private deny list — Evidence: the CLAUDE.md bullet now names both allowed identities, matching the guard's allowlist (GitHub noreply suffix, noreply@anthropic.com); the three commits on the branch are all committed with the second; the instance quality command is unchanged, and run locally at e6a1267 with the private deny list over --commits --all it gives 0 findings, exit 0
  • [met] The optional commit-msg and pre-push hooks run the guard on the message, identity and pushed commits — Evidence: with core.hooksPath tools/hooks in the scratch clone: a message containing a private-domain address was refused (exit 1), a clean commit went through, a clean push to a scratch bare remote succeeded, a push of a private-author commit was refused
  • [met] Findings are masked to first and last character; a missing deny file or unknown option exits 2 — Evidence: mask() unchanged and now asserted by the self-test; every finding printed in this round was masked; the new error paths also exit 2: --commits nosuchrev prints a one-line git error: git log failed: ..., a non-git directory prints git error: git ls-files failed: ..., and a missing --message-file prints only the file name

Findings

  • [advisory][reproduced] The self-test covers the matchers, not the rules: it calls scan_text, check_identity, allowed and mask directly, never the code that reads git. Mutations that turn detection off in that code still leave --self-test at 0 of 8 checks failed and CI green: rule_commits returning [], rule_tracked_files returning [], dropping the committer identity check, ignoring the message file, and turning off P4 on author and committer names. The round-1 advisory is closed for the matching core (allowed() forced to true fails 3 checks, disabled deny matching fails 1) but still open for the git plumbing. The docstring's "so CI shows the guard still catches what it should" claims more than that. A fixture run through rule_commits and rule_tracked_files in a temporary repository would close it.
  • [advisory][reproduced] The asset-name allowlist is not anchored to the @2x shape: allowed() accepts any match ending in .png, .jpg, .jpeg, .gif, .svg, .webp or .pdf. So a real address followed directly by one of those extensions passes. A tracked line containing jane.doe@corp-mail.de.pdf gives 0 findings, and the same rule applies to P2 identities. This is unlikely in practice, but the fix is to match the scale suffix (@<n>x.<ext>) instead.

Not Checked

  • Hooks and guard under non-Linux shells, and behaviour under a non-UTF-8 locale (unchanged from round 1)
  • The CI job log text (only check-run conclusions were read)
  • The synthetic merge commit identity on pull requests from other accounts (unchanged from round 1)
  • Beyond the delta: code unchanged since 9fd45df was not re-audited, apart from re-running each criterion's verification at e6a1267

Routed

none

Judgment Dimensions

  • Code smell: none introduced. The error handling is two narrow except clauses around the rule calls. The self-test builds its bad samples at run time, so the tool's own source stays clean.
  • Complexity: +59 lines, paid for: they add a CI-visible regression check and turn tracebacks into exit 2.
  • Need-to-build: not applicable to the delta. It fixes round-1 advisories on the tool the PR already builds.
  • Condensed better, not worse: yes. Three of the four round-1 advisories are fully closed, and the fourth is closed for the matching core.

Review round 2 on PR #4 passed with two advisories; this fixes both.

The self-test exercised only the matching functions, so breaking the code
that reads git (rule_commits, rule_tracked_files, the committer check, the
message file, the deny list applied to names) still passed. The self-test
now also builds a throwaway git repository in a temporary directory with
known leaks: a tracked file containing a private address, a commit with a
private committer email, a commit whose committer name is a deny-listed
test term, and a message file checked under a private author identity. It
runs rule_tracked_files, rule_commits and rule_message_file against it and
expects exact finding counts. Identities are set through the GIT_AUTHOR_*
and GIT_COMMITTER_* environment variables, saved and restored, because
those override git config; the sample commits use --no-verify so local
hooks cannot interfere. If git cannot build the sample repository, that
is reported as a failed check rather than skipped.

The asset-name exception passed any match ending in an image or pdf
extension, so an address with a trailing ".pdf" went through. It now
exempts only the retina asset shape `@<n>x.<png|jpg|jpeg|gif|svg|webp>`
(e.g. icon@2x.png), and pdf is dropped. A new self-test case expects a
pdf-suffixed address to be flagged. The module docstring describes the
extended self-test.

Tested: `--self-test` reports 0 of 12 checks failed. Mutation checks, each
now caught: rule_commits returning nothing (1 check fails),
rule_tracked_files returning nothing (1), committer check dropped (1),
message file ignored (1), deny list on names disabled (1), allowed()
always true (7), asset exception widened back to .pdf (2). The guard with
the private deny file over all refs finds nothing; boundary lint reports
2 findings, 0 new.

Risks: the self-test now needs a working git binary and a writable
temporary directory; both are present on the CI runner.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kkcu5mQ2JpAcSWUwskrSJw
@claude

claude Bot commented Sep 22, 2026

Copy link
Copy Markdown
Author

Review round 2 (e6a1267, PASS): both advisories are fixed in 00e3b4e.

  • The self-test now exercises the git-reading code. It builds a throwaway repository with four known leaks: a tracked file, a committer email, a committer name, and a new commit's message and identity. It expects exact finding counts. The five mutations from the review now each fail the self-test: no commits read, no files read, committer check dropped, message file ignored, deny list not applied to names.
  • Asset exception narrowed: only the retina shape (@2x.png and the like) is exempt now. An address ending in .pdf is flagged, and a self-test case checks for it.

Generated by Claude Code

@claude

claude Bot commented Sep 22, 2026

Copy link
Copy Markdown
Author

PR Review Verdict

Result: PASS

Deterministic: lint: success (both CI check runs on the PR head, push and pull_request; boundary lint, public-history guard self-test, and the guard over full history) — Tree: kschlt/earnedshape (the PR's own tree; CI) — Head: 00e3b4e

Acceptance Criteria

  • [met] The guard exits 0 on the current tree and full history, and exits 1 naming file and line for a non-allowlisted email in a tracked file — Evidence: round 3, scratch clone at 00e3b4e: --commits --all printed 0 finding(s), exit 0; a staged file with an address whose domain ends in .pdf gave a masked P1 line naming s.md:1, exit 1; icon@2x.png, logo@1.5x.WEBP, git@github.com:o/r.git and an example.com address on the next line gave no finding
  • [met] A commit with a non-allowlisted author email makes --commits <range> exit 1 naming the commit — Evidence: scratch commit with a private-domain identity, author and committer environment variables unset so -c user.email takes effect; two P2 lines naming the short SHA, exit 1
  • [met] With --deny-file, a deny term in a tracked file or commit message exits 1, and CI passes no deny file — Evidence: scratch deny file zebracorn, commit message Zebracorn test: masked Z*******n, exit 1; lint.yml is unchanged in this round and passes no --deny-file to either guard step
  • [met] CI runs the guard over full history on push and pull request, and the boundary lint still passes — Evidence: both lint check runs on 00e3b4e are success; --self-test locally prints 0 of 12 checks failed; boundary_lint.py . reports 2 finding(s), 0 new
  • [met] CONTRIBUTING.md and CLAUDE.md state the repository and its full history are public and how the guard runs; the instance config runs the guard with the private deny list — Evidence: neither file changed in this round; the instance quality command still chains the boundary lint and the guard with the private deny list, and run at 00e3b4e over --commits --all with that list it gives 0 findings, exit 0
  • [met] The optional commit-msg and pre-push hooks run the guard on the message, identity and pushed commits — Evidence: with core.hooksPath tools/hooks in a scratch clone: a message containing a private-domain address was refused (P3, exit 1), a clean commit went through, a clean push to a scratch bare remote succeeded, a push of a private-author commit was refused
  • [met] Findings are masked to first and last character; a missing deny file or unknown option exits 2 — Evidence: every finding printed in this round was masked; --deny-file /nonexistent and --bogus both exit 2

Findings

  • [advisory][reproduced] The throwaway repository in git_cases inherits the caller's git environment. With GIT_DIR set, --self-test writes its three sample commits into the repository GIT_DIR names, one of them with a non-noreply committer address, and adds leak.md to its index. It still reports 0 of 12 checks failed, exit 0. Git exports GIT_DIR to hooks run in a linked worktree, so a future hook that calls --self-test there would add a leaking commit to the real repository. No current caller does this: CI, the quality command and the two shipped hooks never run --self-test. The same gap shows with a global commit.gpgsign = true: the sample repository cannot be built, the self-test fails with only CalledProcessError and no reason, and the check count drops from 12 to 10. A fix: pass an explicit env to the sample's git calls with GIT_DIR, GIT_WORK_TREE, GIT_INDEX_FILE and GIT_OBJECT_DIRECTORY removed and global/system config ignored (GIT_CONFIG_GLOBAL=/dev/null, GIT_CONFIG_NOSYSTEM=1), instead of changing os.environ for the whole process.
  • [advisory][reproduced] The round-2 advisory is closed for the five named mutations. Each now fails the self-test, and so do three more: rule_commits reading only the newest commit, deny terms dropped from committer names in rule_commits, and the old any-extension asset rule. Two detections that the CI full-history step depends on are still not covered, though. Removing the author-identity check in rule_commits, or its P3 scan of commit bodies, still leaves --self-test at 0 of 12 checks failed. Every sample commit uses a noreply author and a clean message. The updated docstring says the self-test "runs every rule against … known leaks", which overstates it by these two paths. Adding one sample commit with a leaking author and message body, and raising the expected count, would close this. P4 on commit bodies and tracked files is also uncovered, but it only runs locally.

Not Checked

  • Hooks and guard under non-Linux shells, and behaviour under a non-UTF-8 locale (unchanged from rounds 1 and 2)
  • The CI job log text (only check-run conclusions were read)
  • The synthetic merge-commit identity on pull requests from other accounts (unchanged from round 1)
  • Whether any git version exports GIT_DIR to hooks in a main (non-linked) worktree; only git 2.43 was checked
  • Beyond the delta (e6a1267..00e3b4e, one file): code unchanged since e6a1267 was not re-audited, apart from re-running each criterion's verification at 00e3b4e

Routed

none

Judgment Dimensions

  • Code smell: minor. git_cases changes os.environ for the whole process and restores it in finally. That is correct but more fragile than passing env= to the sample's git calls, and it is the root of the first advisory.
  • Complexity: +52 lines, paid for. The self-test now reads real git output through the shipped rule functions instead of calling the matchers directly.
  • Need-to-build: not applicable to the delta. It closes the round-2 advisories on the tool the PR already builds.
  • Condensed better, not worse: yes. Both round-2 advisories are closed as stated. The remaining gaps are narrower and advisory. They do not by themselves call for another rework round.

…mmit rule

Review round 3 on PR #4 passed with two advisories; this commit fixes both.

The self-test's throwaway repository inherited the caller's git
environment. With GIT_DIR set (git sets it for hooks in a linked
worktree), the sample commits, one of them with a non-noreply committer,
landed in the real repository while the self-test still reported
success. With commit signing turned on in the global config, the
self-test failed without giving a reason. git_cases now saves the whole
environment, removes every GIT_* variable, points GIT_CONFIG_GLOBAL at
the null device, sets GIT_CONFIG_NOSYSTEM=1, sets identities per commit,
and restores the environment exactly afterwards.

Removing the author-email check or the commit-message scan still left
the self-test green, although the docstring says the self-test covers
every rule. The sample history now holds a commit with a non-noreply
author and an address in its message, and the commit-rule check expects
four findings: author email, message address, committer email and a
deny-listed committer name.

Tested: --self-test reports 0 of 12 checks failed. Four mutations are
each caught by one failing check: author check removed, message scan
removed, committer check removed, tracked-file scan emptied. With
GIT_DIR pointing at another fresh repository, the self-test passes and
that repository stays without commits. With a global config that turns
on commit signing, the self-test passes. The guard with the private deny
file finds nothing over all refs; boundary lint reports 2 findings,
0 new.

Risks: none beyond the previous round's (the self-test needs git and a
writable temp directory).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kkcu5mQ2JpAcSWUwskrSJw
@claude

claude Bot commented Sep 22, 2026

Copy link
Copy Markdown
Author

Review round 3 (00e3b4e, PASS): both advisories are fixed in 16e7cce.

  • Self-test isolation: the sample repository now runs with every GIT_* variable removed and with no global or system config, and the environment is restored afterwards. With GIT_DIR pointing at another repository, that repository stays untouched. With global commit signing turned on, the self-test still passes.
  • Coverage: the sample history now includes a commit with a non-noreply author and an address in its message. Removing the author check or the message scan now fails the self-test.

Generated by Claude Code

@claude

claude Bot commented Sep 22, 2026

Copy link
Copy Markdown
Author

PR Review Verdict

Result: PASS

Deterministic: lint: success (both CI check runs on the PR head, push and pull_request; boundary lint, public-history guard self-test, and the guard over full history) — Tree: kschlt/earnedshape (the PR's own tree; CI) — Head: 16e7cce

Acceptance Criteria

  • [met] The guard exits 0 on the current tree and full history, and exits 1 naming file and line for a non-allowlisted email in a tracked file — Evidence: round 4, scratch clone at 16e7cce: --commits --all printed 0 finding(s), exit 0; a staged file with an address on a non-reserved domain gave a masked P1 line naming s.md:1, exit 1; an example.com address in the same place gave 0 findings, exit 0
  • [met] A commit with a non-allowlisted author email makes --commits <range> exit 1 naming the commit — Evidence: scratch commit with a private-domain identity, with the author and committer environment variables unset; two P2 lines naming the short SHA, exit 1
  • [met] With --deny-file, a deny term in a tracked file or commit message exits 1, and CI passes no deny file — Evidence: scratch deny file zebracorn, commit message Zebracorn test: masked Z*******n, exit 1; lint.yml is unchanged in this round and passes no --deny-file to either guard step
  • [met] CI runs the guard over full history on push and pull request, and the boundary lint still passes — Evidence: both lint check runs on 16e7cce are success; --self-test locally prints 0 of 12 checks failed; boundary_lint.py . reports 2 finding(s), 0 new
  • [met] CONTRIBUTING.md and CLAUDE.md state the repository and its full history are public and how the guard runs; the instance config runs the guard with the private deny list — Evidence: neither file changed in this round; the instance quality command still chains the boundary lint and the guard with the private deny list, and run at 16e7cce over --commits --all with that list it gives 0 findings, exit 0
  • [met] The optional commit-msg and pre-push hooks run the guard on the message, identity and pushed commits — Evidence: with core.hooksPath tools/hooks in a scratch clone: a message containing a private-domain address was refused (exit 1), a clean commit went through, a clean push to a scratch bare remote succeeded, a push of a private-author commit was refused (exit 1)
  • [met] Findings are masked to first and last character; a missing deny file or unknown option exits 2 — Evidence: every finding printed in this round was masked; --deny-file /nonexistent and --bogus both exit 2

Findings

none

Not Checked

  • git older than 2.32, which ignores GIT_CONFIG_GLOBAL, so the sample repository there would still read the caller's global config; only git 2.43 was run
  • A real hook invocation from a linked worktree; isolation was checked by setting GIT_DIR, GIT_WORK_TREE and GIT_INDEX_FILE directly
  • Hooks and guard under non-Linux shells, and behaviour under a non-UTF-8 locale (unchanged from earlier rounds)
  • The CI job log text (only check-run conclusions were read)
  • The synthetic merge-commit identity on pull requests from other accounts (unchanged from round 1)
  • Beyond the delta (00e3b4e..16e7cce, one file, git_cases only): code unchanged since 00e3b4e was not re-audited, apart from re-running each criterion's verification at 16e7cce

Routed

none

Round-3 Advisories

  • Isolation: closed, reproduced. GIT_DIR, GIT_WORK_TREE and GIT_INDEX_FILE pointed at a scratch repository: the self-test passed (0 of 12) and that repository's HEAD, index and log stayed unchanged. The self-test also passed with commit.gpgsign = true set four ways: through HOME, through the XDG config, through GIT_CONFIG_PARAMETERS, and through GIT_CONFIG_COUNT/KEY/VALUE. A control commit showed that the same global config really does break signing outside the sample. Called in-process with extra GIT_* and other variables set, self_test() left os.environ exactly as it found it.
  • Coverage: closed, reproduced. Removing the author check, the commit-body P3 scan or the committer check each now fails one check. So does emptying the tracked-file scan, reading only the newest commit, not applying deny terms to names, or ignoring the message file.

Judgment Dimensions

  • Code smell: none new. git_cases still changes os.environ for the whole process, not passing env= as round 3 suggested. It now saves and restores the whole environment, the restore was verified exact, and the guard runs single-threaded and exits right after, so the fragility is only theoretical.
  • Complexity: +22/−12 lines in one function, paid for. The small commit() helper removes the repeated flags.
  • Need-to-build: not applicable to the delta. It closes the round-3 advisories on the tool the PR already builds.
  • Condensed better, not worse: yes. One known gap remains, carried from round 3 and not new: P4 deny matching on commit bodies and tracked files is still not exercised through the git-reading code (turning it off there leaves 0 of 12). P4 runs only locally, never in CI. The docstring's list of sample leaks still reads "tracked file, committer, name, new commit" and does not name the new author and message sample. That list understates the coverage rather than overstating it. The review loop has converged.

@kschlt
kschlt merged commit 8c911b8 into main Sep 23, 2026
2 checks passed
@kschlt
kschlt deleted the claude/project-thread-82nj9p branch September 23, 2026 05:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants