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
48 changes: 42 additions & 6 deletions test/cli/pii-regex-guard.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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: ... <noreply@anthropic.com>", and GitHub
# squash merges add "Co-authored-by: ... <user@users.noreply.github.com>" --
# 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):
# <path><TAB><line-regex (ERE)><TAB><reason>
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down
125 changes: 125 additions & 0 deletions test/cli/test-pii-regex-guard.sh
Original file line number Diff line number Diff line change
@@ -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 <noreply@anthropic.com>\n' | run_stdin)"
assert_eq "$rc" "0" "Claude Code co-author trailer passes"

rc="$(printf 'Co-authored-by: octocat <octocat@users.noreply.github.com>\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 <ghp_abcdefghijklmnopqrst@users.noreply.github.com>\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 <noreply@anthropic.com>\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 <noreply@anthropic.com>\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
2 changes: 2 additions & 0 deletions test/pii-regex-allowlist.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions test/run-all.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading