@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
@@ -0,0 +1,462 @@
1
+ // What this hook knows about how each command treats its operands.
2
+ //
3
+ // The tables are the knowledge and nothing here is logic: which commands print
4
+ // a file they are handed, which take a pattern first, which run another
5
+ // command. A command missing from a table is a file that goes unscanned, so
6
+ // every table has a case per entry in the suite and an equality assertion
7
+ // beside it — a generated case cannot notice a deletion.
8
+ // Commands that write the contents of every non-flag argument to stdout.
9
+ // `wc` is deliberately absent: it reports counts, never the bytes themselves.
10
+ export const FILE_READ_COMMANDS = new Set([
11
+ "cat",
12
+ "head",
13
+ "tail",
14
+ "less",
15
+ "more",
16
+ "bat",
17
+ // `view` is read-only vi and `vimdiff` opens two files at once; both print
18
+ // the contents the way `less` does.
19
+ "view",
20
+ "vimdiff",
21
+ "nl",
22
+ "tac",
23
+ "rev",
24
+ "strings",
25
+ "xxd",
26
+ "od",
27
+ "hexdump",
28
+ "base64",
29
+ "cut",
30
+ "sort",
31
+ "uniq",
32
+ "shuf",
33
+ "column",
34
+ "paste",
35
+ "fold",
36
+ "fmt",
37
+ "pr",
38
+ "expand",
39
+ "unexpand",
40
+ "iconv",
41
+ "zcat",
42
+ "gzcat",
43
+ "bzcat",
44
+ "xzcat",
45
+ "zstdcat",
46
+ "lz4cat",
47
+ "lzcat",
48
+ "zless",
49
+ "zmore",
50
+ "bzless",
51
+ "xzless",
52
+ "diff",
53
+ "comm",
54
+ "join",
55
+ "look",
56
+ ]);
57
+ // Commands that read a file but report only measurements of it. Their stdin is
58
+ // not echoed either, so a redirection into one of them is not a read.
59
+ // The rest of the digest family, and the BSD spellings, were missing. Naming a
60
+ // file, that costs nothing — an unclassified command's operands are not
61
+ // collected either. Over `<` it did: stdin is collected for any command not
62
+ // known to print no contents, so `sha512sum < secrets` was scanned while
63
+ // `sha256sum < secrets` was not. The wrong direction is only a false block, but
64
+ // the two spellings disagreeing is not something to leave in a table.
65
+ export const COUNT_ONLY_COMMANDS = new Set([
66
+ "wc",
67
+ "cksum",
68
+ "sum",
69
+ "md5",
70
+ "md5sum",
71
+ "shasum",
72
+ "sha1sum",
73
+ "sha224sum",
74
+ "sha256sum",
75
+ "sha384sum",
76
+ "sha512sum",
77
+ "b2sum",
78
+ ]);
79
+ // Commands whose first non-flag argument is a pattern, expression or script,
80
+ // and whose remaining non-flag arguments are files written to stdout.
81
+ // General-purpose runtimes (`python`, `node`, `deno`, `bun`, and `perl` and
82
+ // `ruby` when they run a program file) are absent: they execute their first
83
+ // argument rather than print it, and the files named after it are argv, not
84
+ // output. Their inline code (`-c`, `-e`) is still scanned via
85
+ // INLINE_CODE_COMMANDS.
86
+ export const PATTERN_OR_SCRIPT_FIRST_COMMANDS = new Set([
87
+ "sed",
88
+ "awk",
89
+ "gawk",
90
+ "grep",
91
+ "egrep",
92
+ "fgrep",
93
+ "rg",
94
+ "ag",
95
+ "jq",
96
+ "yq",
97
+ "zgrep",
98
+ "zegrep",
99
+ "zfgrep",
100
+ "ack",
101
+ "ugrep",
102
+ ]);
103
+ // Flags that hand a pattern-first command its pattern or script, so no operand
104
+ // is standing in for it. Without this, `grep --regexp=aws secrets` and
105
+ // `sed --expression='s/a/b/' secrets` consumed the file as the pattern and never
106
+ // scanned it. The separate-value forms name a pattern or a pattern file, neither
107
+ // of which is printed, so their value is skipped rather than collected.
108
+ export const PATTERN_SUPPLYING_FLAGS = new Set([
109
+ "-e",
110
+ "--regexp",
111
+ "--expression",
112
+ "-f",
113
+ "--file",
114
+ "--from-file",
115
+ ]);
116
+ // Whether a flag token supplies the pattern, and whether its value is already
117
+ // attached to it.
118
+ //
119
+ // Three spellings carry a value: separate (`-e aws`, `--regexp aws`), attached
120
+ // after `=` (`--regexp=aws`), and attached directly to a short flag
121
+ // (`-eaws`, `sed -e's/a/b/'`). Only the first two were recognised, so an attached
122
+ // short value left `patternSkipped` unset and the file that followed was eaten as
123
+ // the pattern — `grep -eaws secrets` and `sed -e's/a/b/' secrets` scanned nothing.
124
+ export function patternSupplyingFlag(token) {
125
+ const equals = token.indexOf("=");
126
+ const beforeEquals = equals === -1 ? token : token.slice(0, equals);
127
+ if (PATTERN_SUPPLYING_FLAGS.has(beforeEquals)) {
128
+ return equals === -1 ? "separate" : "attached";
129
+ }
130
+ // A short flag with its value written against it. Long flags are excluded:
131
+ // `--file-name` is not `--file` with `-name` attached.
132
+ if (!token.startsWith("--") && token.length > 2) {
133
+ if (PATTERN_SUPPLYING_FLAGS.has(token.slice(0, 2)))
134
+ return "attached";
135
+ }
136
+ return null;
137
+ }
138
+ // Flags of a read command whose separate value names a file to write, not one to
139
+ // read: `sort -o out.txt in.txt` prints nothing of `out.txt`, and scanning it
140
+ // blocked a command that only ever writes there.
141
+ //
142
+ // Keyed per command rather than by flag name, because the same letter means
143
+ // different things: `-o` is an output file for these three but an octal-format
144
+ // flag taking no value for `od` and `hexdump`, so a shared list would swallow
145
+ // the operand of `od -o secrets` and miss the read.
146
+ export const WRITE_TARGET_FLAGS = {
147
+ sort: new Set(["-o", "--output"]),
148
+ shuf: new Set(["-o", "--output"]),
149
+ iconv: new Set(["-o", "--output"]),
150
+ };
151
+ // Interpreters whose file operands are input to a one-liner given inline:
152
+ // `perl -pe 's/a/b/' f` and `ruby -pe '…' f` print f. Hand them a program file
153
+ // instead and the operands are argv — `perl script.pl data.txt` prints neither —
154
+ // so their operands count as reads only once inline code has been seen.
155
+ export const INLINE_CODE_READS_OPERANDS = new Set(["perl", "ruby"]);
156
+ // Commands that run another command. They are stripped so the wrapped command
157
+ // is classified instead: `sudo cat secrets` is treated as `cat secrets`.
158
+ //
159
+ // `timeout` and `flock` take an operand of their own first (`timeout 5 cat f`,
160
+ // `flock /tmp/lock cat f`) and sat in a set of their own for a while. Nothing
161
+ // ever asked which set a name came from — the one place that read them ORed the
162
+ // two — because the search does not need to know: it walks forward to the first
163
+ // name it can classify, which steps over an operand as readily as over a flag.
164
+ export const WRAPPER_COMMANDS = new Set([
165
+ "sudo",
166
+ "doas",
167
+ "command",
168
+ "builtin",
169
+ "exec",
170
+ "nohup",
171
+ "time",
172
+ "nice",
173
+ "ionice",
174
+ "stdbuf",
175
+ "xargs",
176
+ "env",
177
+ "timeout",
178
+ "flock",
179
+ // `eval cat secrets` runs its arguments as a command line, so the command to
180
+ // classify is the one after it, exactly as with the wrappers above.
181
+ "eval",
182
+ ]);
183
+ // Interpreters that accept inline program text, which is scanned both as a
184
+ // nested command line and for quoted path literals.
185
+ export const INLINE_CODE_COMMANDS = new Set([
186
+ "sh",
187
+ "bash",
188
+ "zsh",
189
+ "dash",
190
+ "ksh",
191
+ "python",
192
+ "python3",
193
+ "perl",
194
+ "ruby",
195
+ "node",
196
+ "deno",
197
+ "bun",
198
+ "php",
199
+ ]);
200
+ export const POSIX_SHELLS = new Set(["sh", "bash", "zsh", "dash", "ksh"]);
201
+ // git subcommands that can write file contents to stdout. Blob references such
202
+ // as `git show HEAD:.env` name history, not the working tree, and stay
203
+ // uncovered — only paths that exist on disk are scanned. `difftool` hands off
204
+ // to an external tool and `stash` prints no file contents, so neither is here:
205
+ // classifying them would push tokens like the `pop` in `git stash pop` as paths.
206
+ export const GIT_READ_SUBCOMMANDS = new Set([
207
+ "show",
208
+ "diff",
209
+ "blame",
210
+ "annotate",
211
+ "grep",
212
+ "cat-file",
213
+ ]);
214
+ // `-L` is not here: it prints the lines of one named file rather than a patch of
215
+ // whatever operands follow, so it is handled where that file is extracted from
216
+ // the range spec. Listing it here as well marked every other operand of the same
217
+ // command as read.
218
+ //
219
+ // `git log <file>` prints who changed the file and when, never a line of it, so
220
+ // it belongs with the subcommands above only when a patch is asked for. Treating
221
+ // it as a read unconditionally blocked an everyday way of looking at history.
222
+ //
223
+ // The flags are the ones that produce a diff: `-U<n>` and `--unified=<n>` imply
224
+ // `--patch`, and the merge-diff forms print one too. A flag not listed here
225
+ // leaves the file unscanned, so the list errs towards including anything that
226
+ // might print contents — `git -c k=v log f` matches `-c` and is scanned, which
227
+ // is the harmless direction to be wrong in.
228
+ function gitLogPrintsFileContents(operands) {
229
+ return operands.some(({ value }) => {
230
+ if (value === "-p" || value === "-u" || value === "-c" || value === "-m") {
231
+ return true;
232
+ }
233
+ return (value.startsWith("--patch") ||
234
+ value.startsWith("-U") ||
235
+ value.startsWith("--unified") ||
236
+ value.startsWith("--cc") ||
237
+ value.startsWith("--diff-merges"));
238
+ });
239
+ }
240
+ // The file named inside a `git log -L` argument, which carries it after the last
241
+ // `:` of a range spec (`-L1,10:f`, `-L:funcname:f`). Written as a path on its
242
+ // own it would never be found, since the range is part of the token.
243
+ export function gitLineRangeFile(value) {
244
+ const colon = value.lastIndexOf(":");
245
+ if (colon === -1)
246
+ return null;
247
+ const file = value.slice(colon + 1);
248
+ return file === "" ? null : file;
249
+ }
250
+ // Whether a git subcommand writes the contents of the files named after it.
251
+ export function gitSubcommandPrintsFiles(subcommand, operands) {
252
+ if (subcommand === "log")
253
+ return gitLogPrintsFileContents(operands);
254
+ return GIT_READ_SUBCOMMANDS.has(subcommand);
255
+ }
256
+ // Commands whose `-i` rewrites the files it is handed instead of printing them,
257
+ // each with the short switches it accepts without a value.
258
+ //
259
+ // Being on this list at all is what makes `-i` mean in-place: `grep -i` matches
260
+ // case-insensitively and still prints. The letters are what let a bundle be read
261
+ // one at a time in search of the `i`.
262
+ //
263
+ // One table rather than two. A separate set of command names said the same thing
264
+ // as these keys, and the two could disagree — adding `awk` to the set alone made
265
+ // `awk -i` an in-place edit, which a test had to be written to catch. Nothing can
266
+ // disagree with itself.
267
+ //
268
+ // Per command, because the letters differ and sharing one set got it wrong in
269
+ // both directions: `E`, `r` and `z` are valueless for `sed` but absent from a
270
+ // set chosen for `perl`, so the everyday `sed -Ei` was read as a non-in-place
271
+ // command and its file scanned; while `0` was present for `perl -0777`, which
272
+ // made `sed -0i` — not a sed flag at all — look like an in-place edit and left
273
+ // its file unscanned.
274
+ //
275
+ // A letter that is not here stops the reading, whether it takes a value or the
276
+ // list simply does not know it. That is the fail-closed direction: the command
277
+ // is then treated as one that prints, and its operands are scanned.
278
+ // `e` is absent from every line on purpose: it introduces a script for all three,
279
+ // and a sed script contains `i` as its insert command, so reading past `-e` would
280
+ // find an `i` in the program text and call the command an in-place edit.
281
+ export const IN_PLACE_EDITORS = {
282
+ sed: "anszEru",
283
+ perl: "aclnpsStTuUvwWX",
284
+ ruby: "acdlnpsSTUvwWy",
285
+ };
286
+ // True when `cmd` edits in place given `value` as one of its flags: `-i`,
287
+ // `-i.bak`, or a bundle reaching `i` past that command's valueless switches
288
+ // (`-pi`, `-lpi`, `-Ei`).
289
+ //
290
+ // A command absent from the table is not an in-place editor at all, so no flag of
291
+ // it counts — `grep -i` and `grep --in-place` alike.
292
+ //
293
+ // `--in-place` is sed's alone. Accepting it from every command in the table meant
294
+ // `perl --in-place=.bak -pe 'x' secrets` and the same for `ruby` were treated as
295
+ // in-place edits and their files went unscanned, though neither interpreter has
296
+ // that flag: perl and ruby spell it `-i`, and would reject the long form.
297
+ export function isInPlaceFlag(cmd, value) {
298
+ const valueless = IN_PLACE_EDITORS[cmd];
299
+ if (valueless === undefined)
300
+ return false;
301
+ if (cmd === "sed" && value.replace(/=.*/, "") === "--in-place")
302
+ return true;
303
+ if (!value.startsWith("-") || value.startsWith("--"))
304
+ return false;
305
+ for (const ch of value.slice(1)) {
306
+ if (ch === "i")
307
+ return true;
308
+ if (!valueless.includes(ch))
309
+ return false;
310
+ }
311
+ return false;
312
+ }
313
+ // `--` ends option parsing here as well: in `sed -- -i secrets`, `-i` is the
314
+ // script and `secrets` is a file sed prints. Read as an in-place flag, the whole
315
+ // command counted as writing and the file went unscanned.
316
+ export function editsInPlace(cmd, operands) {
317
+ for (const { value } of operands) {
318
+ if (value === "--")
319
+ return false;
320
+ if (isInPlaceFlag(cmd, value))
321
+ return true;
322
+ }
323
+ return false;
324
+ }
325
+ // Global git flags that carry a separate value before the subcommand
326
+ // (`git -C repo show f`, `git -c k=v show f`). Attached forms (`--git-dir=x`)
327
+ // are single flag tokens and need no entry here.
328
+ export const GIT_GLOBAL_FLAGS_WITH_OPERAND = new Set([
329
+ "-C",
330
+ "-c",
331
+ "--git-dir",
332
+ "--work-tree",
333
+ "--namespace",
334
+ "--exec-path",
335
+ "--config-env",
336
+ ]);
337
+ // Recursion limit for inline program text: each `-c` / `-e` script inside
338
+ // another costs one level. Nested command substitutions are not bounded by it,
339
+ // since the tokenizer ends a segment at a paren and reaches the inner command
340
+ // without recursing. `depth` counts nesting, so the command line itself is 0 and
341
+ // four levels of inline text below it are inspected.
342
+ export const MAX_NESTING_DEPTH = 4;
343
+ // Searchers that walk the working directory when handed no path. The `grep`
344
+ // family is not here: it reads stdin unless `-r` is given, and that flag is
345
+ // what puts it in this class for the length of one command.
346
+ export const RECURSIVE_BY_DEFAULT = new Set(["rg", "ag", "ack", "ugrep"]);
347
+ export const GREP_FAMILY = new Set([
348
+ "grep",
349
+ "egrep",
350
+ "fgrep",
351
+ "zgrep",
352
+ "zegrep",
353
+ "zfgrep",
354
+ ]);
355
+ // `-r`, `-R`, `--recursive`, and the letter inside a bundle such as `-rn`. A
356
+ // bundle is read letter by letter because `grep -rn PATTERN` is how it is
357
+ // usually typed.
358
+ export function asksForRecursion(operands) {
359
+ for (const tok of operands) {
360
+ if (tok.redirect)
361
+ continue;
362
+ const v = tok.value;
363
+ if (v === "--recursive" || v === "--dereference-recursive")
364
+ return true;
365
+ if (v === "--")
366
+ break;
367
+ if (!v.startsWith("-") || v.startsWith("--") || v.length < 2)
368
+ continue;
369
+ if (/[rR]/.test(v.slice(1)))
370
+ return true;
371
+ }
372
+ return false;
373
+ }
374
+ // Fold one set of refs into another. A command line yields refs from several
375
+ // places — its substitutions, each of its segments, the inline code it carries,
376
+ // an `env -S` string — and combining them is the same three lines each time,
377
+ // which is how it came to be written out three times with a closure alongside.
378
+ export function mergeRefs(into, refs) {
379
+ into.paths.push(...refs.paths);
380
+ into.envVars.push(...refs.envVars);
381
+ into.dumpsEnvironment = into.dumpsEnvironment || refs.dumpsEnvironment;
382
+ into.searchesWorkingDirectory =
383
+ into.searchesWorkingDirectory || refs.searchesWorkingDirectory;
384
+ }
385
+ // Single place where a command name becomes a behaviour, so a name added to one
386
+ // list cannot silently disagree with another.
387
+ //
388
+ // Not every per-command decision belongs here, and two deliberately stay out.
389
+ // isClassifiableCommand below reads this record generically — any truthy field
390
+ // means "operands understood" — which is what lets a new field be added without
391
+ // updating it, and also what a field has to respect to live here:
392
+ //
393
+ // - `WRITE_TARGET_FLAGS[cmd]` would arrive as a Set for the three commands
394
+ // that have one, and a Set is truthy whatever it holds. That would make
395
+ // `sort`, `shuf` and `iconv` classifiable on the strength of having an
396
+ // output flag, which says nothing about whether their operands are
397
+ // understood. (It is `undefined` for every other command, so the reach is
398
+ // those three, not all of them.)
399
+ // - `cmd === "env"` for the `-S` string would make `env` classifiable, and
400
+ // `env` is left out on purpose: it is a wrapper as often as a command.
401
+ //
402
+ // Both are therefore read from their tables at the point of use rather than
403
+ // folded in here. Anything added to this record must be a boolean that means
404
+ // "this hook understands what the command does with its operands".
405
+ export function classifyCommand(cmd) {
406
+ return {
407
+ printsOperands: FILE_READ_COMMANDS.has(cmd),
408
+ firstOperandIsPatternOrScript: PATTERN_OR_SCRIPT_FIRST_COMMANDS.has(cmd),
409
+ takesInlineCode: INLINE_CODE_COMMANDS.has(cmd),
410
+ inlineCodeReadsOperands: INLINE_CODE_READS_OPERANDS.has(cmd),
411
+ printsNoFileContents: COUNT_ONLY_COMMANDS.has(cmd),
412
+ isGit: cmd === "git",
413
+ isDd: cmd === "dd",
414
+ };
415
+ }
416
+ // Commands whose operands this hook knows how to interpret. Derived from
417
+ // classifyCommand so the two cannot silently disagree. `env` and `printenv`
418
+ // are absent on purpose: they are wrappers as often as they are commands, and
419
+ // inspectEnvironmentCommand handles the cases where they print the environment.
420
+ export function isClassifiableCommand(name) {
421
+ const behaviour = classifyCommand(name);
422
+ // Any behaviour at all means the operands are understood. Read from the object
423
+ // rather than listed field by field, so adding a field cannot leave this
424
+ // function silently disagreeing with classifyCommand.
425
+ return Object.values(behaviour).some(Boolean);
426
+ }
427
+ // Commands a wrapper may hand off to. Beyond the classifiable ones, `env` and
428
+ // `printenv` count here — and only here — so wrapper operands do not hide an
429
+ // environment dump: `sudo -u root printenv` must still find printenv.
430
+ export function isWrapperTarget(name) {
431
+ return isClassifiableCommand(name) || name === "env" || name === "printenv";
432
+ }
433
+ // Commands that write their operands out without ever opening them.
434
+ //
435
+ // These matter only to the wrapper search below, as the point where it has to
436
+ // stop: everything after one of them is its own argument list. Without that
437
+ // stop, `sudo echo cat secrets` walked past `echo` looking for a name it could
438
+ // classify, found `cat` among echo's arguments, and blocked a command that
439
+ // reads nothing.
440
+ //
441
+ // The set is deliberately short and cannot be complete — any command this hook
442
+ // does not classify might be the one a wrapper handed off to, and the search
443
+ // still walks past those. That is the direction to be wrong in: it collects
444
+ // paths that are not read rather than missing a read, and `sudo -u root cat f`
445
+ // depends on it, because `root` is not distinguishable from a command name.
446
+ //
447
+ // Treat this as a stopgap rather than a list to grow. Each name added buys one
448
+ // more false positive and leaves the general case untouched, and a list that
449
+ // accumulates one report at a time is the shape of a rule nobody wrote down. The
450
+ // real fix is to model each wrapper's own arguments — the flags that take a
451
+ // value, and the leading operand of `timeout` and `flock` — so the command
452
+ // position is determinate and no allowlist is needed. That was not done here
453
+ // because an incomplete table of those flags fails the other way, missing reads
454
+ // instead of over-reporting them. If a third false positive of this shape turns
455
+ // up, do that instead of adding a sixth name.
456
+ export const ARGUMENT_ONLY_COMMANDS = new Set([
457
+ "echo",
458
+ "printf",
459
+ "true",
460
+ "false",
461
+ ":",
462
+ ]);