coo-quack/sensitive-canary
coo-quack/sensitive-canary has 30 GitHub stars and 2 forks. GitHot measured 0 stars in 24 hours. Last checked 2026-11-15 05:55 UTC. Last push 2026-11-13.
Measured star growth
- in 24h
- 0
- in 7 days
- Collecting history
- in 15 days
- Collecting history
- in 1 month
- Collecting history
Net star changes over overlapping periods, not daily totals. Missing measurements are left blank. Stars measure attention, not software quality.
About
Claude Code hooks that guard secrets and PII before they reach the Anthropic API
SecurityREADME.md
OriginalSensitive Canary
A security plugin that prevents unintended data leaks from Claude Code. Automatically detects and blocks secrets and PII — in prompts, file reads, and command executions — before they are sent to the Anthropic API.
No proxy server. No background process. Native Claude Code hooks only.
📖 Documentation — Installation guide, detection rules reference, and allow tag details.
Why sensitive-canary?
Claude Code is a powerful development tool, but file reads and command executions can inadvertently send secrets and personal information to the Anthropic API. API keys in .env files, tokens embedded in config files, credentials pasted into the terminal — once sent to the API, they leave your machine.
sensitive-canary intercepts them before they are sent, preventing unintended data leaks.
| Without sensitive-canary | With sensitive-canary |
|---|---|
cat .env → full contents sent to Claude ❌ |
Blocked by name before Claude reads it ✅ |
| Paste a live AWS key in a prompt ❌ | Blocked before the API call is made ✅ |
Read customers.csv full of email addresses ❌ |
PII detected before Claude sees the file ✅ |
echo $API_KEY with live key ❌ |
Env var value scanned and blocked ✅ |
cat docker-compose.yml with POSTGRES_PASSWORD: ❌ |
Assignment detected in YAML and JSON too ✅ |
- Two hooks —
UserPromptSubmitandPreToolUsecover both directions of risk - 109 detection rules — sourced from gitleaks and TruffleHog detector definitions
- Checksum validation — credit cards (Luhn) and national ID numbers (JP My Number, FR NIR, IT Codice Fiscale, DE Steuer-IdNr., ES DNI/NIE, KR RRN/BRN, CN Resident ID)
- Context gating — the noisiest rules only fire when a label is nearby: non-US/JP phone numbers, ZIP, EU/KR and Chinese postal codes, public IPv4 and IPv6, and the Korean resident and business numbers. US and Japanese phone numbers and Japanese postal codes are matched without a label, since their shapes are specific enough. RFC 1918 private addresses are not matched at all — they are non-routable, they identify nothing outside the network they belong to, and they fill the inventories, manifests and ssh configs this tool is most often pointed at
- Not everything that looks like a secret is one — published test card numbers, RFC 2606 domains (
example.com), a value that is a variable reference (PASSWORD: ${VAR}), an ssh or scp target ([email protected],deploy@host,user@host:path), and.env.exampleand its siblings are left alone. Each was blocking ordinary work. A template is exempt only when its contents can be read whole. One holding a NUL byte, running past the per-file cut, reached after the call's budget or deadline, or that is not a regular file at all is blocked on its name, since the contents are what the exemption relies on. A template name that exists on no disk is not blocked — there is nothing to read and nothing to leak - Dates of birth, member numbers and postal addresses — dates of birth and member, customer or account numbers after a label, in 8 languages (English, Japanese, Chinese, Korean, French, Italian, German and Spanish); postal addresses by their shape, in 7 countries (JP, CN, KR, FR, IT, DE and ES), the French, Italian, German and Spanish ones with a postal code
- Entropy filtering — reduces false positives on low-entropy values
- Optional model check — a pii-sentinel server you run yourself can judge whether a file or prompt is sensitive, catching what has no fixed shape, such as a diagnosis next to a name
- Local only — all scanning runs on your machine, the optional model included; nothing is sent anywhere
Quick Start
Requirements
- Node.js 22.6.0 or later (required for
--experimental-strip-types) - Claude Code 1.0.33 or later
Plugin install (recommended)
Install in two commands from inside a Claude Code session:
1. Register the marketplace
/plugin marketplace add coo-quack/claude-code-marketplace
2. Install the plugin
/plugin install sensitive-canary@coo-quack
The hooks are enabled for every session started after this. A session that was already running does not pick them up — it reports the plugin as enabled and checks nothing — so start a new one.
Then check that it blocks. An installation that checks nothing looks exactly like one that works, and only exit 2 stops a tool call, so a hook that fails to start is silent. Write a private-key header to a file:
printf -- '-----BEGIN RSA PRIVATE KEY-----\n' > /tmp/canary-check.txt
Ask Claude to read /tmp/canary-check.txt. It should refuse and say why. If it
shows you what is in the file, the hooks are not running. AWS's documented
AKIAIOSFODNN7EXAMPLE will not do as a test: it appears in AWS's own setup guides
and in READMEs that copy them, so this tool reads it as documentation and allows it.
Keeping up to date: Third-party marketplaces have auto-update disabled by default. To receive automatic updates, run
/plugin→ Marketplaces tab → select the marketplace → Enable auto-update. You can also update manually from the same tab. See Discover and install plugins for details.
npm install
Install locally via npm and configure hooks manually:
npm install -g @coo-quack/sensitive-canary
Update to the latest version:
npm update -g @coo-quack/sensitive-canary
Then add to ~/.claude/settings.json:
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "node $(npm root -g)/@coo-quack/sensitive-canary/dist/user-prompt-submit-hook.js"
}
]
}
],
"PreToolUse": [
{
"matcher": "Read|NotebookRead|Bash|Grep|mcp__.*",
"hooks": [
{
"type": "command",
"command": "node $(npm root -g)/@coo-quack/sensitive-canary/dist/pre-tool-use-hook.js"
}
]
}
]
}
}
Note: These point at the compiled JavaScript the package ships. Node refuses to strip types from a
.tsfile insidenode_modules, and a hook that fails to start exits non-zero without blocking — so an installation wired tosrc/looks installed and checks nothing. The plugin install uses the.tssources, which sit outsidenode_modulesand work.
Manual setup (git clone)
Clone the repository and configure hooks manually:
git clone https://github.com/coo-quack/sensitive-canary.git ~/sensitive-canary
Update to the latest version:
cd ~/sensitive-canary && git pull
Then add to ~/.claude/settings.json:
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "node --experimental-strip-types ~/sensitive-canary/src/user-prompt-submit-hook.ts"
}
]
}
],
"PreToolUse": [
{
"matcher": "Read|NotebookRead|Bash|Grep|mcp__.*",
"hooks": [
{
"type": "command",
"command": "node --experimental-strip-types ~/sensitive-canary/src/pre-tool-use-hook.ts"
}
]
}
]
}
}
What Happens
Prompt blocked
Prompts containing secrets or PII are blocked before being sent.
> Here is my deploy key:
> -----BEGIN RSA PRIVATE KEY-----
> MIIEowIBAAKCAQEAwK3vJ9m5Q8xY2nB4dF6hL0pR7sT1uV3wX5yZ8aC2eG4iK6mO
> Can you review the config that uses it?
🐦 sensitive-canary: sensitive data detected — blocked
[Secret] PEM Private Key (private-key): ---****KEY
To allow, add a tag to your prompt:
[allow-secret] — allow secrets
[allow-all] — bypass all sensitive-canary checks
To allow it through, add the suggested tag:
> [allow-secret] Here is my deploy key: -----BEGIN RSA PRIVATE KEY-----
.env file blocked
.env and its siblings are blocked by filename, before anything is read. The name is compared without regard to case, so .ENV and .Env.local are blocked too. A symbolic link is judged by its own name and by the name of the file it points to, so a link named notes.txt that points to .env is blocked when it is read. Template names — .env.example, .env.sample, .env.template, .env.dist, .env.defaults, in any case — are the exception: they are meant to be committed, so they are read and judged on their contents like any other file. A template that turns out to hold a real credential is still blocked, and one whose contents cannot be read whole falls back to the name. This name-based block is a secret guard and only applies while the secret category is enabled (the default).
Commands that copy, move, link or archive a file (cp, mv, ln, install, rsync, tar -c (and bsdtar, gtar) and zip) read their sources, so cp .env backup.txt is blocked on the name of .env, and the source's contents are scanned as those of any file. The same holds for a directory named as a source: cp -r . backup and tar -czf b.tgz . are blocked when a .env sits directly in it, as a read of that directory is. tar -T FILE and rsync --files-from=FILE read the names in FILE as sources, and tar -C DIR makes the names after it relative to DIR. tar -x reads none of its operands.
> Read .env
🐤 sensitive-canary: blocked — /path/to/.env
🚫 Blocked: .env and .env.* files contain secrets and must not be read into the conversation.
To allow this, the user must add an allow tag to their next prompt:
[allow-secret] — allow secrets
[allow-pii] — allow PII
[allow-all] — bypass all sensitive-canary checks
Example: "[allow-secret] please read /path/to/.env"
File content blocked
Non-.env files are also blocked if their contents contain secrets or PII.
> Read config.yaml
🐤 sensitive-canary: blocked — /path/to/config.yaml
🚫 Blocked: file contains sensitive data
[Secret] AWS Access Key ID (aws-access-key): AKIA****PQRS
Allow tags
To intentionally bypass a block, include the appropriate tag in your current prompt.
| Tag | Effect |
|---|---|
[allow-secret] |
Skip all secret-category checks |
[allow-pii] |
Skip all PII-category checks |
[allow-all] |
Skip all sensitive-canary checks |
Note: Tags are read from the current user message only. Tags in previous messages are ignored — there is no risk of an accidental persistent bypass. Tags are case-insensitive.
[allow-secret]does not bypass PII blocks (and vice versa). The name-based block on.env/.env.*files is a secret guard, so[allow-secret]and[allow-all]lift it and[allow-pii]does not.
Configuration
SENSITIVE_CANARY_CATEGORIES
Limit which rule categories are active. Set it in the env block of your Claude Code settings.json:
{
"env": {
"SENSITIVE_CANARY_CATEGORIES": "secret"
}
}
| Value | Effect |
|---|---|
secret |
Scan for secrets only — PII rules are disabled |
pii |
Scan for PII only — secret rules and the name-based .env/.env.* block are disabled |
secret,pii / all |
Scan everything (default) |
Values are comma-separated and case-insensitive. Unset, empty, or containing no valid token means all categories are enabled.
This is a persistent filter, unlike allow tags which apply per prompt. The category filter is applied first, then allow tags. A typical use is setting secret when PII rules (credit card numbers, phone numbers, …) are too noisy against test fixtures.
pii-sentinel (optional)
The rules find personal data by its shape: an e-mail address, a card number, a phone number. They cannot tell that a
diagnosis next to a name is sensitive. pii-sentinel is a local model
that reads the text and judges a document none, low or high. sensitive-canary can ask it about every file it
reads and every prompt, after the rules have passed them.
sensitive-canary does not install, start or update pii-sentinel, and adds no dependency. Run the server yourself (it needs uv; the first start downloads the model, about 1.2 GB):
mkdir -p -m 700 ~/.pii-sentinel
uvx --from git+https://github.com/coo-quack/[email protected] \
pii-sentinel serve --model coo-quack/mmBERT-pii-sentinel --socket ~/.pii-sentinel/pii-sentinel.sock
Then point the config file at it. The config needs the absolute path, because ~ is not
expanded there (replace /Users/you with your home directory):
{
"piiSentinel": {
"socket": "/Users/you/.pii-sentinel/pii-sentinel.sock"
}
}
| Key | Default | Meaning |
|---|---|---|
socket |
The server's Unix socket, as an absolute path. | |
blockOn |
"high" |
Block at this level or above ("high" or "low"). "identifiable" (experimental) blocks at "high", and on date_of_birth and postal_address findings marked pii: true (pii-sentinel 0.5.0 or later). Findings-based by default; probability thresholds opt-in. Switching from "low" drops the level block at low. See pii-sentinel. |
identifiableThresholds |
{} |
Under "identifiable", the probability (0 to 1) at which each category you name blocks: email_or_phone, government_id, financial_account, sns_handle, ip_address_of_a_person, postal_address, date_of_birth, employment_info, precise_location. Other names are refused. Nothing is named by default, so probabilities block nothing until you name a category. A threshold of 1 in effect turns a category off, and 0 blocks whenever the server reports it. A category the server does not report never blocks. |
timeoutMs |
3000 |
How long one hook call waits for the server, in total. |
maxChars |
20000 |
How much of each file or prompt is sent; the model judges the beginning. |
onUnavailable |
"block" |
What to do when the server does not answer: "block", or "allow" to rely on the rules alone. A refused socket blocks either way. |
- It is asked only when the entry is present and the
piicategory is enabled. Without the entry nothing changes. [allow-pii]and[allow-all]lift its blocks, as they lift the PII rules.- A server that does not answer blocks by default: you asked for the check, and a check that silently did not run
would be a pass. Start the server before Claude Code, or set
"onUnavailable": "allow". - Before sending any text, the hook checks the socket. Every directory on its path must belong to you or root, and a
directory that other users can write to is refused unless it has the sticky bit, as
/tmpdoes. Group write counts as writable by others unless the group contains only you, so fix a refusal withchmod go-w <directory>. Group membership is read from/etc/groupand/etc/passwd, plus the static records ofnss-systemdunder/etc/userdband the other directories it reads, and is trusted only when/etc/nsswitch.confnames no source other thanfilesandsystemdforpasswd,groupandinitgroups. Any other source, such assss,ldap,nis,compatorwinbind, can add members none of those files show, so with one of them (Fedora's default listssss), and on macOS, which has nonsswitch.conf, a group-writable directory is always refused. Memberships that systemd services hand out over Varlink, such as systemd-homed's, are not read. On Windows these checks are skipped. The socket itself must be a socket file owned by you or root. A refused socket blocks even with"onUnavailable": "allow": the server behind it may be running, and falling back on the rules would skip it without a word. urlwas removed in v0.10.0. The hook cannot tell whether the program on a TCP port is your server, so a config withurlblocks with the reason. Replace it withsocket.- An entry that cannot be used (a typo) blocks too, with the reason.
- The text goes only to the local server, which never logs or stores it. Command lines and environment variables are not sent; the rules cover them.
Custom Rules
All detection rules are defined in src/lib/default-config.json as data, not code. You can add your own rules or override built-in ones by creating a config file.
Config file location
Create ~/.config/sensitive-canary/config.json, or point to a custom path with the SENSITIVE_CANARY_CONFIG environment variable. Set it in the env block of your Claude Code settings.json:
{
"env": {
"SENSITIVE_CANARY_CONFIG": "/path/to/my-rules.json"
}
}
or export it in your shell:
export SENSITIVE_CANARY_CONFIG=/path/to/my-rules.json
Adding a rule
Each rule is a JSON object with an id, description, regex (source string), and category ("secret" or "pii"):
{
"rules": [
{
"id": "custom-api-key",
"description": "My Service API Key",
"regex": "MYSVC-[A-Za-z0-9]{32}",
"category": "secret"
}
]
}
Overriding a built-in rule
A user rule with the same id as a built-in rule replaces it. For example, to tighten the email regex:
{
"rules": [
{
"id": "pii-email",
"description": "Internal Email",
"regex": "[A-Za-z0-9]+@internal\\.corp\\.(com|org)",
"category": "pii"
}
]
}
Context gating and validators
User rules support the same fields as built-in rules:
| Field | Type | Description |
|---|---|---|
requireContext |
boolean | Only fire when a nearby context word is found |
contextWords |
string[] | Words that satisfy the context requirement |
excludeContext |
string[] | Words that, found nearby, say the match is not what the rule is after — the mirror of contextWords |
contextWindow |
number | Override the global context window (default: 3 tokens) |
columnLabels |
string[] | Header names of a column: a CSV, TSV or Markdown table header, or the column list of a SQL INSERT. The cells under a matching header are read as values. Needs cellRegex |
cellRegex |
string | The value, matched against the whole trimmed cell with the rule's flags minus g and y, and the rule's validate too. Needs columnLabels |
entropyThreshold |
number | Skip matches below this Shannon entropy |
secretGroup |
number | Capture group holding the secret. Omit for the whole match — writing 0 is not the same as omitting it, see Detection Rules |
validate |
string | Name of a built-in checksum validator (see below) |
flags |
string | Regex flags. g is added if left out; y makes a rule match only at the very start of the text |
Available validators (referenced by name in the validate field):
luhn, aws-key, phone-jp, mynumber-jp, nir-fr, codice-fiscale-it, steuer-id-de, dni-nie-es, rrn-kr, brn-kr, resident-id-cn, public-ipv4, public-ipv6, password-like, basic-credentials, iban, phone-intl, nino-uk, phone-us, personal-mailbox, aws-secret-shape, birth-date
Overriding the context window globally
Set contextWindow at the top level to change how many tokens of surrounding text are scanned for context words (default: 3):
{
"contextWindow": 5,
"rules": []
}
Invalid rules (bad regex, wrong types, missing required fields) are skipped with a warning on stderr. The rest of the config still loads. Each rule is validated against a strict schema before compilation — requireContext: true without contextWords is also rejected, since empty contextWords would silently disable context gating and make the rule fire on every match.
Detection rules
Secrets (61 rules)
| Rule ID | Description |
|---|---|
openai-service-key |
OpenAI Service Account / Admin Key (sk-svcacct-, sk-admin-, sk-proj- prefix) |
azure-storage-key |
Azure Storage Account Key (AccountKey= + 88-char base64) |
azure-sas-key |
Azure Shared Access Key for Service Bus, Event Hubs and IoT Hub (SharedAccessKey= + 44-char base64). Separate from the storage account key, which is 88 characters |
google-oauth-secret |
Google OAuth Client Secret (GOCSPX- prefix) |
flyio-token |
Fly.io API Token (FlyV1 fm2_ prefix) |
databricks-token |
Databricks Personal Access Token (dapi + 32 hex) |
vault-token |
HashiCorp Vault Token (hvs. / hvb. prefix) |
shopify-token |
Shopify Access Token (shpat_, shpss_, shpca_, shppa_ prefix) |
doppler-token |
Doppler Token (dp.pt., dp.st., … prefix) |
grafana-token |
Grafana Cloud / Service Account Token (glc_, glsa_ prefix) |
notion-token |
Notion Integration Token (ntn_ prefix) |
aws-access-key |
AWS Access Key ID |
gcp-api-key |
Google Cloud API Key |
private-key |
PEM Private Key (RSA / EC / DSA / PGP / OpenSSH) |
private-key-base64 |
PEM private key that has been base64-encoded — how one appears in a kubeconfig, a Kubernetes Secret or a Terraform state, where the -----BEGIN header never shows in the text |
url-basic-auth |
Credentials in the userinfo field of an http(s) URL — a git remote, a .netrc, a private registry, a curl invocation. RFC 3986 deprecates the form for this reason |
github-pat |
GitHub Personal Access Token |
github-fine-grained |
GitHub Fine-Grained Token |
gitlab-pat |
GitLab Personal Access Token |
npm-token |
npm Access Token |
slack-token |
Slack Token |
slack-webhook |
Slack Webhook URL |
discord-webhook |
Discord Webhook URL |
telegram-bot-token |
Telegram Bot Token |
twilio-sid |
Twilio Account SID |
sendgrid-key |
SendGrid API Key |
mailgun-key |
Mailgun API Key |
mailchimp-key |
Mailchimp API Key |
stripe-secret-key |
Stripe Secret Key |
stripe-restricted-key |
Stripe Restricted Key |
openai-key |
OpenAI API Key (legacy format) |
openai-project-key |
OpenAI Project API Key (sk-proj- prefix) (entropy ≥ 3.5) |
anthropic-key |
Anthropic API Key |
replicate-token |
Replicate API Token |
huggingface-token |
Hugging Face Access Token |
groq-key |
Groq API Key |
openrouter-key |
OpenRouter API Key |
xai-key |
xAI (Grok) API Key |
perplexity-key |
Perplexity API Key |
digitalocean-pat |
DigitalOcean Personal Access Token |
square-access-token |
Square Access Token |
mapbox-token |
Mapbox Token |
sentry-user-token |
Sentry User Auth Token |
sentry-org-token |
Sentry Organization Auth Token |
atlassian-token |
Atlassian API Token |
linear-key |
Linear API Key |
postman-key |
Postman API Key |
supabase-key |
Supabase Personal Access Token |
jwt |
JSON Web Token (JWT) |
generic-secret |
Generic API key / secret assignment (entropy ≥ 3.5) |
env-assignment |
.env-style secret assignment (entropy ≥ 3.0) |
connection-string |
Database connection string with embedded credentials |
aws-secret-near-key-id |
AWS Secret Access Key written beside its Access Key ID, on the same line or the next |
docker-hub-pat |
Docker Hub Personal Access Token (dckr_pat_ prefix) |
http-basic-auth |
HTTP Basic Authorization header whose value decodes to user:password |
http-bearer-token |
HTTP Bearer token in an Authorization header (entropy ≥ 3.5) |
password-cli-flag |
Password on a command line: --password, --passwd, --pass, or mysql -p'…' |
password-netrc |
password line in a .netrc file or a vault key listing |
password-prose |
Password written in a sentence: password is …, pw: …, パスワードは … |
password-json-key |
JSON value under a key that ends in pass, passwd, password or pwd |
redis-auth |
Redis AUTH command, or redis-cli -a |
PII (48 rules)
| Rule ID | Description | Validation |
|---|---|---|
pii-email |
Email address | Role addresses such as support@ and noreply@ are not matched |
pii-credit-card |
Credit card number | Luhn check |
pii-ssn |
US Social Security Number | Invalid prefix exclusion |
pii-mynumber-jp |
Japanese Individual Number (My Number) | Checksum (weighted mod 11); a twelve-digit AWS account ID in an ARN is not matched |
pii-nir-fr |
French NIR / Social Security Number | Check key (mod 97) |
pii-codice-fiscale-it |
Italian Codice Fiscale | Control character (mod 26) |
pii-steuer-id-de |
German Steuer-Identifikationsnummer | MOD 11,10 |
pii-dni-nie-es |
Spanish DNI / NIE | Control letter (mod 23) |
pii-iban |
IBAN | Length for the common countries, and mod-97 check digits |
pii-nino-uk |
UK National Insurance number (spaced form, AB 12 34 56 C) |
Excluded letters and prefixes |
pii-phone-us |
US phone number | Area code and exchange: 555 and toll-free numbers are not matched |
pii-phone-jp |
Japanese phone number | — |
pii-phone-intl |
International phone number with a country code (+49 151 85280948) |
Nine to fifteen digits |
pii-phone-fr |
French phone number | Context-gated |
pii-phone-it |
Italian phone number | Context-gated |
pii-phone-de |
German phone number | Context-gated |
pii-phone-es |
Spanish phone number | Context-gated |
pii-postal-jp |
Japanese postal code (〒 prefix required) |
— |
pii-postal-code |
Postal code (US ZIP / EU / KR) | Context-gated |
pii-rrn-kr |
Korean Resident Registration Number | Checksum (weighted mod 11) |
pii-brn-kr |
Korean Business Registration Number | Checksum (NTS standard algorithm) |
pii-resident-id-cn |
Chinese Resident Identity Card | Check digit (GB 11643 MOD 11-2) |
pii-phone-kr |
Korean phone number | Context-gated |
pii-phone-cn |
Chinese phone number | Context-gated |
pii-postal-cn |
Chinese postal code (6-digit) | Context-gated |
pii-ipv4-public |
Public IPv4 address | Context-gated, reserved ranges excluded |
pii-ipv6 |
IPv6 address | Context-gated, reserved ranges excluded |
pii-date-of-birth |
Date of birth after a label (DOB, date of birth, 生年月日, 誕生日) |
Calendar date between 1900 and this year; the defaults 1970-01-01 and 1900-01-01 are not matched |
pii-customer-number |
Member, customer or account number after a label (customer ID, membership number, 会員番号, 顧客番号) |
Five to twenty-four characters with at least two digits, after a label |
pii-address-jp |
Japanese postal address (prefecture, municipality and block number) | A prefecture, a municipality and a block number in sequence; a prefecture or a municipality alone is not matched |
pii-date-of-birth-zh |
Date of birth after a Chinese label (出生日期, 出生年月日, 出生于) |
Calendar date between 1900 and this year; the defaults 1970-01-01 and 1900-01-01 are not matched |
pii-date-of-birth-ko |
Date of birth after a Korean label (생년월일, 출생일, 출생연월일) |
Calendar date between 1900 and this year; the defaults 1970-01-01 and 1900-01-01 are not matched |
pii-date-of-birth-fr |
Date of birth after a French label (date de naissance, né(e) le, DDN) |
Calendar date between 1900 and this year; the defaults 1970-01-01 and 1900-01-01 are not matched |
pii-date-of-birth-it |
Date of birth after an Italian label (data di nascita, nato/a il) |
Calendar date between 1900 and this year; the defaults 1970-01-01 and 1900-01-01 are not matched |
pii-date-of-birth-de |
Date of birth after a German label (Geburtsdatum, Geb.-Datum, geboren am) |
Calendar date between 1900 and this year; the defaults 1970-01-01 and 1900-01-01 are not matched |
pii-date-of-birth-es |
Date of birth after a Spanish label (fecha de nacimiento, F. Nac., nacido/a el) |
Calendar date between 1900 and this year; the defaults 1970-01-01 and 1900-01-01 are not matched |
pii-customer-number-zh |
Member, customer or account number after a Chinese label (客户编号, 会员号, 会员卡号, 银行账号) |
Five to twenty-four characters with at least two digits, not all zeros, after a label |
pii-customer-number-ko |
Member, customer or account number after a Korean label (고객번호, 회원번호, 계좌번호) |
Five to twenty-four characters with at least two digits, not all zeros, after a label |
pii-customer-number-fr |
Member, customer or account number after a French label (numéro de client, N° d'adhérent, numéro de compte) |
Five to twenty-four characters with at least two digits, not all zeros, after a label |
pii-customer-number-it |
Member, customer or account number after an Italian label (numero cliente, codice cliente, numero socio) |
Five to twenty-four characters with at least two digits, not all zeros, after a label |
pii-customer-number-de |
Member, customer or account number after a German label (Kundennummer, Kd.-Nr., Mitgliedsnummer, Kontonummer) |
Five to twenty-four characters with at least two digits, not all zeros, after a label |
pii-customer-number-es |
Member, customer or account number after a Spanish label (número de cliente, n.º de socio, número de cuenta) |
Five to twenty-four characters with at least two digits, not all zeros, after a label |
pii-address-cn |
Chinese postal address (province, 市/区/县/旗/盟/镇/乡/街道 unit, road and 号 number) | A province, or a unit that is not inside Han text (after a non-Han character or 在, 到, 于, 为, 至, 往 or 从); up to three units after a province; a road (路, 街, 道, 胡同, 巷 or 弄) and 号; a road alone, a metro line, a date or a count is not matched |
pii-address-kr |
Korean postal address (시/도 or 구/군, then a road name and building number, or 동/리 and lot number) | A 시/도 or 구/군 unit, each followed by a space; then 로, 대로 or 길 with a building number, or 동/리 with a lot number; a counter after the number (명, 번 출구) is not a building number, and a particle after it is |
pii-address-fr |
French postal address (number, street, postal code and town) | A street type and name of capitalised words; then at most ten separators or tokens (Bât., Apt, Lot, BP, CEDEX, CP :) before a five-digit postal code and a capitalised town; a lower-case word between the number and the code is not read |
pii-address-it |
Italian postal address (street, civic number, CAP and town) | A street type and a name of up to five words; then at most twelve separators or tokens (int., scala, piano, a letter after a slash, CAP:) before a CAP and a capitalised town; a free word between the number and the CAP is not read |
pii-address-de |
German postal address (street, house number, postal code and town) | A street (a compound, a type word, a genitive or a preposition); a comma, semicolon, pipe, dash, newline or two spaces after the number; then at most twelve separators or floor tokens (3. OG, Hinterhaus, PLZ:) before a postal code and a capitalised town; in is not a delimiter |
pii-address-es |
Spanish postal address (street, number, postal code and town) | A street type and a name of up to five words; then at most twelve separators or floor and door tokens (3.º izda., 2º B, piso 2, puerta B, CP:) before a postal code and a capitalised town; a free word between the number and the code is not read |
Detection patterns are based on rule definitions from gitleaks and TruffleHog.
National ID checksum algorithms follow the official specs from each issuing authority: 地方公共団体情報システム機構 (J-LIS) for My Number, INSEE for NIR, Agenzia delle Entrate for Codice Fiscale, Bundeszentralamt für Steuern for Steuer-IdNr., the Ministerio del Interior for DNI/NIE, the Ministry of the Interior and Safety for the Korean RRN, GB 11643-1999 for the Chinese Resident Identity Card, and the NTS (Hometax) standard algorithm for the Korean BRN.
Context gating
Phone numbers (IT, DE, FR, ES, KR, CN), bare 5/9-digit and Chinese 6-digit postal codes, and public IP addresses produce too many false positives on digit-only patterns. These rules carry a list of context words (phone, ZIP, PLZ, CAP, IP, etc. in the relevant languages) and only fire when one of those words appears near the match. National ID numbers rely on their checksums instead and do not need context. Japanese postal codes keep their 〒 prefix requirement, which is a stricter form of the same idea.
How It Works
① UserPromptSubmit hook
Runs just before a prompt is sent to the API.
User presses Enter
↓
UserPromptSubmit hook
↓ scans prompt
├─ secret / PII detected AND no matching [allow-xxx] tag → block (exit 2)
├─ pii-sentinel configured: it judges the prompt high → block (exit 2)
└─ nothing detected OR tag present → pass (exit 0)
When blocked, the terminal shows what was detected and how to bypass it.
② PreToolUse hook
Runs just before Claude calls the Read, Bash or Grep tool, or any MCP tool.
Claude calls Read / Bash / Grep / MCP tool
↓
PreToolUse hook
↓
── Read tool ─────────────────────────────────────────────────────
│ 1. filename is .env / .env.* → blocked (secret category only)
│ 2. file contents contain secret / PII → blocked
│
├─ Bash tool ──────────────────────────────────────────────────────
│ 1. env var values referenced in the command contain secret / PII → blocked
│ 2. a bare env / printenv, a variable listing such as export -p, set
│ or declare -p, or inline code such as print(os.environ) or
│ console.log(process.env), would print the whole environment →
│ every variable is scanned; os.environ["NAME"] scans NAME
│ 3. command string itself contains secret / PII (e.g. echo AKIA...) → blocked
│ 4. the command is located past any wrapper (sudo, env VAR=1, timeout,
│ nice, xargs) and any leading VAR=value assignment
│ 5. inline scripts (-c, -e, -pe) are parsed and scanned
│ 6. file paths from input redirections, command substitutions and chained
│ commands are extracted and scanned
│ 7. printing commands (cat, head, tail, sed, awk, grep, rg, cut, sort,
│ base64, xxd, strings, diff, comm, dd, and git subcommands) targeting
│ a named file → file contents scanned
│ 8. copying, moving, linking and archiving commands (cp, mv, ln,
│ install, rsync, tar -c, zip) read their sources, not their
│ destination → each source scanned, and an .env source blocked
│
└─ every other tool, Grep and mcp__* included ─────────────────────
1. input fields naming an existing file are scanned for
secret / PII → blocked
then, with pii-sentinel configured, the contents of every file read
above are sent to it, and one it judges high → blocked
A value is scanned when either its field name says path or the value itself is shaped like one.
The field names are path, paths, file, files, filepath, filename, filenames, absolutepath, notebookpath and sourcepath, compared with separators and case removed — so file_path, filePath and filepath are one name. Beyond those, any value containing a / is treated as a path whatever its field is called, which is what covers a tool carrying its path under target, document or uri.
The / is what separates a path from a word, and it is there so that a search pattern is not read as a path: { "pattern": ".env" } is a search for the text .env, not a read of the file, and .env exists in most checkouts. The cost is that a bare filename under an unlisted field name is still missed.
Values are found up to four levels down and inside arrays, both of strings and of objects, so { "path": "…" }, { "paths": ["…"] }, { "args": ["/abs/…"] } and { "args": { "paths": [{ "path": "…" }] } } are all covered. A field naming a directory is left alone.
Which tools reach the hook at all is the matcher's business, and the default (Read|NotebookRead|Bash|Grep|mcp__.*) sends it Read, NotebookRead, Bash, Grep and every MCP tool. Widen the matcher and the same field search applies to whatever else arrives.
What the matcher leaves out is not scanned. WebFetch and WebSearch are not in the default matcher, so the hook never runs for them and a URL carrying a key (https://example.com/api?key=…) goes out unchecked. The default matcher does include mcp__.*, but for an MCP tool the hook reads only the fields that name an existing file (a value containing / counts as a path) and the text of its command fields. A body, a message or a query argument is not scanned, so mcp__x__post_message with a key in body is allowed.
Widening the matcher to WebFetch|WebSearch would start the hook for those calls and still scan nothing in them: both are among the tools without file output, and the hook reads no input from those calls. Scanning URLs or free-text arguments takes a change to what the hook reads, in src/lib/tool-inputs.ts, as well as to the matcher. Widening it to every tool (.*) starts the hook for all of them, under the same reading.
Commands that only measure a file (wc, cksum, sha256sum) are not treated as reads, whether the file is named or fed in over <: they print counts and digests, never the bytes. Neither are the tools that surface no file contents — Write, Edit, MultiEdit, NotebookEdit, TodoWrite, Glob, WebFetch, WebSearch, ExitPlanMode, AskUserQuestion — nor any tool whose name leads with a write verb, such as mcp__fs__write_file or createPage.
Neither is a command that sends its result back to the file it was handed. sed -i, perl -i and ruby -i (bundled forms such as perl -pi -e and perl -lpi included) edit in place and write nothing to stdout. A bundle is read one letter at a time, continuing only past switches that command is known to accept without a value — so sed -Ei and perl -lpi are in-place edits, while perl -Ilib -pe and perl -MList::Util -pe are reads. A letter the list does not know stops the reading and the file is scanned, which is the safe way to be wrong. git log <file> is not a read either — it prints who changed the file and when — unless a patch is asked for with -p, -u, --patch, -U<n>, --unified=<n>, one of the merge-diff forms (-c, -m, --cc, --diff-merges), or -L, which prints the lines of one named file.
When blocked, the hook exits 2, which stops the tool call, and writes the reason to stderr, which is where Claude reads it from. The reason names what was detected and which allow tag lifts the block, and asks Claude to pass that on to the user.
The terminal also receives a direct message (via /dev/tty).
Known Limitations
- Heredoc bodies — a heredoc body is treated as text, not as commands, so
cat > deploy.sh <<'EOF'writing a script that mentions.envis not itself a read. The trade-off is that a heredoc which feeds commands to a remote shell (ssh host <<'EOF'with acat /etc/secretsin the body) is not inspected either. One fed to a local shell or interpreter (bash <<'EOF',python3 - <<'EOF') is read as the program it is. - An environment reached by a computed route in inline code — Python, JavaScript, Ruby, Perl and PHP code is read for the environment by how each language spells it (
os.environ,process.env,ENV,%ENV,getenv(),$_SERVER), and a lookup whose name is not a literal counts as the whole environment. A route only the running program builds (getattr(os, "environ"),globalThis["process"]) is not followed. In a tool'scodefield, whose language is not given, a bare RubyENVand a JavaScriptprocesson its own (console.log(process)) are not read, since both words are common elsewhere;ENV[…],ENV.…andprocess.envare. - A program a shell gets from a file, another command or a variable — program text written on the command line is read wherever it reaches a shell or interpreter:
bash -c, a here-string, a heredoc, and whatecho,printfor a heredoc piped throughcathands one on stdin. A program it reads from a file or from another command's output (bash setup.sh,curl … | sh,bash < <(echo env)) is not, nor one that only exists once the command runs: a variable (echo $v | bash), a substitution (bash <<< $(…)), or the output of several commands joined into one line ((echo -n en; echo v) | bash). - A tool that runs a command is read for the command, by field name —
command,commands,cmd,script,code,commandlineandshellcommand, each read with punctuation and case ignored, socommand_line,command-line,commandLineandcommand.lineare the same name. A shell-running MCP server that names the field something else hands its command past unread. - Only the first and last 1 MiB of a file are scanned — a file larger than the cut is read at both ends rather than to its end, because a hook that does not return is killed by the PreToolUse timeout, and a killed hook does not block the call. The cut is in bytes, so a file of multi-byte characters gives up sooner in characters. What is missed is the middle of a file larger than both windows, and a secret straddling either edge, since the cut lands mid-match; the search of the transcript for allow tags, which stops 8 MiB back, makes the same trade. What the cut does not bound is the work done on what it read: that is a property of each rule's pattern, and
docs/rules.mdcovers why three of them carry length bounds. - A write-named tool that also returns contents — the exemption reads a tool's name, and assumes a name led by a write verb means the tool surfaces no file contents.
updateandcopyare where those two things come apart:mcp__*__update_fileandmcp__*__copy_fileopen a file to do their work, and one that returned the result would not be scanned. Scanning them instead would block writing to a file that already holds a secret, which is not a leak, so the exemption stays as it is. - A bare filename under an unlisted field name — a value is treated as a path when its field name says so or when it contains a
/. A tool passing{ "target": "secrets.txt" }satisfies neither, so it is not scanned. Requiring the/is deliberate: without it, a search for the text.envwould be blocked as though the file had been read. - git history references —
git show HEAD:.envand similar references to objects in git history (not on disk) are not scanned, since the object does not exist as a file path. - Unlisted commands — the set of commands known to print file contents is a list, not an analysis of the command. A printing command that is not on the list is not caught.
- A template holding a real credential is blocked —
.env.exampleand its siblings are exempt from the name guard, not from the scan. Placeholders (your-token-here,REPLACE_ME,changeme,<token>,postgres://user:password@localhost/db) are recognised and left alone, but a template committed with a live key is blocked like any other file, through printing commands (grep KEY .env.example) as much as throughRead. Use[allow-secret]if that is deliberate. - A binary swept up by a directory being named — the files under a directory are scanned because the directory was named, and one whose first four kilobytes read as neither text nor UTF-16 is skipped rather than ground through every rule. A file named outright is scanned whatever its bytes look like, and so is an
.envin a swept directory: the name decides that one when the contents cannot. - Anything that is not a regular file — a FIFO, a process substitution (
/dev/fd/63) and/dev/stdinare not read, socatof one is not scanned. Reading them can never reach the end of the file:cat /dev/zeroheld the hook open until Claude Code's PreToolUse timeout killed it, and a killed hook does not block the call. Not scanning them is the lesser of the two, since a hang lets the call through as well. A directory is not read either, but it is not left alone: the files directly under it are scanned, one level and no further. - A search that names no path is judged on names alone —
rg pattern,grep -r patternandGrep {pattern}with nopathall print from the working directory, so that directory is checked for an.envand its siblings. Its other files are not read. A directory the user named is one they asked about and its contents are scanned; a directory only implied by a search is every repository anyone works in, and reading those stopped a plainrg TODOin a third of the checkouts it was measured against. ~user/…is not expanded —~and~/…are resolved to the home directory, but the form naming another user needs the password database, and guessing would name the wrong file.- A file in an encoding neither reading recovers — every run of text between NUL bytes is scanned, and UTF-16 is decoded first: by its byte-order mark, or by NULs falling on one side of each pair through the first sixteen kilobytes. When those hold fewer than eight NULs, the file is undecided: if a NUL appears anywhere in the bytes read, both byte orders are scanned as well as the UTF-8 reading, each where it reads as text. Without a mark that verdict is a guess, so both readings are scanned and a wrong guess hides nothing. What is left out is an encoding that is neither: a file in Shift_JIS or GBK is read as the bytes it is, and a credential written in one of those character sets is not found. An ASCII credential inside such a file still is.
- A shell construct that names the file only at run time —
for f in secrets; do cat "$f"; doneandfind . -name secrets -exec cat {} +both name the file in the command line, but the hook classifies the command it can see, and in these the reading command iscatreached through a loop or throughfind's own argument list. - At most 64 MiB is read across one tool call — the per-file cut bounds one file; this bounds the call. A glob naming three hundred large files took half a minute, which is long enough for the PreToolUse timeout to kill the hook, and a killed hook does not block. Files past the budget are not scanned, so naming enough large files before the one that matters is a way past the scan.
- A relative path is resolved against the directory Claude Code reports — and against a literal
cdat the start of the same command. Acdlater in the line, one inside a subshell, and one whose argument is a variable, a glob or-are all left alone, because where they land cannot be worked out here. A directory changed some other way is the same case. **reaches one level, not every level — a pattern crossing directories is expanded as a single*, because expanding it properly walked a whole tree until the hook was killed.cat **/secretssees*/secrets.- One tool call stops reading after five seconds — whatever it has read by then is what was scanned. A byte budget bounds the reading; this bounds the walking as well, and both are ways past the scan for anyone willing to name enough files first.
- A glob is expanded by the hook, not by the shell —
cat *.envis expanded here to decide what to scan, a moment before the shell expands it and against the hook's own working directory. A file created in between is missed, and at most 256 matches of one pattern are scanned. - Paths held in shell variables — a path is only scanned when it appears literally in the command.
f=.env; cat "$f"resolves at run time, after the hook has already decided. - Paths arriving over a pipe —
find . -name '.env' | xargs catnames no file the hook can see. - Programs that read files themselves —
python script.pyis not scanned, because running a script does not print its source; whatever the script opens at run time is beyond the hook's reach. - A flag's separate value is collected as a path — on a printing command, only a few flags are known to take a value, so every other flag's value becomes a path candidate: the
5inhead -n 5 fand incut -c 5 f. A pattern-first command is different —grep -A 5 fspends the5as the pattern instead, and onlyfis collected. Harmless in practice, since only paths that exist as regular files are read — a file named5in the working directory would be scanned, and nothing else is. - A command a wrapper hands off to may be mistaken for its argument, or the reverse — the search past
sudo,timeoutand the others takes the first name it can classify, because a wrapper flag's value (sudo -u root cat f) cannot be told apart from a command name. So an unclassified command's arguments are searched too:sudo mycmd cat fresolves tocatand scansf.echo,printf,true,falseand:are known to print their arguments rather than open them, and stop the search; any other unclassified name does not. - Inline program text is followed four levels deep — each
-c/-escript inside another costs one level, so a read buried five interpreters down is not reached. Nested command substitutions are not bounded this way. - Best effort only — detection is not exhaustive. Arbitrary shell metacharacters, eval chains, and complex expansions may not be fully tracked.
Allow Tags (detailed)
Allow tags filter the scan results — the scan still runs, including for a .env file whose name guard a tag has lifted. Lifting the name guard is not the same as skipping the check: [allow-secret] on a .env holding an email address still blocks on the address.
Mask tags
[mask-secret], [mask-pii], and [mask-all] are recognised but not supported. Claude Code hooks cannot rewrite prompt content, so masking before sending is not possible.
If you include a mask tag, sensitive-canary will explain this and list what was detected:
> [mask-secret] My deploy key is -----BEGIN RSA PRIVATE KEY----- , can you review this?
🐦 sensitive-canary: prompt masking is not supported
[mask-secret] cannot mask prompt content.
The following sensitive data was detected:
[Secret] PEM Private Key (private-key): ---****KEY
Please choose one of the following:
1. Manually redact the values above and resubmit
2. To send as-is, add an allow tag to your prompt:
[allow-secret] — allow secrets
[allow-all] — bypass all sensitive-canary checks
Allow + Mask tag priority
When more than one tag appears, the last one wins. It replaces the earlier ones entirely rather than combining with them, so changing your mind mid-message works the way it reads.
| Example | secret | pii |
|---|---|---|
[allow-all] … [allow-secret] |
allow | blocked |
[allow-secret] … [allow-all] |
allow | allow |
[allow-secret] … [mask-secret] |
mask (unsupported) | blocked |
[mask-secret] … [allow-secret] |
allow | blocked |
[allow-secret] … [allow-pii] |
blocked | allow |
The last line is the one to watch: two tags do not add up. Narrowing from [allow-all] to [allow-secret] really does put PII back under guard, which is the point — but so does writing [allow-secret] [allow-pii] and expecting both. [allow-all] is how you ask for both.
A tag counts wherever it appears in the message, mid-sentence included. What does not count is a tag inside a fenced code block, inside one of the elements Claude Code writes around command output, or in a line the runtime wrote rather than you — a compaction summary, a skill body, or a background task reporting back. Those are quoting, not asking.
File structure
.claude-plugin/
plugin.json plugin manifest
hooks/
hooks.json Claude Code hook configuration
src/
user-prompt-submit-hook.ts UserPromptSubmit hook
pre-tool-use-hook.ts PreToolUse hook
lib/
inspector.ts allow tag parsing, message scanning
rules.ts secret and PII detection rule definitions
default-config.json the rules themselves, as data
shell.ts shell syntax: tokens, quoting, heredocs, substitutions
bash-commands.ts what each command does with the files it is given
program-environment.ts what inline code does with the environment
tool-inputs.ts which input fields of a tool name a file
transcript.ts allow tags from the session transcript
pii-sentinel.ts the optional pii-sentinel client
Development
pnpm install # install dependencies
pnpm test # run tests
pnpm run test:watch # run tests in watch mode
pnpm run typecheck # type check (tsc)
pnpm run lint # lint with Biome (no changes)
pnpm run fix # lint + auto-fix with Biome
pnpm run ci # typecheck + lint + tests (for CI)
The lockfile is pnpm's, and every CI job installs with pnpm, so npm install
here ignores it, writes a second lockfile, and resolves a different tree from the
one that is tested. CONTRIBUTING.md has the rest of the workflow.