@coo-quack/sensitive-canary 0.7.0 → 0.8.0

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 +791 -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/CHANGELOG.md CHANGED
@@ -1,5 +1,796 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.8.0 (2026-08-16)
4
+
5
+ ### Breaking changes
6
+
7
+ - **Two allow tags no longer add up.** Tags were resolved per category with the
8
+ first occurrence winning; the last tag now replaces the earlier ones whole.
9
+ `[allow-secret] [allow-pii]` resolves to the second tag alone, where in 0.7.0
10
+ it granted both — **`[allow-all]` is how both are asked for**. The change is
11
+ the other direction being wrong: `[allow-all]` narrowed to `[allow-secret]`
12
+ went on allowing PII, which is the opposite of what narrowing means and the
13
+ unsafe one of the two possible mistakes
14
+ - **A tag written by anything other than a person no longer counts.** A line the
15
+ runtime writes under the user's role — a background task reporting back, most
16
+ often — is skipped, so an agent's report that quotes `[allow-all]` no longer
17
+ lifts the guard for the next tool call. A workflow that relied on a tag
18
+ arriving from a subagent's output has to write the tag in a prompt instead
19
+ - **A tag between two fenced blocks is read as quoted.** Text from the first
20
+ fence marker in a message to the last is quoting, where before the markers
21
+ were paired off and the span between the second and third read as typed. A tag
22
+ before the first fence or after the last still counts
23
+
24
+ ### Features
25
+
26
+ - Parse Bash commands as shell syntax rather than by splitting on whitespace.
27
+ The tokenizer understands quotes (including `$'…'` and `$"…"`), heredoc
28
+ bodies, command and process substitutions, subshells, shell keywords,
29
+ redirection operators and their file-descriptor prefixes. Several ordinary
30
+ ways of naming a file were invisible to the old split: a path with a space in
31
+ it, `cat <secrets` with no space, a `cat` on the second line of a multi-line
32
+ command, `(cat secrets)`, `while cat secrets; do :; done`, and
33
+ `echo $(cat secrets)`
34
+ - Tokens record whether they came from a redirection operator or from a word, so
35
+ a quoted `>` is read as an operand. `grep ">" secrets`, an ordinary way to
36
+ search a file for a `>` character, previously had `secrets` skipped as though
37
+ it were an output target
38
+ - Scan the value of a variable referenced through an expansion that carries a
39
+ suffix, such as `${TOKEN:-fallback}` or `${TOKEN#prefix}`. Only the bare
40
+ `$TOKEN` and `${TOKEN}` forms were recognised before
41
+ - Expand the set of commands whose operands are treated as files written to
42
+ stdout, from seven to around forty: `tac`, `rev`, `strings`, `xxd`, `od`,
43
+ `hexdump`, `base64`, `cut`, `sort`, `uniq`, `shuf`, `column`, `paste`, `fold`,
44
+ `fmt`, `pr`, `expand`, `unexpand`, `iconv`, the `z*cat` family, `diff`, `comm`,
45
+ `join`, `look`, plus a second class whose first non-flag argument is a pattern
46
+ or script and whose remaining arguments are files (`sed`, `awk`, `grep`, `rg`,
47
+ `ag`, `jq`, `yq`). In-place editing is exempt: `sed -i`, `perl -i` and
48
+ `ruby -i` (including bundled forms such as `perl -pi -e` and `perl -lpi`) send
49
+ the result back to the file and write nothing to stdout. A bundle is read one
50
+ letter at a time, continuing only past switches that command accepts without a
51
+ value — the letters differ per command, so `sed -Ei` counts while
52
+ `perl -Ilib -pe` and `perl -MList::Util -pe` are reads rather than in-place
53
+ edits. A letter the list does not know stops the reading and the file is
54
+ scanned. `grep -i` is unaffected — its `-i` is case-insensitive matching, and
55
+ it still prints
56
+ - Locate the command past a wrapper (`sudo`, `env VAR=1`, `timeout N`, `nice`,
57
+ `xargs`, `stdbuf`) and past a leading `VAR=value` assignment, so the wrapped
58
+ command is classified instead of the wrapper
59
+ - Parse and scan inline program text from `-c` / `-e` / `-pe`, both as a nested
60
+ command line and for the quoted path literals in it, which is what catches
61
+ `python3 -c "open('.env').read()"`
62
+ - Scan the file operands of git subcommands that print contents (`show`, `diff`,
63
+ `blame`, `annotate`, `grep`, `cat-file`) and of `dd if=`. `git log` counts only
64
+ when a patch is asked for (`-p`, `--patch`, `-U<n>`, and the merge-diff forms):
65
+ without one it prints who changed the file and when, never a line of it
66
+ - Scan every environment variable when a bare `env` or `printenv` would print the
67
+ whole environment, including behind a wrapper (`sudo printenv`) and when the
68
+ output is redirected
69
+ - Treat commands that only measure a file (`wc`, `cksum`, `md5sum`, `sha1sum`,
70
+ `sha256sum`) as non-reads, whether the file is named or fed in over `<`
71
+ - Inspect the file inputs of every tool other than `Read` and `Bash`, `Grep` and
72
+ the MCP tools included. An input field naming an existing regular file is
73
+ scanned before the call: `path`, `paths`, `file`, `files`, `filepath`,
74
+ `filename`, `filenames`, `absolutepath`, `notebookpath` and `sourcepath`,
75
+ compared with separators and case removed so that `file_path`, `filePath` and
76
+ `filepath` are one name. Beyond those names, any value containing a `/` is
77
+ treated as a path whatever its field is called, which covers a tool carrying
78
+ one under `target`, `document` or `uri`. The `/` is what keeps a search
79
+ pattern from being read as a path — `{ "pattern": ".env" }` searches for that
80
+ text rather than reading the file — at the cost of missing a bare filename
81
+ under an unlisted name. Found up to four levels down and inside arrays, of
82
+ strings and of objects alike. A field naming a directory is left alone.
83
+ Exempt are the tools that surface no file contents (`Write`, `Edit`, `MultiEdit`,
84
+ `NotebookEdit`, `TodoWrite`, `Glob`, `WebFetch`, `WebSearch`, `ExitPlanMode`,
85
+ `AskUserQuestion`) and tools whose name leads with a write verb (`write_file`,
86
+ `createPage`): naming a file they do not read is not a leak. The default
87
+ matcher becomes `Read|Bash|Grep|mcp__.*`
88
+ - Add an opt-in integration test that runs the hook inside a real headless
89
+ Claude Code session and asserts both halves of the block contract: the read is
90
+ stopped, and the reason reaches Claude. Every other test spawns the hook and
91
+ reads its output itself, which says nothing about whether the runtime acts on
92
+ it. Set `SENSITIVE_CANARY_INTEGRATION=1` to run it; it needs credentials and
93
+ network, so CI skips it
94
+ - The PreToolUse block reason is written to stderr instead of to a stdout
95
+ `{"decision":"block"}` payload. Both reach Claude on the current version, but
96
+ the documentation describes stdout as ignored on a non-zero exit and takes the
97
+ PreToolUse decision from `hookSpecificOutput` rather than a top-level
98
+ `decision` field, so the old form depended on undescribed behaviour. Blocking
99
+ is unchanged: exit 2 is what stops the call
100
+ - Heredoc bodies are treated as text, not commands: writing a script that
101
+ mentions `.env` via `cat > deploy.sh <<EOF` is not a read. Known limitation: a
102
+ heredoc that feeds commands to a remote shell (`ssh host <<EOF`) is not caught,
103
+ written up under "② PreToolUse hook" in the README
104
+ - Detect a PEM private key that has been base64-encoded, under the new
105
+ `private-key-base64` rule. `-----BEGIN` never appears in the text, so
106
+ `client-key-data` in a kubeconfig, `tls.key` in a Kubernetes Secret and a key
107
+ held in Terraform state were all invisible to the plaintext rule. Three bytes
108
+ encode to four characters, so the header looks different depending on where it
109
+ sits relative to that boundary: the rule carries all three forms, since
110
+ matching one would find one key in three. Swept over 986 real files on a
111
+ developer machine, it found four keys in a kubeconfig and nothing else
112
+ - Detect credentials in the userinfo half of an http(s) URL, under the new
113
+ `url-basic-auth` rule — a git remote, a `.netrc`, a private registry, a `curl`
114
+ invocation. RFC 3986 §3.2.1 deprecates the form for the same reason. The
115
+ placeholder machinery already covers the near neighbours, so
116
+ `https://user:password@localhost`, `https://x-access-token:${GH_TOKEN}@…` and
117
+ `https://USERNAME:PASSWORD@example.com` stay quiet; the same sweep of 986 real
118
+ files flagged none of them
119
+ - Count the hash in a Telegram bot token loosely. The pattern asked for exactly
120
+ 33 characters after `AA` — not a minimum, an exact count — so a 32-character
121
+ token and a 35-character one were both invisible, and the bot id was capped at
122
+ ten digits. Now 6–12 digits and 30–40 characters, with word boundaries at
123
+ either end
124
+ - Read `mongodb+srv://` as a connection string. The rule listed `mongodb` but
125
+ not the SRV scheme, which is the one MongoDB Atlas hands out
126
+ - Recognise the AWS key prefixes `ABIA`, `ACCA`, `APKA` and `ASCA` alongside the
127
+ nine already listed
128
+
129
+ ### Fixes
130
+
131
+ - Read a file both ways when its encoding is a guess. Eight NUL pairs — sixteen
132
+ bytes — in front of a UTF-8 file were enough for `detectUtf16` to call it
133
+ UTF-16, and the rest of it then decoded into characters no rule matches. The
134
+ counts cannot separate the two cases: a UTF-8 file with a few NULs on one side
135
+ of its pairs looks exactly like a page of Japanese UTF-16, where `一` (U+4E00)
136
+ puts a NUL on the minority side. A verdict that did not come from a
137
+ byte-order mark is marked as a guess and both readings are scanned. The same
138
+ cap was excluding real documents: thirty lines of Japanese with one `一`
139
+ apiece were not read as UTF-16 at all
140
+ - Keep an `.env` in a swept directory whatever its bytes look like. The sweep
141
+ skips binaries so that a folder of images is not ground through every rule,
142
+ and that skip ran before the name guard, so eight bytes of NUL at the head of
143
+ a `.env` took the strongest guard in the tool out of the sweep
144
+ - Do not honour a tag written by anything other than a person. A background task
145
+ reporting back arrives under the user's role carrying an agent's prose, and
146
+ prose about these tags was enough — a report quoting the documentation armed
147
+ the guard it was describing. The transcript says which lines are which, and
148
+ that is what the reader asks now
149
+ - Read the run from the first fence marker to the last as quoted. Pairing the
150
+ markers off left the span between the second and third readable as typed, and
151
+ a pasted markdown document with a code block inside it puts a quoted tag in
152
+ exactly that span
153
+ - Scan somewhere when a search names no path. `rg pattern`, `grep -r pattern`
154
+ and `Grep {pattern}` with no `path` all print from the working directory, and
155
+ with no field to collect there was nothing to scan. Judged on names alone: a
156
+ directory nobody named is every repository anyone searches, and reading their
157
+ contents stopped a plain `rg TODO` in four of twelve checkouts
158
+ - Name `NotebookRead` in the hook matcher. It reached the hook only because
159
+ `Read` is a substring of it, and the `notebook_path` field the hook reads was
160
+ being served by that accident: anchoring the match, or a rename, would have
161
+ dropped notebooks with nothing to say so
162
+ - Count one finding per category rather than per value. A value that a secret
163
+ rule and a PII rule both match is two findings, and collapsing on the value
164
+ alone reported whichever came first — so the block named one category while
165
+ the other was what held it, and which tag lifts it read as arbitrary
166
+ - Stop the search for the wrapped command at a command that prints its
167
+ arguments. `sudo echo cat secrets` resolved to the `cat` sitting in echo's
168
+ arguments and scanned a file the command never opens, so `echo`, `printf`,
169
+ `true`, `false` and `:` end the descent
170
+ - Leave the output file of `sort -o out.txt in.txt` and its siblings
171
+ (`shuf -o`, `iconv -o`, `tee`) out of the scan. The operand a flag names as a
172
+ destination is written, not printed, so naming it is not a read
173
+ - Correct the boundaries of six rules. Discover's `65` range stopped at 6589;
174
+ the card alternatives all assumed groups of four, where Amex prints 4-6-5 and
175
+ Diners 4-6-4; Square's exact length meant one character over the guess stopped
176
+ the token matching at all rather than matching partly; `twilio-sid` had no
177
+ word boundary, so a certificate fingerprint was an Account SID;
178
+ `telegram-bot-token` capped its secret part at 40 and went invisible at 41;
179
+ and `pii-email` excluded `zip` at the TLD position as though a list of file
180
+ extensions were a list of domains
181
+ - Stop treating every dotted value as a reference to code. `isNotSecretShaped`
182
+ waved through anything shaped `a.b.c`, and dotted credentials exist. What
183
+ separates them is that a name is words: measured over 147,643 dotted
184
+ identifiers from source on this machine, 0.06% fall below a mean word length
185
+ of 2.5, and the ones that do are JWTs
186
+ - Redact by code point. Slicing by code unit cut a surrogate pair in half and
187
+ wrote a lone surrogate to the terminal
188
+ - Match the placeholder rule's connection-string pattern once rather than three
189
+ times, and bound its scheme: an unbounded `\w+` in front of a literal that is
190
+ usually absent is quadratic in the length of a value someone else writes
191
+ - Make the email rule near-linear on its worst input. The local part
192
+ (`[A-Za-z0-9._%+-]+`) spans the word boundary at every dot, so on a long run
193
+ of digits and separators with no `@` — a log full of IP addresses or version
194
+ numbers is exactly that — every boundary cost a greedy consume of the rest
195
+ of the text plus a character-at-a-time backtrack in search of the `@`:
196
+ O(n²), half a minute for 200 KB, and effectively forever for a multi-MB
197
+ file. The local part is now bounded at 64 characters (RFC 5321's limit, so
198
+ no deliverable address is lost) and the domain is matched as dot-separated
199
+ labels, which leaves nothing to backtrack over
200
+ - Bound the `connection-string` credentials too. `[^@\s]+` crosses both `:` and
201
+ `/`, so a line of `mongodb://` with no `@` in it ran to the end of the text
202
+ from every occurrence: 188 KB took 2.3s, and 1 MiB through the hook took 98s
203
+ and returned exit 0. Every adversarial shape then in the tests walked past it,
204
+ which is the shape list being caught short rather than the guard working, so
205
+ one written for this syntax was added
206
+ - Bound the `env-assignment` pattern's name the same way. It read `[A-Z_]*`
207
+ before its keyword and `[A-Z_0-9]*` after, so a run of capitals with no `=`
208
+ backtracked from every position: 59 KB took 381ms, 234 KB 6.9s, 1 MiB 125s.
209
+ 1 MiB is what the file cap allows through, so capping the read did not stop
210
+ the hook being killed — measured, a 1 MiB file of repeated `SECRET` was still
211
+ killed at 40 seconds with the cap in place. Every rule in the config is now
212
+ run against a list of adversarial shapes in the tests, so this shape fails before
213
+ a release rather than after one
214
+ - Read a file into a buffer of the cap's size rather than of the size `stat`
215
+ reports. procfs and sysfs entries are regular files that report zero bytes and
216
+ produce content anyway, so their content was read as empty and passed.
217
+ `readFileSync`, which this replaced, read to EOF and did not have the problem.
218
+ Reading such a file is not the same as scanning it whole: the NUL rule stops at
219
+ the first separator, so `/proc/self/environ` is read and only its first
220
+ variable is looked at, which is now listed under Known Limitations
221
+ - Scan only the first 1 MiB of a file rather than reading it whole.
222
+ `readFileSync` has no size limit, so a large enough file kept the hook from
223
+ ever returning — and a hook killed by Claude Code's PreToolUse timeout does
224
+ not block the call, which made the hang a way through. A secret past the cut
225
+ is missed, the same trade the transcript's 64 KB tail read already makes
226
+ - Scan the file operand of a pattern-first command when the pattern flag carries
227
+ its value written against it. `grep -eaws secrets`, `grep -faws secrets` and
228
+ `sed -e's/a/b/' secrets` scanned nothing: the attached spelling was not
229
+ recognised, so nothing marked the pattern as supplied and the file that
230
+ followed was consumed as the pattern. The separate (`grep -e aws`) and `=`
231
+ (`--regexp=aws`) spellings were already handled
232
+ - Put back what quieting the rules had taken out. A corpus of five hundred
233
+ generated values, run against this release and against v0.7.0, found a hundred
234
+ and twenty-seven inputs the old version detected and this one did not — none of
235
+ which the thirty-two cases chosen by hand had caught. Restored:
236
+ - an address near an excluded word. One word within a couple of dozen
237
+ characters was erasing every address near it, three at a time in a CSV. The
238
+ exclusion is now the three shapes that are really hostnames: a VCS user, an
239
+ address straight after `ssh`/`scp`/`rsync`/`sftp`, and the `host:path` form
240
+ - a bare private address. Requiring a label lost `192.168.1.50`,
241
+ `X-Forwarded-For: 10.0.0.5` and `remote_addr=…`; what says an address is a
242
+ machine is the command around it, so that is what excludes it now, and a
243
+ `host:port` pair is a service rather than a person
244
+ - an assignment that is not at the start of a line: `docker run -e PASSWORD=…`,
245
+ `cd /app && PASSWORD=…`, a single-quoted value, a value with a trailing
246
+ semicolon or comma, and one indented past sixteen columns. `DB_PASS` counts
247
+ as well as `DB_PASSWORD`
248
+ - a Square token after `key_` or in a query string, which a boundary counting
249
+ `_` and `=` as base64 had erased
250
+ - the Korean resident and business numbers without their separators, which is
251
+ how they are stored. Context keeps a timestamp out instead
252
+ - a postal code next to the word `max`, and a connection string whose password
253
+ runs past 256 characters
254
+ - Read a command that arrives as an argv array on the `Bash` tool too, not only
255
+ on an MCP one. The same command was scanned or not depending on who sent it
256
+ - Block a `.env` template whose contents cannot be read whole. The exemption
257
+ assumed the contents would be scanned instead, and a NUL byte or a file past
258
+ the per-file cut stopped that — so `.env.nul.example` and `.env.big.example`
259
+ passed on their names after all
260
+ - Expand `**` as a single `*` rather than refusing it. Refusing it meant `cat **`
261
+ was scanned not at all, while the shell expanded it and read the files
262
+ - Read a command field that arrives as an argv array or nested under another key.
263
+ Only a top-level string was read, so `{"command":["cat",".env"]}` and
264
+ `{"args":{"command":"cat .env"}}` went past — both by a name with no slash in
265
+ it, which the path rules do not collect either
266
+ - Stop reading after five seconds. A byte budget bounds what is read and not what
267
+ is walked, and a pattern reaching one level under a home directory took ten
268
+ seconds, which is close enough to the PreToolUse timeout to matter
269
+ - Stop blocking ordinary work. Measured over sixty-four commands from a working
270
+ day, the hook blocked sixteen of them; it now blocks five, and four of those
271
+ five are this repository's own README and changelog, which contain an
272
+ AWS-shaped key as documentation. What changed:
273
+ - an address is not a person when an `ssh`, `scp`, `rsync`, `clone` or `git@`
274
+ is next to it, and `example.com` and the other RFC 2606 domains are nobody's
275
+ mail
276
+ - the published test card numbers are not cards
277
+ - `cap` is an English word as well as an Italian postal one, so sizes and
278
+ limits nearby say it is not a postal code
279
+ - the Korean resident and business numbers are written with their separators;
280
+ without that, a millisecond timestamp in a log was a finding
281
+ - a Square token inside a longer run of base64 is a slice of something else,
282
+ which is what made `cat ~/.ssh/known_hosts` a finding
283
+ - a value that is a variable reference (`PASSWORD: ${VAR}`) names a secret
284
+ rather than being one
285
+ - `.env.example`, `.env.sample`, `.env.template`, `.env.dist` and
286
+ `.env.defaults` are not blocked by name. Their contents are still scanned, so
287
+ a template with a real key in it is still caught — by what is in it
288
+ - **`[allow-secret]` lifted PII blocks, which the README says it cannot.**
289
+ Deduplication ran before the allow tag, and it keys on the value — so a string
290
+ that a secret rule and a PII rule both match lost the PII finding first, and
291
+ the tag then removed what was left. The two hooks had the order the other way
292
+ round from each other; the prompt hook was right
293
+ - **A pasted log could lift the guard on the key in the same message.** The
294
+ prompt hook read tags from the raw prompt while the other hook read them from
295
+ what the user typed, so a fenced log or a README quoting `[allow-secret]`
296
+ decided them. One implementation now answers for both
297
+ - **Input the check could not read was treated as input the check approved.**
298
+ Two characters missing from the end of a payload passed a key through. Empty
299
+ stdin is still nothing to check; bytes that will not parse now stop the call
300
+ - **A filename could put lines into the text Claude reads.** POSIX allows a
301
+ newline in a path and a path is attacker-chosen, so a file could be named such
302
+ that the block message grew a line saying the block was a false positive.
303
+ Escape sequences went the same way and could clear the screen first. Control
304
+ characters are escaped on the way out now, and the finding list is capped —
305
+ one rule that matched everywhere produced forty thousand lines
306
+ - **A single rule from a config file could hang the hook.** The scan budget is
307
+ checked between rules and cannot interrupt one match, so `(a+)+$` ran for
308
+ hours and the hook was killed — which does not block. A V8-side timeout does
309
+ interrupt a running match, at 0.06ms per scan
310
+ - A config path that is a FIFO blocked the read forever, and a config with more
311
+ than about 120,000 rules threw while the module was still loading, before any
312
+ handler existed. Both exited without blocking
313
+ - **`tail` printed the part that was not scanned.** The per-file cap reads the
314
+ first megabyte; `tail -2 app.log` shows the last lines, which on a large log is
315
+ where a failure has just printed a connection string. Both ends are read now.
316
+ What is still missed is the middle of a file larger than both windows
317
+ - `view` and `vimdiff` print a file the way `less` does and were not on the list
318
+ - The documentation said the hooks are active immediately after installing the
319
+ plugin. A session that is already running does not pick them up: it reports the
320
+ plugin as enabled and checks nothing. It also said a PreToolUse allow tag is
321
+ consumed by the first tool call — it lasts until a tool result is recorded, so
322
+ calls issued together are all covered by one. And it said a `.env` with an
323
+ allow tag is passed through without scanning, which is the opposite of what the
324
+ code does. All three are corrected, and the step that proves a hook is really
325
+ running is now on the recommended install path rather than only the pnpm one
326
+ - The `phone-jp` validator existed and was named in neither document; a test now
327
+ holds both documents to the registry. Added a section on the ways a rule goes
328
+ quiet without warning — `secretGroup: 0` is not the same as omitting it, an
329
+ `entropyThreshold` above 8 rejects everything, `flags: "y"` matches only at the
330
+ start of the text, and a large `contextWindow` widens `excludeContext` too
331
+ - **The same defect was still in `env-assignment`, and worse.** Its value
332
+ capture was open-ended, so a megabyte of `TOKEN=TOKEN=…` took six minutes —
333
+ past any hook timeout, and a killed hook does not block. The capture is atomic
334
+ now (`(?=(X))\1`, since every character the delimiter test accepts is one the
335
+ class already excludes, so retrying a shorter run could never succeed) and
336
+ capped, with a single character deciding whether the value simply ran past the
337
+ cap. 373 seconds to 2 milliseconds, and a fifty-thousand-character value is
338
+ still found
339
+ - **The hook stopped every tool call, with no way out, when its working
340
+ directory had been removed.** `process.cwd()` throws there, and it was called
341
+ while the module was still loading — before the transcript is read — so the
342
+ message advising an allow tag described something that could not be honoured.
343
+ A build script that runs `rm -rf dist` from inside `dist`, or a
344
+ `git worktree remove`, is enough. There is nothing sensitive about a missing
345
+ directory: a relative path simply has no base
346
+ - **A tag written in backticks did not work, and the documentation writes them
347
+ that way.** Treating inline code as quoting refused the form this project
348
+ teaches, and refused it silently — the block that followed advised adding the
349
+ tag it had just ignored. Fenced blocks still quote rather than issue, so a
350
+ pasted log cannot lift the guard
351
+ - `<bash-input>` was missing from the elements that are not user input, an
352
+ unclosed element was not stripped at all, and a line the runtime wrote as an
353
+ assistant turn was read as user input if the message inside it claimed the
354
+ role
355
+ - A UTF-16 file whose first characters are Japanese or Chinese has no zero byte
356
+ among them, and five hundred pairs of prefix decided the whole file. The
357
+ window is wider, the threshold is on the asymmetry rather than the rate, and
358
+ whether the result reads as text is what settles it
359
+ - A FIFO named as the transcript blocked the read forever; a write to a closed
360
+ stderr threw on the way out of a block and turned it into a pass; and a
361
+ payload of `null` parsed successfully and then threw on the first field read
362
+ - **Twenty-six wrong blocks out of six hundred real files.** A value that is a
363
+ URL, a path, an identifier, a header name, a number or a dotted setting name
364
+ is no longer read as the secret its variable is named after — `secret_name`,
365
+ `VAULT_TOKEN_PATH` and `TOKEN_HEADER_NAME` describe a secret rather than
366
+ holding one, and a key whose last word is `PROJECT` or `ENDPOINT` says so
367
+ outright. The shape test applies only where a rule captured a free-form value:
368
+ a Slack webhook is a URL and a secret both, and asking whether it looks like a
369
+ URL is the wrong question
370
+ - A connection string with `${PGPASSWORD}` still in it holds no credential at
371
+ all, and `postgres:postgres@` is what a compose file ships with
372
+ - A context word is a label, not a fragment of the identifier beside the number.
373
+ `extract-zip` supplied "zip" and `golang.org/x/mobile` supplied "mobile", so a
374
+ version number beside either read as a postal code or a telephone number —
375
+ which is to say lockfiles and `go.sum` could not be read at all. Nor could
376
+ `name@version`, which is an address by shape
377
+ - Twelve identical digits satisfy the My Number checksum by arithmetic rather
378
+ than by being anyone's number, and `01-02-2024` is a date. A Japanese
379
+ telephone number has ten digits or eleven, and 0120 belongs to a business
380
+ - **A megabyte of `eyJ` used to kill the hook, and a killed hook does not
381
+ block.** Two rules were shaped `{n,}` followed by a literal that may never
382
+ come, which makes the engine retry the whole tail from every start position.
383
+ `eyJ` recurs every three characters, so one 400 KiB file was enough to spend
384
+ the PreToolUse timeout and take the rest of the call with it — including the
385
+ `.env` name guard, by naming the padding first. A JWT begins at a token
386
+ boundary, and saying so leaves one start instead of a third of a million: a
387
+ megabyte went from 104 seconds to 27 milliseconds. The Mapbox, Sentry and
388
+ Square patterns had the same shape and are bounded too
389
+ - A scan that runs past ten seconds now stops the call rather than finishing
390
+ quietly. Bounding those patterns fixed the two rules that could do it; this is
391
+ so the next rule of that shape is caught instead of repeating it. The check
392
+ sits between rules, since a single match cannot be interrupted
393
+ - **An allow tag could be issued by something other than the user.** Claude Code
394
+ records the output of a `!` command, slash-command names and system reminders
395
+ as user messages, so `[allow-all]` appearing in any of them lifted the guard
396
+ for the next tool call — `grep -r allow-all` was enough. A tag inside a code
397
+ fence no longer counts either: a pasted log is quoting the tag, not asking for
398
+ it
399
+ - **A UTF-16 file was not scanned at all.** Every other byte is NUL, and the
400
+ scan stops at the first one, so the contents came to one character. PowerShell
401
+ 5.1 writes UTF-16LE by default, which makes redirecting a command's output to
402
+ a file a way past this. Little-endian, big-endian and byte-order-marked files
403
+ are all read now; genuinely binary files are still left alone
404
+ - `Read` with a `file_path` that is not a string exited 0, while the same shape
405
+ under any other tool name reached the shared collector and blocked
406
+ - **A crash no longer passes the call through.** Only exit 2 blocks, and an
407
+ unforeseen error exits 1 — so any bug anywhere in a hook silently switched the
408
+ protection off, which is the failure this tool exists to prevent. Both hooks
409
+ now stop the call instead, with a message saying the check did not finish
410
+ rather than claiming a finding. Input the hooks do understand is unaffected;
411
+ `[allow-all]` gets past it
412
+ - **A prompt that is not a string is read rather than dropped.** Not throwing on
413
+ `{"prompt":{"text":"…"}}` was only half the fix: coercing it to the empty
414
+ string exited 0, which is the same silence the exception produced. Every
415
+ string inside the value is collected now, to a bounded depth, so object,
416
+ array and content-block prompts are scanned like a plain one
417
+ - A field named `command.line` or `command line` was walked past while
418
+ `file.path` was read correctly — the two collectors normalised field names
419
+ with a regex each, and the one for commands dropped only `-` and `_`
420
+ - The placeholder recognition added above could be used to smuggle a live
421
+ credential: it asked whether a value *contained* a placeholder word, so
422
+ `changeme_` in front of a real key switched the rule off. The whole value has
423
+ to be placeholder now
424
+ - Widening the Stripe and OpenAI rules swallowed two rules whole:
425
+ `stripe-restricted-key` became a strict subset of `stripe-secret-key`, and
426
+ `openai-project-key` stopped being reported at all. Both fire again
427
+ - **Private IPv4 addresses are no longer detected.** An RFC 1918 address is
428
+ non-routable and identifies nothing outside the network it belongs to, and the
429
+ rule spent its time on ansible inventories, ssh configs, Kubernetes manifests
430
+ and docker-compose files — five such files, all blocked before, all quiet now.
431
+ Public addresses are unchanged and still require a nearby label. Anyone who
432
+ wants the old behaviour can add the rule back through the config file; the
433
+ `excludeContext` field it used is documented now and still serves the postal
434
+ code rule
435
+ - The release could not publish. GitHub runs every `run:` step as `bash -e {0}`,
436
+ and the smoke test added last round pipes into a hook that exits 2 on purpose,
437
+ so errexit killed the step before the assertion that expected the 2. The gate
438
+ written to make the release safer made it impossible; every invocation now
439
+ captures its status instead of letting the pipeline decide the step's fate
440
+ - The release gates now run against the tarball `npm publish` would upload, not
441
+ the checked-out tree. Deleting `"dist/"` from the `files` field used to pass
442
+ every gate while shipping a package whose hooks cannot start — verified by
443
+ doing it, along with dropping `hooks/` and shipping the tests
444
+ - `hooks/hooks.json` — the file the plugin install path reads, and the only one
445
+ still pointing at the TypeScript sources — had no gate at all. Emptying it left
446
+ every check green. The release now parses it, requires both events, and
447
+ resolves every command's path inside the tarball
448
+ - Recognise a value written to be replaced. Half of a realistic `.env.example`
449
+ was blocked on its contents (`your-password-here`, `REPLACE_ME_WITH_REAL`,
450
+ `django-insecure-...`, `postgres://user:password@localhost/db`), which defeats
451
+ exempting the name: the file exists to be committed and read. Ten realistic
452
+ templates now read clean, and one holding a live key is still blocked. Only
453
+ secret rules consult the list, and `example` is deliberately not on it — AWS's
454
+ own documented key ends in it and is still a key
455
+ - An address stopped being found when a remote-shell word appeared anywhere
456
+ within forty characters: `rsync failed, notify alice@corp.io` was silently
457
+ dropped. The exemption now covers the operand position only — `ssh user@host`
458
+ and at most two arguments in between — and the `host:path` forms of scp and
459
+ rsync are left to the trailing-colon rule that already handled them
460
+ - Cover the credit card brands the rule claimed and did not match. The Discover
461
+ branch required seventeen digits, so no Discover, JCB or Diners card could
462
+ reach it, and Mastercard's 2-series (2221-2720) and UnionPay were absent
463
+ outright — five brands undetected. Ranges follow Discover's published IIN
464
+ summary, which also puts the Discover range at 644-658, so 659 is no longer
465
+ claimed
466
+ - Slack's rotated tokens (`xoxe-`), app-level tokens (`xapp-`) and workflow
467
+ tokens (`xwfp-`) were not matched; nor were Stripe restricted, organization
468
+ and webhook-signing secrets, nor eight of GitLab's ten token prefixes
469
+ - Mapbox and Sentry tokens were written to shapes those services do not issue —
470
+ Mapbox delimits into three parts of which the first is the literal `pk`, `sk`
471
+ or `tk`, and a Sentry org token is underscore-separated, not dotted. Neither
472
+ rule had ever matched a real token
473
+ - A Square token longer than sixty characters was missed. Square's contract
474
+ allows up to 1024; the length had been pinned at exactly what appears in the
475
+ wild
476
+ - Codice fiscale: omocodia substitutes letters for digits at the seven numeric
477
+ positions when two people would share the first fifteen characters, and both
478
+ the pattern and the checksum guard demanded digits there — so every such code,
479
+ each issued to a real person, was missed
480
+ - Add two more from a format survey: an Azure Shared Access Key
481
+ (`SharedAccessKey=` + 44-char base64, for Service Bus, Event Hubs and IoT Hub,
482
+ which is a different length from the 88-character storage account key) and a
483
+ Google OAuth client secret (`GOCSPX-`). Google's `ya29.` access tokens and
484
+ `1//` refresh tokens are deliberately not matched: Google documents no format
485
+ for them beyond a size cap and reserves the right to change it, so a pattern
486
+ would be a guess that reads as a guarantee
487
+ - Add nine rules for credentials that no rule covered: OpenAI service-account and
488
+ admin keys, Azure Storage account keys, Fly.io, Databricks, HashiCorp Vault,
489
+ Shopify, Doppler, Grafana and Notion tokens. 64 rules to 73
490
+ - Stop repeating the blocked command back to Claude. The reason a block gives
491
+ carried the first eighty characters of the command, so blocking
492
+ `export GITHUB_TOKEN=ghp_…` handed the token to the model inside the sentence
493
+ explaining that it had been withheld. The detection lines were already
494
+ redacted; the line above them was not
495
+ - Read a command out of a tool input field. Only `Bash` was ever parsed as a
496
+ command, so an MCP server that runs a shell — `{"command":"cat .env"}` — was
497
+ looked at as a path, found not to be a file, and let through, with the default
498
+ matcher sending every `mcp__*` tool down that path. `command`, `cmd`, `script`
499
+ and `code` are read now, the last for the paths quoted inside it
500
+ - Treat an input of the wrong type as absent rather than throwing. A `command`
501
+ that is a number, a `prompt` that is an object, a `cwd` that is an array: each
502
+ threw, and an exception exits 1, which does not block —
503
+ `{"prompt":{"text":"<a key>"}}` went through unscanned
504
+ - Anchor the assignment rule to the start of a line and require its value to be a
505
+ value. Widening it to `:` and lower case made it read ordinary code:
506
+ `function check(token: ShellToken)` was a secret, and the plugin could not read
507
+ its own source — 97 findings across 17 files of this repository, now none
508
+ - Resolve a relative path against the directory the tool runs in. The payload
509
+ carries a `cwd` and nothing read it, so `cat secrets.txt` named a path relative
510
+ to wherever the hook process happened to start and was dropped as a file that
511
+ is not there. A literal `cd` earlier in the same command moves the base too,
512
+ which is what `cd build && cat secrets` needs
513
+ - Read an assignment written with `:` and with a lower-case name. The rule wanted
514
+ `[A-Z_]` and `=`, so a `docker-compose.yml` full of `POSTGRES_PASSWORD: …`, an
515
+ `appsettings.json` with `"client_secret": …` and an `~/.aws/credentials` with
516
+ `aws_secret_access_key = …` all passed — the three file shapes this tool exists
517
+ to guard
518
+ - Keep a substitution among the operands instead of ending the segment at it.
519
+ `cat <(echo hi) secrets` left `secrets` in a segment of its own, where it was
520
+ read as a command name; the comment at that line said the only cost was
521
+ reaching the inner command twice
522
+ - Bound the work of one tool call at 64 MiB across every file it reads, and skip a
523
+ file already read. A glob naming three hundred large files took half a minute,
524
+ and five overlapping globs read the same files five times
525
+ - Lift the `.env` name block only for a tag that allows secrets. `parseAllowTags`
526
+ reads `[allow-<anything>]`, and the guard asked only whether any tag was
527
+ present, so `[allow-pii]` and a mistyped `[allow-pi]` both turned it off. It no
528
+ longer skips the content scan either: a tag for one category was silently
529
+ covering the other
530
+ - Expand `~` and `~/…` to the home directory. `cat ~/.aws/credentials` named a
531
+ path that exists on no disk, so it was dropped as a file that is not there —
532
+ and `~/.ssh/id_rsa`, `~/.npmrc` and `~/.netrc` went the same way
533
+ - Expand `{a,b}` as well as `*`, `?` and `[`. `cat .env{,.bak}` reached the name
534
+ guard as the single name `.env{`, which is not an `.env` file, so the guard
535
+ that reads names rather than disks did not fire
536
+ - Keep the literal candidate beside a glob's matches. Returning only the matches
537
+ was a way through this hook did not have before the expansion existed:
538
+ `cat /nonexistent/.env.*` matches nothing, so nothing was scanned and the
539
+ `.env` name guard never ran, and a file really named `report[2].txt` was read
540
+ as a character class and expanded to `report2.txt`
541
+ - Read a shell's bundled `-c`. `bash -lc 'cat secrets'` runs what `bash -c`
542
+ runs, and only the exact spelling was recognised, so the inline code went
543
+ unparsed. The letters before the `c` have to be valueless switches
544
+ - Step past `eval` the way the other wrappers are stepped past
545
+ - Read `$(<secrets)`, which has no command in it at all: bash reads the file and
546
+ substitutes its contents, so the redirection is the only thing there
547
+ - Scan the quoted literals inside an awk or sed program.
548
+ `awk 'BEGIN{while((getline l < "secrets")>0) print l}'` names a file without
549
+ ever passing it as an operand
550
+ - Detect `-----BEGIN ENCRYPTED PRIVATE KEY-----` and the SSH2 spelling, which
551
+ `openssl genpkey -aes256` writes and the rule did not list
552
+ - Expand a glob before deciding whether it names a file. `cat sec*` collected
553
+ `sec*`, found nothing on disk by that name, and allowed the read; `cat .env*`
554
+ did the same, one character away from `cat .env`, which is blocked on its name.
555
+ A pattern is now expanded and each match is scanned, up to 256 of them
556
+ - Read a redirection that stands before the command. `< secrets cat` is `cat`
557
+ reading `secrets`, but the operator was skipped and its target taken for the
558
+ command name, so the real command went unclassified and nothing of it was
559
+ collected — while `cat < secrets` blocked
560
+ - Scan the file named inside a `git log -L` range. `-L1,10:secrets` prints the
561
+ lines of that file, and the file is written inside the flag's own argument
562
+ where neither the flag nor the operand handling would look for it
563
+ - Read `--` as the end of option parsing for the in-place test too. In
564
+ `sed -- -i secrets`, `-i` is the script and `secrets` is a file sed prints; read
565
+ as the in-place flag, the command counted as writing and the file was skipped
566
+ - Collect a path from an array inside an array. `{ "paths": [["…"]] }` fell
567
+ between the string branch and the object branch and was never looked at
568
+ - Stop reading a path that names something other than a regular file. Reading
569
+ `/dev/zero` never reaches the end of the file, so the hook did not return and
570
+ Claude Code's PreToolUse timeout killed it — and a killed hook does not block
571
+ the call, which made the hang a way through. The tool-input side already
572
+ stat'd first; the Bash side now does too. On the paths that name a file
573
+ outright — `Read` and a Bash command — `.env` and `.env.*` are still blocked
574
+ on the name alone, before anything is opened. A tool input naming no existing
575
+ file is left alone as before, since its "path" may be a URL route or an object
576
+ key. What is no longer read is a FIFO, a process substitution or `/dev/stdin`,
577
+ which is now listed under Known Limitations
578
+ - Read `--` as the end of option parsing. `grep -- -aws secrets` searches for
579
+ `-aws` in `secrets`, but `-aws` was taken for a flag, so nothing marked the
580
+ pattern as supplied and `secrets` was consumed in its place rather than
581
+ scanned. Without the `--` the same tokens mean what they did before: the file
582
+ is the pattern and the command reads stdin
583
+ - Scan a variable named inside another expansion's suffix. `${A:-$TOKEN}` prints
584
+ `$TOKEN` whenever `A` is unset, but each expansion was matched whole, so the
585
+ skip to the closing brace swallowed the suffix and the name in it. Every `$` a
586
+ name follows now counts, which also takes in an unclosed `${TOKEN`: searching
587
+ a checkout for template references with `grep -rn '${TOKEN' .` is blocked when
588
+ that variable holds a secret. A false block, and the same direction the hook
589
+ already errs in for `echo '$TOKEN'`
590
+ - `.claude-plugin/plugin.json` declared `0.5.1` while `package.json` declared
591
+ `0.7.0`: the release checklist asks for both, and the bump was missed for
592
+ 0.6.0 and 0.7.0. The plugin manifest now matches the released version
593
+ - Also treat the rest of the digest commands as measuring a file rather than
594
+ printing it: `sha512sum < secrets` was scanned while `sha256sum < secrets` was
595
+ not, because only four of the family were listed. `sha224sum`, `sha384sum`,
596
+ `sha512sum`, `b2sum`, `shasum`, `md5` and `sum` join them
597
+ - Read a directory when a tool is pointed at one. `grep -r AKIA .` and a Grep
598
+ whose `path` names a folder both return file contents, and both reached a
599
+ check that asks whether the candidate is a regular file, found it is not, and
600
+ let the call through — with the key printed to stdout. The files directly
601
+ inside are now scanned, one level deep and capped at the same limit a glob is.
602
+ Measured over forty-five real directories, five block, and all five hold
603
+ credential-shaped assignments
604
+ - Scan what a command says, not only what it opens, for every tool rather than
605
+ only `Bash`. An MCP server that runs a shell takes `{"command":"echo AKIA…"}`,
606
+ and the key was in the argument list unread
607
+ - Scan every run of text in a file that holds NUL bytes. Scanning stopped at the
608
+ first one, so a single leading NUL reduced the scan to the empty string — and
609
+ an empty scan finds nothing and allows the read. The runs are joined by
610
+ newlines so no rule matches across two of them
611
+ - Recognise UTF-16 when one side of each byte pair dominates, rather than when
612
+ the other side is empty. Characters in the U+xx00 rows — U+3000, the
613
+ ideographic space, among them — put a single NUL on the wrong side, and one of
614
+ those in a Japanese document sent the whole file down the binary path
615
+ - Expand `$VAR` and `${VAR}` in a path. `cat ~/.aws/credentials` was blocked and
616
+ `cat $HOME/.aws/credentials` was not, so the guard turned on which of two
617
+ spellings the author used. A variable that is unset is left as written
618
+ - Hold the scan budget across the whole hook invocation rather than resetting it
619
+ per `scan()` call. A hook scans once per environment variable and twice per
620
+ file, so each call stayed inside the budget while the total did not: with a
621
+ slow rule in a user config and sixty variables, measured at 29 seconds and
622
+ exit 0. Now capped at 10.5 seconds, and past the budget it exits 2
623
+ - Fifteen detection rules matched a shape the vendor does not issue. `flyio-token`
624
+ required the `FlyV1 ` auth scheme, which is not part of the token, and excluded
625
+ `_` and `-` from a base64url body; `linear-key`, `twilio-sid` and `postman-key`
626
+ were lower-case only against mixed-case and hex formats; `notion-token`,
627
+ `digitalocean-pat`, `gitlab-pat`, `square-access-token` and `huggingface-token`
628
+ each covered one of the prefixes their vendor issues; `replicate-token` omitted
629
+ the hyphen; `azure-sas-key` fixed the length at 43 where Azure IoT DPS
630
+ documents 16–64 byte keys; `anthropic-key` asked for 95 characters after the
631
+ prefix where the format has 101, truncating the match; and `twilio-sid` had no
632
+ rule for the API Key SID at all
633
+ - Start the scan clock when the payload arrives, not when the process does.
634
+ Both the five-second file deadline and the scan budget began at module load
635
+ and counted the wait for stdin against themselves, so a slow handover spent
636
+ the whole allowance before a file was read: six seconds of delay and nothing
637
+ was scanned, on an exit code of 0
638
+ - Stop a compaction summary and a skill body from carrying an allow tag. Both
639
+ are written by the runtime with the user's role, and neither is anyone asking
640
+ for anything: a summary re-injects earlier turns, so a tag discussed at any
641
+ point in a conversation came back armed, and a meta line carries file content,
642
+ so writing a `SKILL.md` was enough to lift every check
643
+ - Treat a fence that never closes as quoting, the way an unclosed synthetic
644
+ element already was. A paste cut short is still a paste
645
+ - Return a quarter of a value in the block reason rather than eight characters
646
+ of it. The reason is written to stderr, which is where Claude reads it, so it
647
+ reaches the API the block exists to keep the value from — of a nine-character
648
+ password, eight characters were being handed back
649
+ - Skip binaries when a directory is swept, while still scanning one in full when
650
+ it is named outright. Nobody asked for the files a directory sweep picks up,
651
+ and a folder of images cost three seconds and reported the compressed bytes as
652
+ email addresses. Measured: 8 MiB of images, 3,103ms to 131ms
653
+ - Require a NUL imbalance to be near-total before reading a file as UTF-16. An
654
+ eight-to-one ratio read four real binaries out of seventeen thousand as text,
655
+ and a key sitting in a JPEG's bytes went unfound because the file decoded to
656
+ nonsense
657
+ - Stop reading a reference to a value in code as the value. `env-assignment`
658
+ matched `process.env.API_TOKEN`, `user.password_digest` and `self.api_key`;
659
+ two in five of the distinct values it matched across thirty thousand real
660
+ files were one of these
661
+ - The last tag in a message is the one that applies, replacing the earlier ones
662
+ rather than merging with them. Resolving each category separately kept the
663
+ wider grant of the two, so `[allow-all]` narrowed to `[allow-secret]` went on
664
+ allowing PII — the opposite of what narrowing means. Two tags no longer add
665
+ up: `[allow-all]` is how both categories are asked for
666
+ - Resolve tags the same way in both hooks. `PreToolUse` collected every allow
667
+ tag and never looked at mask tags, so `[mask-secret] [allow-secret]` stopped
668
+ the prompt and then allowed the tool call it was stopping
669
+ - Read AWS's documented keys as documentation. AWS writes `EXAMPLE` where the
670
+ random part would end — `AKIAIOSFODNN7EXAMPLE` and its siblings — and those
671
+ appear in every setup guide and in every README that copies one, where a block
672
+ reads exactly like a block on a live key. The new `aws-key` validator rejects
673
+ the suffix; a real key whose last seven characters spell it is one in
674
+ thirty-six to the seventh. This project's own README, installation page and
675
+ getting-started page were among the files it made unreadable
676
+ - Stop reading a Retina asset filename as an email address. `logo@2x.png`
677
+ satisfied the pattern because `png` is two or more letters. Thirty asset
678
+ extensions are excluded, none of which is a country code
679
+ - Match card numbers at every length their brand issues. ISO/IEC 7812 allows
680
+ 10–19 digits and the pattern encoded one length per brand, so 19-digit
681
+ UnionPay, JCB and Discover cards, 13-digit Visa, and every Maestro card went
682
+ unmatched. Swept over 986 real files, the wider pattern adds no false positive
683
+
684
+ ### Documentation
685
+
686
+ - `SECURITY.md` described the parse-error path as a fail-open that exits 0. It
687
+ exits 2 — the security policy stated the protection backwards
688
+ - `docs/troubleshooting.md` said hooks activate without a restart, which the
689
+ README, the installation page and the getting-started page all contradict.
690
+ Restarting is now the first step, since a session that missed the hooks lists
691
+ the plugin as enabled and checks nothing
692
+ - `README.md` said only the first 1 MiB of a file is scanned. Both ends are
693
+ read; what is missed is the middle
694
+ - `docs/rules.md` said any allow tag lifts the `.env` name block and passes the
695
+ file through without scanning. `[allow-pii]` does not lift it, and the
696
+ contents are scanned either way
697
+ - `docs/rules.md` gave `API_KEY=placeholder` as a value the entropy threshold
698
+ reports. It is not reported — the placeholder test drops it
699
+ - The file structure in `README.md` listed two files under `src/lib/`, from
700
+ before this release added three more and moved the rules into JSON
701
+ - The development commands in `README.md` were `npm`, while `CONTRIBUTING.md`
702
+ and every CI job are `pnpm`. The lockfile is pnpm's, so following the README
703
+ ignored it, wrote a second lockfile, and resolved a different tree from the one
704
+ that is tested
705
+
706
+ ### CI
707
+
708
+ - Publish compiled JavaScript. Node refuses to strip types from a `.ts` file
709
+ inside `node_modules`, so an npm install wired to `src/` started the hook,
710
+ failed with `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`, exited 1 — and a
711
+ non-zero exit that is not 2 does not block. The tool looked installed and
712
+ checked nothing. `dist/` now ships beside `src/`, the npm instructions point at
713
+ it, and no type stripper or `tsx` download is involved. The plugin install
714
+ keeps using the sources, which sit outside `node_modules` and work
715
+ - Run the release path's own gates. `release.yml` is reached by a push to `main`
716
+ and is not chained to the pull-request build, so a red branch could publish. It
717
+ now runs the audit, the version-agreement check whose absence let the plugin
718
+ manifest ship stale twice, a build, and a smoke test that starts both published
719
+ hooks with plain `node`
720
+ - Stop shipping the tests. `files` carried `src/`, which carried `__tests__`:
721
+ more than half the tarball, and none of it useful to anyone installing
722
+
723
+ - Add a `versions` job that fails when `package.json` and
724
+ `.claude-plugin/plugin.json` declare different versions, or when either
725
+ declares nothing that looks like one
726
+ - Check `vitest.config.ts` the way `src/` is checked. It sits at the repository
727
+ root, and both `tsc` and `biome` were scoped to `src`, so the file that decides
728
+ how the tests run was neither typechecked nor linted
729
+ - Run CI on pushes to `main` and `develop`, not only on pull requests. The
730
+ commit a merge makes belongs to no PR, so nothing built it: two branches that
731
+ are green apart can still be red together
732
+ - The release could not publish, again, and for a new reason. The smoke test now
733
+ starts each hook through `eval`, and under the `bash -e {0}` GitHub runs every
734
+ step with, errexit fires inside the pipeline's subshell: a hook exiting 2 on
735
+ purpose reached the assertion as 1, so all eight blocking assertions failed.
736
+ `set -uo pipefail` does not clear `-e`, which arrives from the invocation.
737
+ Measured: without `set +e`, eight errors and the step exits 1
738
+ - Make every step after the publish recoverable. The job was gated on
739
+ `should_release`, which npm alone decides, so a failure after the publish left
740
+ the version on npm with no GitHub Release and no catalog sync, and a re-run
741
+ skipped the job entirely. Only the publish is conditional now; the tag, the
742
+ release and the sync are idempotent and run every time
743
+ - Run the release smoke test against the commands `hooks/hooks.json` declares,
744
+ as well as against `dist/`. The manifest starts `src/*.ts` under type
745
+ stripping, which is what a plugin install runs and what the gate only checked
746
+ the existence of; `dist/*.js` is what the npm instructions point at. The
747
+ commands are read back from the manifest so the two cannot drift apart
748
+ - Fail the release when the marketplace catalog does not pin this plugin to
749
+ `main`. `/plugin install` serves the entry's `ref`, and with no `ref` that is
750
+ the repository's default branch — `develop`. Every gate in `release.yml`
751
+ guards `main` and npm, and none of them was on the path a plugin user installs
752
+ from, so a merge into `develop` reached users directly
753
+ - Anchor the version shape test at both ends, in `ci.yml` and `release.yml`
754
+ alike. `^[0-9]+\.[0-9]+\.[0-9]+` with no `$` accepts anything at all after a
755
+ valid prefix, and `release.yml` splices that value into four `run:` scripts:
756
+ `0.8.0"; curl … | sh; echo "`, `0.8.0 && rm -rf /` and `0.8.0$(id)` were all
757
+ accepted by the old test and are all rejected by the new one. The version is
758
+ now validated in the job that captures it, before it reaches `$GITHUB_OUTPUT`,
759
+ and every step that uses it reads it from `env:` rather than by interpolation
760
+ - Create the git tag before publishing to npm, and let npm alone decide whether
761
+ a version is released. npm refuses to republish, so a publish that landed and
762
+ was followed by a failing step could not be retried — the tag it never created
763
+ had to be made by hand, and the release job declined to act on a re-run
764
+ - Give `release.yml` a `concurrency` group, so two pushes to `main` in quick
765
+ succession cannot race over the tag and the publish. Nothing is cancelled: a
766
+ release half-way through is worse than one that waits
767
+ - Put a `timeout-minutes` on every job in both workflows. A hung step otherwise
768
+ holds a runner for the six-hour default
769
+ - Assert that the published `UserPromptSubmit` hook allows a clean prompt. Every
770
+ assertion made of it was that it exits 2, so a hook that exits 2
771
+ unconditionally — blocking every prompt the user types — would have shipped
772
+ green
773
+ - Install with `--frozen-lockfile` in CI. A lockfile CI is allowed to rewrite is
774
+ a lockfile CI does not check
775
+ - Take CodeQL off GitHub's default setup and run it from `codeql.yml`. Default
776
+ setup only analyses a pull request whose base is the default or a protected
777
+ branch, so a PR stacked on another feature branch was never scanned — the same
778
+ gap `ci.yml` had, and the branches it covers cannot be configured. The new
779
+ workflow analyses pull requests, pushes to `main` and `develop`, and a weekly
780
+ schedule, because an advisory lands after a change merges rather than only
781
+ alongside one. The two setups cannot coexist: default setup has to be off for
782
+ these analyses to be accepted
783
+ - Fail the lint on warnings, and check `docs/.vitepress/` the way `src/` is
784
+ checked. `biome lint` exits 0 on a warning, so the dead `tokenize` in
785
+ `src/lib/rules.ts` — superseded by `contextTokens`, and carrying a comment
786
+ describing the behaviour that replaced it — sat in the tree reported and
787
+ ignored. Two rules are turned off rather than obeyed: `useLiteralKeys`
788
+ contradicts this project's `noPropertyAccessFromIndexSignature`, and applying
789
+ it broke the typecheck; `noTemplateCurlyInString` is off for the test tree,
790
+ which cannot test `${VAR}` handling without writing one
791
+
792
+ ---
793
+
3
794
  ## v0.7.0 (2026-08-04)
4
795
 
5
796
  ### Features