@coo-quack/sensitive-canary 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +798 -0
  3. package/README.md +142 -45
  4. package/dist/lib/bash-commands.js +405 -0
  5. package/dist/lib/command-tables.js +462 -0
  6. package/dist/lib/default-config.json +570 -0
  7. package/dist/lib/encoding.js +123 -0
  8. package/dist/lib/fail-closed.js +31 -0
  9. package/dist/lib/inspector.js +0 -0
  10. package/dist/lib/rules.js +399 -0
  11. package/dist/lib/shapes.js +161 -0
  12. package/dist/lib/shell.js +436 -0
  13. package/dist/lib/tool-inputs.js +217 -0
  14. package/dist/lib/transcript.js +115 -0
  15. package/dist/lib/validators.js +435 -0
  16. package/dist/pre-tool-use-hook.js +773 -0
  17. package/dist/user-prompt-submit-hook.js +105 -0
  18. package/hooks/hooks.json +1 -1
  19. package/package.json +25 -11
  20. package/src/lib/bash-commands.ts +455 -0
  21. package/src/lib/command-tables.ts +518 -0
  22. package/src/lib/default-config.json +155 -46
  23. package/src/lib/encoding.ts +135 -0
  24. package/src/lib/fail-closed.ts +36 -0
  25. package/src/lib/inspector.ts +0 -0
  26. package/src/lib/rules.ts +202 -365
  27. package/src/lib/shapes.ts +175 -0
  28. package/src/lib/shell.ts +512 -0
  29. package/src/lib/tool-inputs.ts +235 -0
  30. package/src/lib/transcript.ts +142 -0
  31. package/src/lib/validators.ts +435 -0
  32. package/src/pre-tool-use-hook.ts +774 -198
  33. package/src/user-prompt-submit-hook.ts +60 -18
  34. package/src/__tests__/pre-tool-use-hook.test.ts +0 -779
  35. package/src/__tests__/user-prompt-submit-hook.test.ts +0 -297
  36. package/src/lib/__tests__/inspector.test.ts +0 -289
  37. package/src/lib/__tests__/rules.test.ts +0 -1370
package/README.md CHANGED
@@ -20,14 +20,16 @@ Claude Code is a powerful development tool, but file reads and command execution
20
20
  | Without sensitive-canary | With sensitive-canary |
21
21
  |--------------------------|----------------------|
22
22
  | `cat .env` → full contents sent to Claude ❌ | Blocked by name before Claude reads it ✅ |
23
- | Paste `AKIAIOSFODNN7EXAMPLE` in prompt ❌ | Blocked before the API call is made ✅ |
24
- | Tool result contains user@email.com ❌ | PII detected and blocked ✅ |
23
+ | Paste a live AWS key in a prompt ❌ | Blocked before the API call is made ✅ |
24
+ | `Read customers.csv` full of email addresses ❌ | PII detected before Claude sees the file ✅ |
25
25
  | `echo $API_KEY` with live key ❌ | Env var value scanned and blocked ✅ |
26
+ | `cat docker-compose.yml` with `POSTGRES_PASSWORD:` ❌ | Assignment detected in YAML and JSON too ✅ |
26
27
 
27
28
  - **Two hooks** — `UserPromptSubmit` and `PreToolUse` cover both directions of risk
28
- - **64 detection rules** — sourced from gitleaks and TruffleHog detector definitions
29
+ - **76 detection rules** — sourced from gitleaks and TruffleHog detector definitions
29
30
  - **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)
30
- - **Context gating** — phone numbers, postal codes, and public IP addresses require a nearby label, reducing false positives on bare digit sequences
31
+ - **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
32
+ - **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 (`git@github.com`, `deploy@host`, `user@host:path`), and `.env.example` and 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
31
33
  - **Entropy filtering** — reduces false positives on low-entropy values
32
34
  - **Local only** — all scanning runs in your terminal; nothing is sent anywhere
33
35
 
@@ -56,7 +58,15 @@ Install in two commands from inside a Claude Code session:
56
58
  /plugin install sensitive-canary@coo-quack
