Repository navigation
feat(tools): guard the public history against private addresses and names - #4
Conversation
…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
PR Review VerdictResult: 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
Findings
Not Checked
Routednone Judgment Dimensions
|
…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
|
Review round 1 (9fd45df, PASS): all four advisories are fixed in 27eaf89. e6a1267 adds one clarification.
Generated by Claude Code |
PR Review VerdictResult: 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
Findings
Not Checked
Routednone Judgment Dimensions
|
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
|
Review round 2 (e6a1267, PASS): both advisories are fixed in 00e3b4e.
Generated by Claude Code |
PR Review VerdictResult: 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
Findings
Not Checked
Routednone Judgment Dimensions
|
…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
|
Review round 3 (00e3b4e, PASS): both advisories are fixed in 16e7cce.
Generated by Claude Code |
PR Review VerdictResult: 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
Findingsnone Not Checked
Routednone Round-3 Advisories
Judgment Dimensions
|
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.pyblocks 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.comfor co-author trailers, reserved example domains); P4 no term from a deny file, only when--deny-fileis passed. Findings are printed masked to their first and last character, because a public repository's CI logs are public too.Key trade-offs:
fetch-depth: 0) so every commit is read.Acceptance List
Derived from the acceptance criteria of GRD.
python3 tools/public_guard.pyexits 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: runpython3 tools/public_guard.py . --commits --alland read0 finding(s); then in a scratch clone add an address on a non-reserved domain to a tracked file, commit nothing, rerunpython3 tools/public_guard.py .and read a P1 line withfile:lineand exit 1; an@example.comaddress in the same place passespython3 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 rungit -c user.email=<address on a non-reserved domain> commit --allow-empty -m test, thenpython3 tools/public_guard.py . --commits HEADand read a P2 line naming the short SHA, exit 1--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 writezebracornto a scratch deny file, commit a message containingZebracorn, runpython3 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.ymland see the guard step passes no--deny-filepython3 tools/boundary_lint.py .) and the CI workflow run on the PR — Verify: thelint.ymldiff setsfetch-depth: 0and adds aPublic-history guardstep running--commits HEAD, under the existingpush/pull_requesttriggers; the workflow run on this PR is green;python3 tools/boundary_lint.py .reports2 finding(s), 0 newCONTRIBUTING.md(noreply address, CI command,git config core.hooksPath tools/hooks) andCLAUDE.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 withpython3 -c "import json; print(json.load(open('.aos/config.json'))['commands']['quality'])"commit-msgandpre-pushhooks intools/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 rungit 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 refusedmask()intools/public_guard.py; runpython3 tools/public_guard.py . --deny-file /nonexistent; echo $?andpython3 tools/public_guard.py . --bogus; echo $?, both print 2What Was Tested
file:line, while anexample.comaddress 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.Risks
🤖 Generated with Claude Code
https://claude.ai/code/session_01Kkcu5mQ2JpAcSWUwskrSJw
Generated by Claude Code