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:

CheckpointWhenMechanism
Git commit (files)Every git commitPre-commit hook (claude-global/hooks/pre-commit)
Git commit (message)Every git commitCommit-msg hook (claude-global/hooks/commit-msg)
Claude Code editEvery Edit/Write tool callPreToolUse hook (claude-global/hooks/scan-outbound.js)
Claude Code commitEvery git commit via Bash toolPreToolUse hook (claude-global/hooks/scan-outbound.js)
Claude Code forge writeEvery gh issue/pr create/edit/close/comment/review via Bash toolPreToolUse 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.shclarify-commit-scope.shgh 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 Codes for the code table.
  • <label> argument: pass the actual logical file path so per-file .private-info-allowlist entries (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, never exits, 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 lose GH_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

TypePatternExamples
RFC 1918 IPv410.x.x.x, 172.16-31.x.x, 192.168.x.x192.168.1.1, 10.0.0.1
Email addressesuser@domain.tlduser@example.com
MAC addressesXX:XX:XX:XX:XX:XX / XX-XX-XX-XX-XX-XXaa:bb:cc:dd:ee:ff
Absolute local paths/Users/<name>, /home/<name>, C:\Users\<name>/Users/john/docs
Hard secretsAWS/Anthropic/OpenAI/GitHub/Slack/Google/HuggingFace/Groq/Replicate/Cohere API keys, PEM private keysAKIA..., 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–2069CVE-2021-42574
Blocklist patternsUser-defined in .private-info-blocklist (repo root); prefix warn: for soft-blockHostnames, 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 repoowner/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

  • gh CLI 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:

ContextBehavior on warn hit
Claude Code Edit/Write/BashPreToolUse 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

FilePurpose
bin/scan-outbound.shScanner script (detection patterns)
bin/lib/gh-outbound-guard.shSourceable fail-closed guard wrapper scripts call before handing free text to gh
claude-global/hooks/pre-commitGit pre-commit hook (staged files)
claude-global/hooks/commit-msgGit commit-msg hook (commit message)
claude-global/hooks/scan-outbound.jsClaude Code PreToolUse hook
claude-global/hooks/lib/is-private-repo.jsShared module: dynamic private repo detection via gh api
.private-info-allowlistException patterns
.private-info-blocklistAdditional detection patterns (gitignored, symlinked from private repo)
bin/scan-offensiveOffensive content detector: keyword tier + optional LLM tier (forward filter) / --skill-mode JSONL manifest (skill path)
skills/scan-offensive/scripts/scan-repo.shDrives 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).

CheckpointWhenMode
Claude Code forge writeEvery gh issue/pr write via scan-outbound.jsForward filter (always-on, public + private repos)
Manual retroactive scanOn-demand via /scan-offensive skillRetroactive (any repo)
  • .offensive-content-blocklist is gitignored and must be created manually from .offensive-content-blocklist.example. When absent, bin/scan-offensive runs keyword-only with no patterns (clean by default). block-dotenv.js write-protects it — Claude Code cannot edit it.
  • Forward filter LLM tier: ANTHROPIC_API_KEY enables the LLM classifier in scan-outbound.js. When unset, the forward filter runs keyword-only with a stderr warning. The /scan-offensive skill 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:

FlagEffect
--since YYYY-MM-DDIssues updated on or after this date (server-side)
--until YYYY-MM-DDIssues updated on or before this date (client-side)
--from-issue NIssue numbers ≥ N
--to-issue NIssue numbers ≤ N
--limit NStop 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

CodeMeaningCaller behavior
0Clean — no violationsProceed
1Hard violation(s) foundBlock
2Warn-only — possible match, user confirmation recommendedAsk user (interactive) or auto-block (non-interactive)
3Usage error (invalid arguments)Block (configuration error)
5STALE — 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.

For code-level security vulnerability scanning (injection, traversal, SQL, etc.), see /review-code-security.