diff --git a/test/cli/pii-regex-guard.sh b/test/cli/pii-regex-guard.sh index f3d9c9a..f9ddc1c 100644 --- a/test/cli/pii-regex-guard.sh +++ b/test/cli/pii-regex-guard.sh @@ -11,7 +11,17 @@ # violation unless test/pii-regex-allowlist.txt has an entry with # (1) a path equal to the file AND (2) an ERE matching the line. # --stdin Read raw text from stdin (e.g. commit messages). ANY hit is a -# violation -- no allowlist applies. +# violation -- the path allowlist does not apply. +# +# Built-in noreply exemption (both modes): an email hit whose FULL match is a +# known non-personal noreply address is never a violation. Claude Code stamps +# every commit with "Co-Authored-By: ... ", and GitHub +# squash merges add "Co-authored-by: ... " -- +# without this exemption every such PR fails the commit-message scan. These +# addresses are undeliverable by design and identify no mailbox. The check is +# full-match-anchored, so an address that merely extends a safe local part or +# puts "noreply" on another domain still fails; phone/secret hits are never +# exempt, including a phone/secret smuggled inside a safe-shaped address. # # Allowlist format (same contract as test/cjk-allowlist.txt): # @@ -34,15 +44,38 @@ PHONE_RE='\b[0-9]{2,4}-[0-9]{2,4}-[0-9]{4}\b|\+[0-9]{1,3}([- .][0-9]{1,4}){1,3}[ SECRET_RE='AKIA[0-9A-Z]{16}|\bgh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|-----BEGIN [A-Z ]*PRIVATE KEY-----|xox[baprs]-[A-Za-z0-9-]{10,}|\bsk-[A-Za-z0-9_-]{20,}' PATTERN="$EMAIL_RE|$PHONE_RE|$SECRET_RE" +# See "Built-in noreply exemption" in the header. Anchored so only a hit that +# IS one of these addresses in full (not one merely containing/extending one) +# passes. +SAFE_NOREPLY_RE='^(noreply@anthropic\.com|[A-Za-z0-9._%+-]+@users\.noreply\.github\.com)$' + +# 0 iff every PATTERN match on the line ($1) is a safe noreply address. +line_all_safe() { + local m + while IFS= read -r m; do + [[ $m =~ $SAFE_NOREPLY_RE ]] || return 1 + # The combined PATTERN is leftmost-longest, so an email match can shadow + # a phone/secret match overlapping it (e.g. a token as the local part of + # a users.noreply address). Re-scan the matched address on its own. + if grep -qE "$PHONE_RE|$SECRET_RE" <<< "$m"; then return 1; fi + done < <(grep -oE "$PATTERN" <<< "$1") + return 0 +} + mode="${1:-tree}" scan_stdin() { - local hits + local hits line violations=0 # grep exits 1 on no match; that is the clean case, not an error. hits="$(grep -nE "$PATTERN" || true)" - if [ -n "$hits" ]; then - printf '%s\n' "$hits" | sed 's/^/(stdin):/' - echo "pii-regex-guard: email/phone/secret pattern in stdin (no allowlist applies here)" >&2 + [ -z "$hits" ] && return 0 + while IFS= read -r line; do + line_all_safe "${line#*:}" && continue + printf '(stdin):%s\n' "$line" + violations=$((violations + 1)) + done <<< "$hits" + if [ "$violations" -gt 0 ]; then + echo "pii-regex-guard: email/phone/secret pattern in stdin (only the built-in noreply exemption applies here)" >&2 return 1 fi return 0 @@ -72,6 +105,8 @@ scan_tree() { while IFS= read -r line; do hit_path="${line%%:*}" hit_line="${line#*:}"; hit_line="${hit_line#*:}" # strip path: and lineno: + # Built-in noreply exemption: value-safe regardless of path. + line_all_safe "$hit_line" && continue # Per-value allowlisting: strip every allowlisted match from the line, # then re-scan the remainder. A real secret sharing a line with an # allowlisted placeholder therefore still fails. @@ -86,7 +121,8 @@ scan_tree() { used[i]=1; stripped_any=1 done done - if [ "$stripped_any" -eq 1 ] && ! grep -qE "$PATTERN" <<< "$remainder"; then + # Clean iff everything left after stripping is at most safe-noreply. + if [ "$stripped_any" -eq 1 ] && line_all_safe "$remainder"; then allowed=1 fi if [ "$allowed" -eq 0 ]; then diff --git a/test/cli/test-pii-regex-guard.sh b/test/cli/test-pii-regex-guard.sh new file mode 100644 index 0000000..e6149ff --- /dev/null +++ b/test/cli/test-pii-regex-guard.sh @@ -0,0 +1,125 @@ +#!/bin/bash +# test/cli/test-pii-regex-guard.sh — pii-regex-guard.sh stdin/tree behavior +# +# Regression suite for the guard that CI runs over the tree and over PR +# commit messages. The load-bearing properties: +# - the built-in noreply exemption lets Claude Code's Co-Authored-By +# trailer (noreply@anthropic.com) and GitHub's anonymized commit +# addresses (*@users.noreply.github.com) through in BOTH modes — these +# appear in every Claude-authored commit message, and without the +# exemption every such PR fails the commit-message scan (the 2026-08-10 +# PR #83 failure) +# - the exemption is full-match-anchored: lookalikes that extend the local +# part or swap the domain still fail +# - everything else (personal-looking emails, phones, secrets) still fails +# in --stdin mode, where the path allowlist does not apply +# - tree mode honors the exemption path-independently, and still enforces +# the per-path allowlist for all other hits +# +# Usage: bash test/cli/test-pii-regex-guard.sh +# Requires: bash, git. No claude CLI, no network. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +GUARD="$REPO_ROOT/test/cli/pii-regex-guard.sh" + +# shellcheck source=test/assertions/lib.sh +source "$SCRIPT_DIR/../assertions/lib.sh" + +WORK="$(mktemp -d)" +cleanup() { rm -rf "$WORK"; } +trap cleanup EXIT + +# stdin mode needs a CWD inside some git repo (the script resolves the repo +# root before reading stdin); the real repo works and its tree is not read. +run_stdin() { + local rc=0 + (cd "$REPO_ROOT" && bash "$GUARD" --stdin) > "$WORK/out.txt" 2>&1 || rc=$? + echo "$rc" +} + +echo "=== stdin mode: noreply exemption ===" + +rc="$(printf 'Co-Authored-By: Claude Fable 5 \n' | run_stdin)" +assert_eq "$rc" "0" "Claude Code co-author trailer passes" + +rc="$(printf 'Co-authored-by: octocat \n' | run_stdin)" +assert_eq "$rc" "0" "GitHub anonymized commit address passes" + +rc="$(printf 'Co-authored-by: octocat <12345+octocat@users.noreply.github.com>\n' | run_stdin)" +assert_eq "$rc" "0" "GitHub id+login anonymized address passes" + +echo "" +echo "=== stdin mode: exemption anchoring ===" + +rc="$(printf 'Contact: foo.noreply@anthropic.com\n' | run_stdin)" +assert_eq "$rc" "1" "extended local part (foo.noreply@) still fails" + +rc="$(printf 'Contact: noreply@fixture.example\n' | run_stdin)" +assert_eq "$rc" "1" "noreply local part on another domain still fails" + +rc="$(printf 'noreply@anthropic.com and alice@fixture.example\n' | run_stdin)" +assert_eq "$rc" "1" "safe address sharing a line with an unsafe one still fails" + +# The combined pattern is leftmost-longest: an email match can shadow an +# overlapping phone/secret match, so a value smuggled into the local part of +# a safe-shaped address must be re-caught inside the match. +rc="$(printf 'Co-authored-by: x \n' | run_stdin)" +assert_eq "$rc" "1" "secret token as safe-address local part still fails" + +rc="$(printf 'Co-authored-by: x <090-1234-5678@users.noreply.github.com>\n' | run_stdin)" +assert_eq "$rc" "1" "phone number as safe-address local part still fails" + +echo "" +echo "=== stdin mode: non-email patterns unaffected ===" + +rc="$(printf 'Reach me at alice@fixture.example\n' | run_stdin)" +assert_eq "$rc" "1" "personal-shaped email fails" +assert_file_contains "$WORK/out.txt" "alice@fixture" "violation output names the offending line" + +rc="$(printf 'Call 090-1234-5678 tomorrow\n' | run_stdin)" +assert_eq "$rc" "1" "phone number fails" + +rc="$(printf 'token ghp_abcdefghijklmnopqrst0123456789ABCDEF\n' | run_stdin)" +assert_eq "$rc" "1" "secret-shaped token fails" + +rc="$(printf 'a normal commit message\n\nwith body text only\n' | run_stdin)" +assert_eq "$rc" "0" "clean text passes" + +rc="$(printf 'fix guard\n\nCo-Authored-By: Claude Fable 5 \n' | run_stdin)" +assert_eq "$rc" "0" "realistic Claude commit message passes end-to-end" + +echo "" +echo "=== tree mode: exemption + allowlist ===" + +TREE="$WORK/scratch" +mkdir -p "$TREE/test" +git -C "$TREE" init -q + +# No commits are made in the scratch repo — `git add` alone is enough for +# `git ls-files`, so no git identity is configured. +run_tree() { + local rc=0 + (cd "$TREE" && bash "$GUARD") > "$WORK/tree-out.txt" 2>&1 || rc=$? + echo "$rc" +} + +printf 'docs quoting Co-Authored-By: Claude Fable 5 \n' > "$TREE/README.md" +git -C "$TREE" add -A +rc="$(run_tree)" +assert_eq "$rc" "0" "tree: noreply trailer passes with no allowlist entry" + +printf 'contact alice@fixture.example here\n' > "$TREE/note.md" +git -C "$TREE" add -A +rc="$(run_tree)" +assert_eq "$rc" "1" "tree: unallowlisted email fails" +assert_file_contains "$WORK/tree-out.txt" "note.md" "tree violation names the file" + +printf 'note.md\t@fixture\\.example\tsynthetic placeholder\n' > "$TREE/test/pii-regex-allowlist.txt" +git -C "$TREE" add -A +rc="$(run_tree)" +assert_eq "$rc" "0" "tree: allowlisted email passes" + +report_results diff --git a/test/pii-regex-allowlist.txt b/test/pii-regex-allowlist.txt index 76a2791..3b25f16 100644 --- a/test/pii-regex-allowlist.txt +++ b/test/pii-regex-allowlist.txt @@ -13,6 +13,8 @@ plugins/google-meet/plugin.md your-email@example\.com setup doc placeholder addr test/cli/test-cli-smoke.sh smoke@example\.com test fixture git identity test/cli/test-pii-hook.sh @(example\.com|client-corp\.co\.jp|service\.io|partner-firm\.com|real-company\.net|my-own-domain\.jp|corp-x\.jp|real-client\.org) PII-hook suite synthetic email fixtures the tests assert on test/cli/test-pii-hook.sh (090-1234-5678|080-9999-8888|\+81[- ]90-1234-5678|\+81 80 4232 1097|\+1\.2\.345) PII-hook suite synthetic phone/version fixtures the tests assert on +test/cli/test-pii-regex-guard.sh (@fixture\.example|foo\.noreply@anthropic\.com) guard-suite synthetic email fixtures, incl. lookalikes probing the noreply exemption anchors +test/cli/test-pii-regex-guard.sh (090-1234-5678|ghp_[A-Za-z0-9]{20,}) guard-suite synthetic phone/token fixtures the tests assert on test/cli/test-track-managed-gitignore.sh owner@example-own\.jp test fixture allowlist content .claude/rules/rill-workspace.md [0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}\.md journal timestamp filename (YYYY-MM-DD-HHMM.md), not a phone number SPEC.md [0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}\.md journal timestamp filename examples, not phone numbers diff --git a/test/run-all.sh b/test/run-all.sh index 15ebbc8..d368b9c 100755 --- a/test/run-all.sh +++ b/test/run-all.sh @@ -52,6 +52,7 @@ else fi echo "" run_test "PII regex guard" "$SCRIPT_DIR/cli/pii-regex-guard.sh" +run_test "PII regex guard suite" "$SCRIPT_DIR/cli/test-pii-regex-guard.sh" run_test "/distill" "$SCRIPT_DIR/skills/test-distill.sh" run_test "/briefing" "$SCRIPT_DIR/skills/test-briefing.sh"