Private Information Scanning
July 28, 2026 · View on GitHub
Automated scanning to prevent private information from being committed to public repositories.
How It Works
Checkpoints scan for private information:
| Checkpoint | When | Mechanism |
|---|---|---|
| Git commit (files) | Every git commit | Pre-commit hook (claude-global/hooks/pre-commit) |
| Git commit (message) | Every git commit | Commit-msg hook (claude-global/hooks/commit-msg) |
| Claude Code edit | Every Edit/Write tool call | PreToolUse hook (claude-global/hooks/scan-outbound.js) |
| Claude Code commit | Every git commit via Bash tool | PreToolUse hook (claude-global/hooks/scan-outbound.js) |
| Claude Code forge write | Every gh issue/pr create/edit/close/comment/review via Bash tool | PreToolUse hook (claude-global/hooks/scan-outbound.js) |
All call bin/scan-outbound.sh as the scanner (single source of truth for patterns).
Known false-negatives (v1): gh repo edit/create/rename/archive commands are not scanned — they have zero callers in the current codebase. gh api raw calls are not scanned regardless of payload. Non-github.com remotes follow the same private-repo skip path as all other commands.
Wrapper-script blind spot: gh / gh api calls issued from inside wrapper scripts — multi-process chains such as run-completion.sh → clarify-commit-scope.sh → gh issue create — are invisible to scan-outbound.js. The hook inspects only the literal Bash-tool command string, never what a subprocess does internally. The convention below closes that gap.
Wrapper-script self-scan convention
Scripts that pass free text (title, body, commit message, file content) to gh must call gh_outbound_guard from bin/lib/gh-outbound-guard.sh immediately before sending.
- Two-layer defense: the hook layer covers what Claude types directly in Bash tool calls; the script layer covers what scripts do internally to
gh. Neither subsumes the other. - Fail-closed: warn-tier scanner results are treated as blocks, identically to hard violations — these scripts run non-interactively, so there is no user to ask. An unresolvable scanner or a usage error also blocks. See
## Scanner Exit Codesfor the code table. <label>argument: pass the actual logical file path so per-file.private-info-allowlistentries (path:pattern) apply. Allowlist entries are label-scoped — the same pattern under a different label still blocks.- Writes with no backing local file (inline issue body text, composed revision notes) have no path to key on; they can only be relaxed via global (non-per-file) allowlist patterns.
- The guard is a sourced library: it
returns, neverexits, and must be invoked with input redirection (gh_outbound_guard "<label>" < "$file") — never on the right-hand side of a pipe, which would run it in a subshell and loseGH_OUTBOUND_GUARD_MESSAGE.
Private repos are skipped: detected dynamically via gh api (GitHub CLI). If the repo's private flag is true, scanning is skipped. If gh is unavailable or the API call fails, scanning proceeds (fail-open, safe default).
Forge-write target visibility
For gh issue/pr writes, the repo whose visibility governs the scan is the target repo — resolved from an explicit --repo/-R owner/name flag when present, otherwise the current working directory's repo. This prevents a public-target write launched from inside a private working directory from escaping the scan. Target-visibility resolution fails closed: if the target repo cannot be determined or its visibility lookup errors, the content is scanned as if the target were public. A --repo/-R value smuggled inside a quoted --body/--title argument is ignored for target resolution — only a real flag selects the target.
Private-repo name leaks (public targets only): when the target repo is public, outbound content is additionally scanned for the names of your private repositories (e.g. owner/private-repo, or #N cross-references to them). The private-repo name list is fetched dynamically via gh repo list --visibility private; if that lookup fails, no extra names are added (fail-open). This complements the soft-prompt guidance in rules/github-issues.md (## Multi-repo leak prevention).
Detection Patterns
| Type | Pattern | Examples |
|---|---|---|
| RFC 1918 IPv4 | 10.x.x.x, 172.16-31.x.x, 192.168.x.x | 192.168.1.1, 10.0.0.1 |
| Email addresses | user@domain.tld | user@example.com |
| MAC addresses | XX:XX:XX:XX:XX:XX / XX-XX-XX-XX-XX-XX | aa:bb:cc:dd:ee:ff |
| Absolute local paths | /Users/<name>, /home/<name>, C:\Users\<name> | /Users/john/docs |
| Hard secrets | AWS/Anthropic/OpenAI/GitHub/Slack/Google/HuggingFace/Groq/Replicate/Cohere API keys, PEM private keys | AKIA..., sk-ant-api03-..., ghp_..., -----BEGIN RSA PRIVATE KEY----- |
| Hidden Unicode (Trojan Source) | Zero-width: U+200B, U+200C, U+200D, U+FEFF. Bidi overrides: U+202D, U+202E, U+2066–2069 | CVE-2021-42574 |
| Blocklist patterns | User-defined in .private-info-blocklist (repo root); prefix warn: for soft-block | Hostnames, domain names; warn: suspicious combinations |
| Private-repo name leak (public forge-writes only) | Dynamic: names of your private repos + owner/repo#N cross-references to them, in content pushed to a public target repo | owner/private-repo, owner/private-repo#42 |
Setup
Automatically enabled after running install.sh / install.ps1. The global git config (.config/git/config) sets core.hooksPath to ~/dotfiles/claude-global/hooks, activating the pre-commit hook for all repos.
Prerequisites
ghCLI installed and authenticated (gh auth login)- Private repo detection works automatically — no setup needed
Allowlist (Exception Patterns)
Add exceptions to .private-info-allowlist, one pattern per line.
Each repo's .private-info-allowlist in its root is the only allowlist loaded.
.private-info-allowlist is write-protected by the block-dotenv.js PreToolUse hook —
Claude Code cannot edit it automatically. Add exceptions manually when genuinely needed.
# Global pattern (applies to all files)
git@github.com
noreply.github.com
# Per-file pattern (format: filepath:pattern — filepath supports glob matching)
docs/networking.md:192.168
tests/*:@example.com
Blocklist (Additional Detection Patterns)
The blocklist lives in .private-info-blocklist at the repo root (gitignored; symlinked from
a private repo by the installer) to avoid exposing blocked patterns in a public repo. One regex per line.
.private-info-blocklist.example (tracked) serves as a format reference — see it for annotated examples.
Soft-block (warn) Patterns
Prefix a pattern with warn: to mark it as a soft-block. Soft-block hits do not
immediately fail — instead:
| Context | Behavior on warn hit |
|---|---|
| Claude Code Edit/Write/Bash | PreToolUse hook returns block asking the model to confirm with the user |
Interactive git commit (TTY available) | Pre-commit / commit-msg hook prompts Proceed with commit? [y/N] |
Non-interactive git commit (CI, no TTY) | Treated as a hard block (safe default) |
Hard-block patterns and warn: patterns coexist in the same file. If both match the
same content, hard wins (exit 1). Output uses [blocklist] for hard hits and
[blocklist-warn] for soft hits.
An empty warn: line (nothing after the colon) is skipped with a stderr warning —
it would otherwise match every scanned line.
# Hard block — always rejects
internal-host\.example
# Soft block — high false-positive risk, defer to user
warn:(?i)(api.*myname|myname.*api)
Manual Scanning
Scan specific files:
bin/scan-outbound.sh path/to/file1 path/to/file2
Scan from stdin:
echo "some content" | bin/scan-outbound.sh --stdin
echo "some content" | bin/scan-outbound.sh --stdin filename-label
Scan all tracked files in a repo:
git ls-files | while read f; do [ -f "$f" ] && bin/scan-outbound.sh "$f"; done
Troubleshooting
Commit blocked by false positive
Add the pattern to .private-info-allowlist and re-commit.
Pre-commit hook not running
Verify that core.hooksPath is set:
git config --get core.hooksPath
# Should show: ~/dotfiles/claude-global/hooks
Claude Code hook not blocking
Ensure settings.json has the hooks section (check ~/.claude/settings.json).
Files
| File | Purpose |
|---|---|
bin/scan-outbound.sh | Scanner script (detection patterns) |
bin/lib/gh-outbound-guard.sh | Sourceable fail-closed guard wrapper scripts call before handing free text to gh |
claude-global/hooks/pre-commit | Git pre-commit hook (staged files) |
claude-global/hooks/commit-msg | Git commit-msg hook (commit message) |
claude-global/hooks/scan-outbound.js | Claude Code PreToolUse hook |
claude-global/hooks/lib/is-private-repo.js | Shared module: dynamic private repo detection via gh api |
.private-info-allowlist | Exception patterns |
.private-info-blocklist | Additional detection patterns (gitignored, symlinked from private repo) |
bin/scan-offensive | Offensive content detector: keyword tier + optional LLM tier (forward filter) / --skill-mode JSONL manifest (skill path) |
skills/scan-offensive/scripts/scan-repo.sh | Drives retroactive scan: fetches issues/comments, invokes bin/scan-offensive --skill-mode, emits JSONL manifest |
Offensive Content Filter
A companion two-tier detector (bin/scan-offensive) flags offensive content
(hate speech, slurs, harassment, profanity) in addition to the private-info scan.
Tier 1 is a regex blocklist (.offensive-content-blocklist); Tier 2 is an
optional LLM classifier for borderline text (forward filter only — see below).
| Checkpoint | When | Mode |
|---|---|---|
| Claude Code forge write | Every gh issue/pr write via scan-outbound.js | Forward filter (always-on, public + private repos) |
| Manual retroactive scan | On-demand via /scan-offensive skill | Retroactive (any repo) |
.offensive-content-blocklistis gitignored and must be created manually from.offensive-content-blocklist.example. When absent,bin/scan-offensiveruns keyword-only with no patterns (clean by default).block-dotenv.jswrite-protects it — Claude Code cannot edit it.- Forward filter LLM tier:
ANTHROPIC_API_KEYenables the LLM classifier inscan-outbound.js. When unset, the forward filter runs keyword-only with a stderr warning. The/scan-offensiveskill path does not use an external API — CC evaluates all items inline. - Redact behavior: matches are edited to
[redacted by content-scan], never deleted. - The forward filter runs for all repos (public and private). The private-info scan continues to skip private repos.
Retroactive scan — three-phase procedure
/scan-offensive fetches issues and comments via skills/scan-offensive/scripts/scan-repo.sh,
passes each body through bin/scan-offensive --skill-mode to produce a JSONL manifest
(preamble record + one item record per issue/comment body), then CC evaluates each item
inline against the classification rubric. Each item record's envelope field wraps the
body in a typed XML <item> structure with XML-entity escaping and a standing instruction
that marks the <content> region as untrusted data.
Range filters narrow the scan for large repos:
| Flag | Effect |
|---|---|
--since YYYY-MM-DD | Issues updated on or after this date (server-side) |
--until YYYY-MM-DD | Issues updated on or before this date (client-side) |
--from-issue N | Issue numbers ≥ N |
--to-issue N | Issue numbers ≤ N |
--limit N | Stop after N issues |
--apply --manifest-path FILE --confirm-ids ID,... applies redactions from a previously
produced manifest. Canary semantics: first confirmed ID is redacted, then the script
exits 0 so CC can verify before proceeding to remaining IDs (override with --canary-skip).
Scanner Exit Codes
| Code | Meaning | Caller behavior |
|---|---|---|
| 0 | Clean — no violations | Proceed |
| 1 | Hard violation(s) found | Block |
| 2 | Warn-only — possible match, user confirmation recommended | Ask user (interactive) or auto-block (non-interactive) |
| 3 | Usage error (invalid arguments) | Block (configuration error) |
| 5 | STALE — body changed since manifest was generated (--apply only) | Abort redaction; re-run scan |
Migration note: exit code 2 previously meant "usage error." It was changed to 3
to make room for the warn-only exit code. Scripts that invoke scan-outbound.sh directly
and test for exit 2 as a usage-error indicator must be updated.
Related
For code-level security vulnerability scanning (injection, traversal, SQL, etc.), see /review-code-security.