@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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +791 -0
- package/README.md +142 -45
- package/dist/lib/bash-commands.js +405 -0
- package/dist/lib/command-tables.js +462 -0
- package/dist/lib/default-config.json +570 -0
- package/dist/lib/encoding.js +123 -0
- package/dist/lib/fail-closed.js +31 -0
- package/dist/lib/inspector.js +0 -0
- package/dist/lib/rules.js +399 -0
- package/dist/lib/shapes.js +161 -0
- package/dist/lib/shell.js +436 -0
- package/dist/lib/tool-inputs.js +217 -0
- package/dist/lib/transcript.js +115 -0
- package/dist/lib/validators.js +435 -0
- package/dist/pre-tool-use-hook.js +773 -0
- package/dist/user-prompt-submit-hook.js +105 -0
- package/hooks/hooks.json +1 -1
- package/package.json +25 -11
- package/src/lib/bash-commands.ts +455 -0
- package/src/lib/command-tables.ts +518 -0
- package/src/lib/default-config.json +155 -46
- package/src/lib/encoding.ts +135 -0
- package/src/lib/fail-closed.ts +36 -0
- package/src/lib/inspector.ts +0 -0
- package/src/lib/rules.ts +202 -365
- package/src/lib/shapes.ts +175 -0
- package/src/lib/shell.ts +512 -0
- package/src/lib/tool-inputs.ts +235 -0
- package/src/lib/transcript.ts +142 -0
- package/src/lib/validators.ts +435 -0
- package/src/pre-tool-use-hook.ts +774 -198
- package/src/user-prompt-submit-hook.ts +60 -18
- package/src/__tests__/pre-tool-use-hook.test.ts +0 -779
- package/src/__tests__/user-prompt-submit-hook.test.ts +0 -297
- package/src/lib/__tests__/inspector.test.ts +0 -289
- 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
|