57
59
  ```
58
60
 
59
- Done. The hooks are enabled automatically.
61
+ The hooks are enabled for every session started after this. A session that was
62
+ already running does not pick them up — it reports the plugin as enabled and
63
+ checks nothing — so start a new one.
64
+
65
+ **Then check that it blocks.** An installation that checks nothing looks exactly
66
+ like one that works, and only exit 2 stops a tool call, so a hook that fails to
67
+ start is silent. Write a file holding `AKIA` followed by `IOSFODNN7EXAMPLE` and
68
+ ask Claude to read it. It should refuse and say why. If it shows you the key, the
69
+ hooks are not running.
60
70
 
61
71
  > **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](https://docs.anthropic.com/en/docs/claude-code/discover-plugins) for details.
62
72
 
@@ -85,18 +95,18 @@ Then add to `~/.claude/settings.json`:
85
95
  "hooks": [
86
96
  {
87
97
  "type": "command",
88
- "command": "npx tsx $(npm root -g)/@coo-quack/sensitive-canary/src/user-prompt-submit-hook.ts"
98
+ "command": "node $(npm root -g)/@coo-quack/sensitive-canary/dist/user-prompt-submit-hook.js"
89
99
  }
90
100
  ]
91
101
  }
92
102
  ],
93
103
  "PreToolUse": [
94
104
  {
95
- "matcher": "Read|Bash",
105
+ "matcher": "Read|NotebookRead|Bash|Grep|mcp__.*",
96
106
  "hooks": [
97
107
  {
98
108
  "type": "command",
99
- "command": "npx tsx $(npm root -g)/@coo-quack/sensitive-canary/src/pre-tool-use-hook.ts"
109
+ "command": "node $(npm root -g)/@coo-quack/sensitive-canary/dist/pre-tool-use-hook.js"
100
110
  }
101
111
  ]
102
112
  }
@@ -105,7 +115,7 @@ Then add to `~/.claude/settings.json`:
105
115
  }
106
116
  ```
107
117
 
108
- > **Note:** Node.js does not support `--experimental-strip-types` for files inside `node_modules`, so `npx tsx` is used instead.
118
+ > **Note:** These point at the compiled JavaScript the package ships. Node refuses to strip types from a `.ts` file inside `node_modules`, and a hook that fails to start exits non-zero without blocking — so an installation wired to `src/` looks installed and checks nothing. The plugin install uses the `.ts` sources, which sit outside `node_modules` and work.
109
119
 
110
120
  </details>
111
121
 
@@ -141,7 +151,7 @@ Then add to `~/.claude/settings.json`:
141
151
  ],
142
152
  "PreToolUse": [
143
153
  {
144
- "matcher": "Read|Bash",
154
+ "matcher": "Read|NotebookRead|Bash|Grep|mcp__.*",
145
155
  "hooks": [
146
156
  {
147
157
  "type": "command",
@@ -165,11 +175,14 @@ Then add to `~/.claude/settings.json`:
165
175
  Prompts containing secrets or PII are blocked before being sent.
166
176
 
167
177
  ```
168
- > My AWS key is AKIAIOSFODNN7EXAMPLE. Can you review this code?
178
+ > Here is my deploy key:
179
+ > -----BEGIN RSA PRIVATE KEY-----
180
+ > MIIEowIBAAKCAQEAwK3vJ9m5Q8xY2nB4dF6hL0pR7sT1uV3wX5yZ8aC2eG4iK6mO
181
+ > Can you review the config that uses it?
169
182
 
170
- 🐤 sensitive-canary: sensitive data detected — blocked
183
+ 🐦 sensitive-canary: sensitive data detected — blocked
171
184
 
172
- [Secret] AWS Access Key ID (aws-access-key): AKIA****MPLE
185
+ [Secret] PEM Private Key (private-key): ---****KEY
173
186
 
174
187
  To allow, add a tag to your prompt:
175
188
  [allow-secret] — allow secrets
@@ -179,12 +192,12 @@ To allow, add a tag to your prompt:
179
192
  To allow it through, add the suggested tag:
180
193
 
181
194
  ```
182
- > [allow-secret] My AWS key is AKIAIOSFODNN7EXAMPLE. Can you review this code?
195
+ > [allow-secret] Here is my deploy key: -----BEGIN RSA PRIVATE KEY-----
183
196
  ```
184
197
 
185
198
  ### .env file blocked
186
199
 
187
- `.env` / `.env.*` files are blocked by filename, regardless of their contents. This name-based block is a secret guard and only applies while the `secret` category is enabled (the default).
200
+ `.env` and its siblings are blocked by filename, before anything is read. Template names — `.env.example`, `.env.sample`, `.env.template`, `.env.dist`, `.env.defaults` — 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).
188
201
 
189
202
  ```
190
203
  > Read .env
@@ -225,7 +238,7 @@ To intentionally bypass a block, include the appropriate tag in your **current p
225
238
  | `[allow-pii]` | Skip all PII-category checks |
226
239
  | `[allow-all]` | Skip all sensitive-canary checks |
227
240
 
228
- > **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 can be bypassed by any of the three allow tags.
241
+ > **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.
229
242
 
230
243
  ---
231
244
 
@@ -319,15 +332,16 @@ User rules support the same fields as built-in rules:
319
332
  |---|---|---|
320
333
  | `requireContext` | boolean | Only fire when a nearby context word is found |
321
334
  | `contextWords` | string[] | Words that satisfy the context requirement |
335
+ | `excludeContext` | string[] | Words that, found nearby, say the match is not what the rule is after — the mirror of `contextWords` |
322
336
  | `contextWindow` | number | Override the global context window (default: 3 tokens) |
323
337
  | `entropyThreshold` | number | Skip matches below this Shannon entropy |
324
- | `secretGroup` | number | Capture group index containing the secret (default: 0 = full match) |
338
+ | `secretGroup` | number | Capture group holding the secret. Omit for the whole match — writing `0` is not the same as omitting it, see [Detection Rules](https://coo-quack.github.io/sensitive-canary/rules.html) |
325
339
  | `validate` | string | Name of a built-in checksum validator (see below) |
326
- | `flags` | string | Regex flags (default: `"g"`) |
340
+ | `flags` | string | Regex flags. `g` is added if left out; `y` makes a rule match only at the very start of the text |
327
341
 
328
342
  Available validators (referenced by name in the `validate` field):
329
343
 
330
- `luhn`, `mynumber-jp`, `nir-fr`, `codice-fiscale-it`, `steuer-id-de`, `dni-nie-es`, `rrn-kr`, `brn-kr`, `resident-id-cn`, `public-ipv4`, `public-ipv6`
344
+ `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`
331
345
 
332
346
  ### Overriding the context window globally
333
347
 
@@ -346,13 +360,26 @@ Invalid rules (bad regex, wrong types, missing required fields) are skipped with
346
360
 
347
361
  ## Detection rules
348
362
 
349
- ### Secrets (39 rules)
363
+ ### Secrets (52 rules)
350
364
 
351
365
  | Rule ID | Description |
352
366
  |---|---|
367
+ | `openai-service-key` | OpenAI Service Account / Admin Key (`sk-svcacct-`, `sk-admin-`, `sk-proj-` prefix) |
368
+ | `azure-storage-key` | Azure Storage Account Key (`AccountKey=` + 88-char base64) |
369
+ | `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 |
370
+ | `google-oauth-secret` | Google OAuth Client Secret (`GOCSPX-` prefix) |
371
+ | `flyio-token` | Fly.io API Token (`FlyV1 fm2_` prefix) |
372
+ | `databricks-token` | Databricks Personal Access Token (`dapi` + 32 hex) |
373
+ | `vault-token` | HashiCorp Vault Token (`hvs.` / `hvb.` prefix) |
374
+ | `shopify-token` | Shopify Access Token (`shpat_`, `shpss_`, `shpca_`, `shppa_` prefix) |
375
+ | `doppler-token` | Doppler Token (`dp.pt.`, `dp.st.`, … prefix) |
376
+ | `grafana-token` | Grafana Cloud / Service Account Token (`glc_`, `glsa_` prefix) |
377
+ | `notion-token` | Notion Integration Token (`ntn_` prefix) |
353
378
  | `aws-access-key` | AWS Access Key ID |
354
379
  | `gcp-api-key` | Google Cloud API Key |
355
380
  | `private-key` | PEM Private Key (RSA / EC / DSA / PGP / OpenSSH) |
381
+ | `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 |
382
+ | `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 |
356
383
  | `github-pat` | GitHub Personal Access Token |
357
384
  | `github-fine-grained` | GitHub Fine-Grained Token |
358
385
  | `gitlab-pat` | GitLab Personal Access Token |
@@ -390,13 +417,12 @@ Invalid rules (bad regex, wrong types, missing required fields) are skipped with
390
417
  | `env-assignment` | `.env`-style secret assignment *(entropy ≥ 3.0)* |
391
418
  | `connection-string` | Database connection string with embedded credentials |
392
419
 
393
- ### PII (25 rules)
420
+ ### PII (24 rules)
394
421
 
395
422
  | Rule ID | Description | Validation |
396
423
  |---|---|---|
397
424
  | `pii-email` | Email address | — |
398
425
  | `pii-credit-card` | Credit card number | Luhn check |
399
- | `pii-ipv4` | IPv4 address (RFC 1918 private ranges only) | — |
400
426
  | `pii-ssn` | US Social Security Number | Invalid prefix exclusion |
401
427
  | `pii-mynumber-jp` | Japanese Individual Number (My Number) | Checksum (weighted mod 11) |
402
428
  | `pii-nir-fr` | French NIR / Social Security Number | Check key (mod 97) |
@@ -449,30 +475,87 @@ When blocked, the terminal shows what was detected and how to bypass it.
449
475
 
450
476
  ### ② PreToolUse hook
451
477
 
452
- Runs just before Claude calls the `Read` or `Bash` tool.
478
+ Runs just before Claude calls the `Read`, `Bash` or `Grep` tool, or any MCP tool.
453
479
 
454
480
  ```
455
- Claude calls Read / Bash tool
481
+ Claude calls Read / Bash / Grep / MCP tool
456
482
  ↓
457
483
  PreToolUse hook
458
484
  ↓
459
485
  ── Read tool ─────────────────────────────────────────────────────
460
486
  │ 1. filename is .env / .env.* → blocked (secret category only)
461
487
  │ 2. file contents contain secret / PII → blocked
462
- └─ Bash tool ─────────────────────────────────────────────────────
463
- 1. env var values referenced in the command contain secret / PII → blocked
464
- 2. command string itself contains secret / PII (e.g. echo AKIA...) → blocked
465
- 3. cat / head / tail / etc. targeting a file → file contents scanned
488
+ │
489
+ ├─ Bash tool ──────────────────────────────────────────────────────
490
+ │ 1. env var values referenced in the command contain secret / PII → blocked
491
+ │ 2. a bare env / printenv would print the whole environment → every
492
+ │ variable is scanned
493
+ │ 3. command string itself contains secret / PII (e.g. echo AKIA...) → blocked
494
+ │ 4. the command is located past any wrapper (sudo, env VAR=1, timeout,
495
+ │ nice, xargs) and any leading VAR=value assignment
496
+ │ 5. inline scripts (-c, -e, -pe) are parsed and scanned
497
+ │ 6. file paths from input redirections, command substitutions and chained
498
+ │ commands are extracted and scanned
499
+ │ 7. printing commands (cat, head, tail, sed, awk, grep, rg, cut, sort,
500
+ │ base64, xxd, strings, diff, comm, dd, and git subcommands) targeting
501
+ │ a named file → file contents scanned
502
+ │
503
+ └─ every other tool, Grep and mcp__* included ─────────────────────
504
+ 1. input fields naming an existing file are scanned for
505
+ secret / PII → blocked
466
506
  ```
467
507
 
468
- When blocked, Claude receives a JSON response explaining the reason and is prompted to tell the user.
508
+ A value is scanned when either its field name says path or the value itself is shaped like one.
509
+
510
+ 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`.
511
+
512
+ 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.
513
+
514
+ 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.
515
+
516
+ 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.
517
+
518
+ 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`.
519
+
520
+ 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.
521
+
522
+ 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.
469
523
  The terminal also receives a direct message (via `/dev/tty`).
470
524
 
525
+ ### Known Limitations
526
+
527
+ - **Heredoc bodies** — a heredoc body is treated as text, not as commands, so `cat > deploy.sh <<'EOF'` writing a script that mentions `.env` is not itself a read. The trade-off is that a heredoc which *feeds* commands to another shell (`ssh host <<'EOF'` with a `cat /etc/secrets` in the body) is not inspected either.
528
+ - **A tool that runs a command is read for the command, by field name** — `command`, `commands`, `cmd`, `script`, `code`, `commandline` and `shellcommand`, each read with punctuation and case ignored, so `command_line`, `command-line`, `commandLine` and `command.line` are the same name. A shell-running MCP server that names the field something else hands its command past unread.
529
+ - **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 64 KB transcript tail read 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.md` covers why three of them carry length bounds.
530
+ - **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. `update` and `copy` are where those two things come apart: `mcp__*__update_file` and `mcp__*__copy_file` open 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.
531
+ - **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 `.env` would be blocked as though the file had been read.
532
+ - **git history references** — `git show HEAD:.env` and similar references to objects in git history (not on disk) are not scanned, since the object does not exist as a file path.
533
+ - **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.
534
+ - **A template holding a real credential is blocked** — `.env.example` and 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 through `Read`. Use `[allow-secret]` if that is deliberate.
535
+ - **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 `.env` in a swept directory: the name decides that one when the contents cannot.
536
+ - **Anything that is not a regular file** — a FIFO, a process substitution (`/dev/fd/63`) and `/dev/stdin` are not read, so `cat` of one is not scanned. Reading them can never reach the end of the file: `cat /dev/zero` held 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.
537
+ - **A search that names no path is judged on names alone** — `rg pattern`, `grep -r pattern` and `Grep {pattern}` with no `path` all print from the working directory, so that directory is checked for an `.env` and 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 plain `rg TODO` in a third of the checkouts it was measured against.
538
+ - **`~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.
539
+ - **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. 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.
540
+ - **A shell construct that names the file only at run time** — `for f in secrets; do cat "$f"; done` and `find . -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 is `cat` reached through a loop or through `find`'s own argument list.
541
+ - **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.
542
+ - **A relative path is resolved against the directory Claude Code reports** — and against a literal `cd` at the start of the same command. A `cd` later 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.
543
+ - **`**` 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 **/secrets` sees `*/secrets`.
544
+ - **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.
545
+ - **A glob is expanded by the hook, not by the shell** — `cat *.env` is 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.
546
+ - **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.
547
+ - **Paths arriving over a pipe** — `find . -name '.env' | xargs cat` names no file the hook can see.
548
+ - **Programs that read files themselves** — `python script.py` is not scanned, because running a script does not print its source; whatever the script opens at run time is beyond the hook's reach.
549
+ - **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 `5` in `head -n 5 f` and in `cut -c 5 f`. A pattern-first command is different — `grep -A 5 f` spends the `5` as the pattern instead, and only `f` is collected. Harmless in practice, since only paths that exist as regular files are read — a file named `5` in the working directory would be scanned, and nothing else is.
550
+ - **A command a wrapper hands off to may be mistaken for its argument, or the reverse** — the search past `sudo`, `timeout` and 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 f` resolves to `cat` and scans `f`. `echo`, `printf`, `true`, `false` and `:` are known to print their arguments rather than open them, and stop the search; any other unclassified name does not.
551
+ - **Inline program text is followed four levels deep** — each `-c` / `-e` script inside another costs one level, so a read buried five interpreters down is not reached. Nested command substitutions are not bounded this way.
552
+ - **Best effort only** — detection is not exhaustive. Arbitrary shell metacharacters, eval chains, and complex expansions may not be fully tracked.
553
+
471
554
  ---
472
555
 
473
556
  ## Allow Tags (detailed)
474
557
 
475
- Allow tags filter the scan results — the scan still runs. The `.env`/`.env.*` name block is the only exception: when an allow tag is present, the file is passed through immediately without scanning.
558
+ 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.
476
559
 
477
560
  ### Mask tags
478
561
 
@@ -481,14 +564,14 @@ Allow tags filter the scan results — the scan still runs. The `.env`/`.env.*`
481
564
  If you include a mask tag, sensitive-canary will explain this and list what was detected:
482
565
 
483
566
  ```
484
- > [mask-secret] My key is AKIAIOSFODNN7EXAMPLE, can you review this?
567
+ > [mask-secret] My deploy key is -----BEGIN RSA PRIVATE KEY----- , can you review this?
485
568
 
486
569
  🐦 sensitive-canary: prompt masking is not supported
487
570
 
488
571
  [mask-secret] cannot mask prompt content.
489
572
  The following sensitive data was detected:
490
573
 
491
- [Secret] AWS Access Key ID (aws-access-key): AKIA****MPLE
574
+ [Secret] PEM Private Key (private-key): ---****KEY
492
575
 
493
576
  Please choose one of the following:
494
577
 
@@ -500,13 +583,19 @@ If you include a mask tag, sensitive-canary will explain this and list what was
500
583
 
501
584
  ### Allow + Mask tag priority
502
585
 
503
- When both `[allow-*]` and `[mask-*]` tags appear in the same prompt, **the tag that appears first wins** for each category (`secret`, `pii`). `[allow-all]` and `[mask-all]` resolve both categories at once.
586
+ 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.
504
587
 
505
- | Example | Result |
506
- |---------|--------|
507
- | `[allow-secret] [mask-secret] …` | secret allowed |
508
- | `[mask-secret] [allow-secret] …` | masking not supported error |
509
- | `[allow-secret] [mask-pii] …` | secret allowed, PII mask error |
588
+ | Example | secret | pii |
589
+ |---------|--------|-----|
590
+ | `[allow-all] … [allow-secret]` | allow | blocked |
591
+ | `[allow-secret] … [allow-all]` | allow | allow |
592
+ | `[allow-secret] … [mask-secret]` | mask (unsupported) | blocked |
593
+ | `[mask-secret] … [allow-secret]` | allow | blocked |
594
+ | `[allow-secret] … [allow-pii]` | blocked | allow |
595
+
596
+ 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.**
597
+
598
+ 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.
510
599
 
511
600
  ---
512
601
 
@@ -523,6 +612,10 @@ src/
523
612
  lib/
524
613
  inspector.ts allow tag parsing, message scanning
525
614
  rules.ts secret and PII detection rule definitions
615
+ default-config.json the rules themselves, as data
616
+ shell.ts shell syntax: tokens, quoting, heredocs, substitutions
617
+ bash-commands.ts what each command does with the files it is given
618
+ tool-inputs.ts which input fields of a tool name a file
526
619
  ```
527
620
 
528
621
  ---
@@ -530,12 +623,16 @@ src/
530
623
  ## Development
531
624
 
532
625
  ```bash
533
- npm install # install dependencies
626
+ pnpm install # install dependencies
534
627
 
535
- npm test # run tests
536
- npm run test:watch # run tests in watch mode
537
- npm run typecheck # type check (tsc)
538
- npm run lint # lint with Biome (no changes)
539
- npm run fix # lint + auto-fix with Biome
540
- npm run ci # typecheck + lint + tests (for CI)
628
+ pnpm test # run tests
629
+ pnpm run test:watch # run tests in watch mode
630
+ pnpm run typecheck # type check (tsc)
631
+ pnpm run lint # lint with Biome (no changes)
632
+ pnpm run fix # lint + auto-fix with Biome
633
+ pnpm run ci # typecheck + lint + tests (for CI)
541
634
  ```
635
+
636
+ The lockfile is pnpm's, and every CI job installs with pnpm, so `npm install`
637
+ here ignores it, writes a second lockfile, and resolves a different tree from the
638
+ one that is tested. `CONTRIBUTING.md` has the rest of the workflow.