@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,405 @@
1
+ // What this hook knows about how each command treats its operands.
2
+ //
3
+ // The tables are the knowledge: which commands print a file they are handed,
4
+ // which take a pattern first, which run another command. extractCommandRefs
5
+ // turns a command line into the files it may print and the environment it may
6
+ // expose, leaving the parsing to shell.ts.
7
+ import path from "node:path";
8
+ import { ARGUMENT_ONLY_COMMANDS, asksForRecursion, classifyCommand, editsInPlace, GIT_GLOBAL_FLAGS_WITH_OPERAND, GREP_FAMILY, gitLineRangeFile, gitSubcommandPrintsFiles, isWrapperTarget, MAX_NESTING_DEPTH, mergeRefs, POSIX_SHELLS, patternSupplyingFlag, RECURSIVE_BY_DEFAULT, WRAPPER_COMMANDS, WRITE_TARGET_FLAGS, } from "./command-tables.js";
9
+ import { extractQuotedLiterals, extractSubstitutions, isNonCommandToken, stripHeredocBodies, tokenizeCommand, } from "./shell.js";
10
+ // Index of the token naming the command whose operands matter.
11
+ //
12
+ // The lead command is the first token that is not a flag, redirection or
13
+ // `VAR=value` assignment. Only a known wrapper (`sudo`, `env`, `timeout`, …)
14
+ // is peeled, by searching the rest of the segment for the first token this
15
+ // hook can classify (`env` and `printenv` included, so their detection behind
16
+ // wrapper operands works); anything else is treated as the command itself. Searching
17
+ // unconditionally mistook operands for commands: `echo cat secrets` resolved
18
+ // to `cat`, and a file that was never read was scanned and blocked. Wrappers
19
+ // are still not peeled by counting their flags: `sudo -u root cat f` would
20
+ // mistake `root` for the command, and `timeout -s KILL 5 cat f` would
21
+ // mistake `5`. Falling back to the lead leaves an unknown command classified
22
+ // as itself.
23
+ //
24
+ // The search past a wrapper carries the same hazard one step further in, which
25
+ // is why it stops at an ARGUMENT_ONLY_COMMANDS name: `sudo echo cat secrets`
26
+ // otherwise resolved to the `cat` sitting in echo's arguments. Past any other
27
+ // unclassifiable name the search continues, since that name may be a wrapper
28
+ // flag's value rather than the command.
29
+ function findCommandIndex(tokens) {
30
+ let lead = -1;
31
+ // A redirection may come before the command — `< secrets cat` is `cat` reading
32
+ // `secrets`. The operator is skipped as a non-command token, but its target is
33
+ // an ordinary word, so `secrets` was taken for the command name and the real
34
+ // one went unclassified: nothing of its operands was collected, and a spelling
35
+ // away from `cat < secrets`, which blocks.
36
+ let skipRedirectTarget = false;
37
+ for (let i = 0; i < tokens.length; i++) {
38
+ const token = tokens[i];
39
+ if (token === undefined)
40
+ continue;
41
+ if (skipRedirectTarget) {
42
+ skipRedirectTarget = false;
43
+ continue;
44
+ }
45
+ if (token.redirect) {
46
+ skipRedirectTarget = true;
47
+ continue;
48
+ }
49
+ if (isNonCommandToken(token))
50
+ continue;
51
+ lead = i;
52
+ break;
53
+ }
54
+ if (lead === -1)
55
+ return 0;
56
+ const leadName = path.basename(tokens[lead]?.value ?? "");
57
+ if (!WRAPPER_COMMANDS.has(leadName))
58
+ return lead;
59
+ for (let i = lead + 1; i < tokens.length; i++) {
60
+ const tok = tokens[i];
61
+ if (tok === undefined || isNonCommandToken(tok))
62
+ continue;
63
+ const name = path.basename(tok.value);
64
+ if (isWrapperTarget(name) || ARGUMENT_ONLY_COMMANDS.has(name))
65
+ return i;
66
+ }
67
+ return lead;
68
+ }
69
+ // `env` and `printenv` print the environment unless they are being used to run
70
+ // another command. `sudo printenv` counts; `env FOO=1 cat f` does not. The
71
+ // command is located with findCommandIndex, so wrapper flags and operands
72
+ // (`sudo -u root printenv`, `timeout 5 env`) cannot hide it.
73
+ function inspectEnvironmentCommand(tokens) {
74
+ const nothing = { dumps: false, named: [] };
75
+ const start = findCommandIndex(tokens);
76
+ const cmdToken = tokens[start];
77
+ if (cmdToken === undefined)
78
+ return nothing;
79
+ const name = path.basename(cmdToken.value);
80
+ if (name !== "env" && name !== "printenv")
81
+ return nothing;
82
+ // `printenv` prints the whole environment unless it is given variables to
83
+ // print. A redirection target is not one of them: `printenv > out.txt` prints
84
+ // everything, and counting `out.txt` as a named variable left the environment
85
+ // unscanned.
86
+ if (name === "printenv") {
87
+ const rest = tokens.slice(start + 1);
88
+ const named = [];
89
+ for (let j = 0; j < rest.length; j++) {
90
+ const t = rest[j];
91
+ if (t === undefined)
92
+ continue;
93
+ if (t.redirect) {
94
+ j++; // its target, or a heredoc delimiter
95
+ continue;
96
+ }
97
+ if (isNonCommandToken(t))
98
+ continue; // flags
99
+ named.push(t.value);
100
+ }
101
+ return named.length === 0
102
+ ? { dumps: true, named: [] }
103
+ : { dumps: false, named };
104
+ }
105
+ // `env` prints the environment unless a subcommand follows its own
106
+ // arguments: assignments (`FOO=1`), flags, and the values of flags that
107
+ // take one (`-u FOO`, `-C dir`) are all env's own. The `-S` split string
108
+ // is the subcommand itself (`env -S "cat f"` runs cat), so it rules a dump
109
+ // out. With no subcommand the whole environment is printed — `env FOO=1`
110
+ // and `env -u FOO` included. Exception: `-i` starts from an empty
111
+ // environment, so only the given assignments (already scanned as command
112
+ // text) print.
113
+ const rest = tokens.slice(start + 1);
114
+ let ignoreEnvironment = false;
115
+ let hasCommand = false;
116
+ for (let j = 0; j < rest.length; j++) {
117
+ const t = rest[j];
118
+ if (t === undefined)
119
+ continue;
120
+ const value = t.value;
121
+ if (value === "-i" || value === "--ignore-environment") {
122
+ ignoreEnvironment = true;
123
+ continue;
124
+ }
125
+ if (t.redirect) {
126
+ j++; // redirection operator: its target is env's own argument here
127
+ continue;
128
+ }
129
+ if (value === "-u" ||
130
+ value === "--unset" ||
131
+ value === "-C" ||
132
+ value === "--chdir") {
133
+ j++; // flag value
134
+ continue;
135
+ }
136
+ if (value === "-S" || value === "--split-string") {
137
+ hasCommand = true; // the split string is the subcommand
138
+ break;
139
+ }
140
+ if (isNonCommandToken(t))
141
+ continue; // flags and FOO=1 assignments
142
+ hasCommand = true;
143
+ break;
144
+ }
145
+ return { dumps: !hasCommand && !ignoreEnvironment, named: [] };
146
+ }
147
+ // True when `token` introduces inline program text for `cmd`. POSIX shells only
148
+ // take code after -c; other interpreters also use -e and combined forms (-pe).
149
+ function isInlineCodeFlag(cmd, token) {
150
+ if (token === "-c" || token === "--command" || token === "--eval")
151
+ return true;
152
+ if (POSIX_SHELLS.has(cmd)) {
153
+ // A shell bundles its switches too: `bash -lc 'cat secrets'` runs the same
154
+ // string `bash -c` would, and only the exact spelling was recognised. The
155
+ // letters before the `c` have to be switches that take no value of their
156
+ // own, or the `c` is part of something else's value.
157
+ return /^-[abefhilmnpuvxCPT]*c$/.test(token);
158
+ }
159
+ if (token === "-r")
160
+ return true; // php -r
161
+ return /^-[A-Za-z]*[eE]$/.test(token);
162
+ }
163
+ // Everything a Bash command reveals that the hook can inspect before it runs:
164
+ // the files whose contents it may print, and the environment it may expose.
165
+ export function extractCommandRefs(command, depth = 0) {
166
+ const refs = {
167
+ paths: [],
168
+ envVars: [],
169
+ dumpsEnvironment: false,
170
+ searchesWorkingDirectory: false,
171
+ };
172
+ if (depth > MAX_NESTING_DEPTH)
173
+ return refs;
174
+ // Heredoc bodies are text, not commands; strip them before any other pass.
175
+ const text = stripHeredocBodies(command);
176
+ // `echo $(cat secrets)` reads secrets just as `cat secrets` does.
177
+ for (const inner of extractSubstitutions(text)) {
178
+ mergeRefs(refs, extractCommandRefs(inner, depth + 1));
179
+ }
180
+ for (const tokens of tokenizeCommand(text)) {
181
+ const environment = inspectEnvironmentCommand(tokens);
182
+ if (environment.dumps)
183
+ refs.dumpsEnvironment = true;
184
+ refs.envVars.push(...environment.named);
185
+ mergeRefs(refs, collectSegmentRefs(tokens, depth));
186
+ }
187
+ return {
188
+ paths: [...new Set(refs.paths)],
189
+ envVars: [...new Set(refs.envVars)],
190
+ dumpsEnvironment: refs.dumpsEnvironment,
191
+ searchesWorkingDirectory: refs.searchesWorkingDirectory,
192
+ };
193
+ }
194
+ // File paths one segment of a command line may print, plus anything found inside
195
+ // inline program text it carries.
196
+ function collectSegmentRefs(tokens, depth) {
197
+ const refs = {
198
+ paths: [],
199
+ envVars: [],
200
+ dumpsEnvironment: false,
201
+ searchesWorkingDirectory: false,
202
+ };
203
+ const start = findCommandIndex(tokens);
204
+ const cmdToken = tokens[start];
205
+ if (cmdToken === undefined)
206
+ return refs;
207
+ const cmd = path.basename(cmdToken.value);
208
+ const operands = tokens.slice(start + 1);
209
+ // `< secrets cat` puts the redirection before the command, so its target is
210
+ // not among the operands and the loop below never sees it. The command still
211
+ // reads it.
212
+ //
213
+ // `$(<secrets)` has no command at all: bash reads the file and substitutes its
214
+ // contents. There the whole token list is in front of the "command", which is
215
+ // the redirection operator itself.
216
+ const beforeCommand = cmdToken.redirect ? tokens.length : start;
217
+ for (let i = 0; i < beforeCommand; i++) {
218
+ if (tokens[i]?.redirect !== true)
219
+ continue;
220
+ if (tokens[i]?.value !== "<")
221
+ continue;
222
+ const target = tokens[i + 1];
223
+ if (target === undefined || target.redirect)
224
+ continue;
225
+ if (!classifyCommand(cmd).printsNoFileContents) {
226
+ refs.paths.push(target.value);
227
+ }
228
+ }
229
+ // In-place editing sends the result back to the file, so nothing reaches
230
+ // stdout and nothing is read into the conversation.
231
+ if (editsInPlace(cmd, operands)) {
232
+ return refs;
233
+ }
234
+ // `eval 'cat secrets'` is a command line in a single word, the same shape
235
+ // `env -S` carries. Stepping past `eval` finds that word as the command name,
236
+ // which classifies as nothing at all.
237
+ if (cmd === "eval") {
238
+ for (const operand of operands) {
239
+ if (operand.redirect)
240
+ continue;
241
+ mergeRefs(refs, extractCommandRefs(operand.value, depth + 1));
242
+ refs.paths.push(...extractQuotedLiterals(operand.value));
243
+ }
244
+ }
245
+ // `env -S "cmd args"` splits the string into the command it runs, so scan
246
+ // inside it the way inline code is scanned.
247
+ if (cmd === "env") {
248
+ for (let k = 0; k < operands.length; k++) {
249
+ const t = operands[k]?.value;
250
+ if (t !== "-S" && t !== "--split-string")
251
+ continue;
252
+ const script = operands[k + 1]?.value;
253
+ if (script === undefined)
254
+ continue;
255
+ mergeRefs(refs, extractCommandRefs(script, depth + 1));
256
+ k++;
257
+ }
258
+ }
259
+ const behaviour = classifyCommand(cmd);
260
+ let skipNext = false;
261
+ let collectNext = false;
262
+ let codeNext = false;
263
+ let inlineCodeSeen = false;
264
+ let patternSkipped = false;
265
+ let gitSubcommandSeen = false;
266
+ let gitReadsFiles = false;
267
+ let optionsEnded = false;
268
+ let lineRangeNext = false;
269
+ for (const tok of operands) {
270
+ if (skipNext) {
271
+ skipNext = false;
272
+ continue;
273
+ }
274
+ if (collectNext) {
275
+ collectNext = false;
276
+ refs.paths.push(tok.value);
277
+ continue;
278
+ }
279
+ if (codeNext) {
280
+ codeNext = false;
281
+ // The expression came from -e/-c, so a later operand is a file, not the
282
+ // script `perl file` would have run. That is `inlineCodeSeen` below: no
283
+ // command takes inline code *and* a leading pattern, an assumption the
284
+ // tests pin, so there is no pattern here to mark as supplied.
285
+ if (behaviour.inlineCodeReadsOperands)
286
+ inlineCodeSeen = true;
287
+ mergeRefs(refs, extractCommandRefs(tok.value, depth + 1));
288
+ refs.paths.push(...extractQuotedLiterals(tok.value));
289
+ continue;
290
+ }
291
+ if (tok.redirect) {
292
+ if (tok.value === "<") {
293
+ // stdin is fed from the next token, unless nothing of it is printed
294
+ collectNext = !behaviour.printsNoFileContents;
295
+ skipNext = behaviour.printsNoFileContents;
296
+ }
297
+ else {
298
+ // A heredoc or herestring delimiter, or an output target: never read.
299
+ skipNext = true;
300
+ }
301
+ continue;
302
+ }
303
+ // `--` ends option parsing: every token after it is an operand, whatever it
304
+ // is spelled like. `grep -- -aws secrets` searches for `-aws` in `secrets`,
305
+ // so the file is a file — read as a flag, `-aws` left the pattern
306
+ // unaccounted for and `secrets` was consumed in its place.
307
+ if (!optionsEnded && tok.value === "--") {
308
+ optionsEnded = true;
309
+ continue;
310
+ }
311
+ if (!optionsEnded &&
312
+ behaviour.takesInlineCode &&
313
+ isInlineCodeFlag(cmd, tok.value)) {
314
+ codeNext = true;
315
+ continue;
316
+ }
317
+ // `git log -L1,10:f` prints the lines of `f` themselves, and the file is
318
+ // written inside the range spec after the last `:`. The flag branch below
319
+ // consumes any `-`-shaped token, so this has to come before it, and the
320
+ // operand branch would never see the file anyway.
321
+ if (behaviour.isGit &&
322
+ (tok.value.startsWith("-L") || tok.value.startsWith("--line-range"))) {
323
+ const inFlag = gitLineRangeFile(tok.value);
324
+ if (inFlag !== null)
325
+ refs.paths.push(inFlag);
326
+ else
327
+ lineRangeNext = true;
328
+ continue;
329
+ }
330
+ if (lineRangeNext) {
331
+ lineRangeNext = false;
332
+ const separate = gitLineRangeFile(tok.value);
333
+ if (separate !== null)
334
+ refs.paths.push(separate);
335
+ continue;
336
+ }
337
+ if (!optionsEnded && tok.value.startsWith("-")) {
338
+ // A global git flag with a separate value consumes the next token too:
339
+ // in `git -C repo show f`, `repo` is not the subcommand.
340
+ if (behaviour.isGit &&
341
+ !gitSubcommandSeen &&
342
+ GIT_GLOBAL_FLAGS_WITH_OPERAND.has(tok.value)) {
343
+ skipNext = true;
344
+ }
345
+ // The value of an output flag is written, not read.
346
+ if (WRITE_TARGET_FLAGS[cmd]?.has(tok.value))
347
+ skipNext = true;
348
+ // The pattern arrived as a flag, so no operand stands in for it.
349
+ if (behaviour.firstOperandIsPatternOrScript) {
350
+ const supply = patternSupplyingFlag(tok.value);
351
+ if (supply !== null) {
352
+ patternSkipped = true;
353
+ // Only a flag still waiting for its value consumes the next token. A
354
+ // value already attached — `--regexp=aws`, or `-eaws` — is part of
355
+ // this one.
356
+ if (supply === "separate")
357
+ skipNext = true;
358
+ }
359
+ }
360
+ continue;
361
+ }
362
+ if (behaviour.isGit) {
363
+ if (!gitSubcommandSeen) {
364
+ gitSubcommandSeen = true;
365
+ gitReadsFiles = gitSubcommandPrintsFiles(tok.value, operands);
366
+ continue;
367
+ }
368
+ if (gitReadsFiles)
369
+ refs.paths.push(tok.value);
370
+ continue;
371
+ }
372
+ // `if=<file>` names an input only for `dd`; other commands taking an
373
+ // `if=` argument are not reading the file it names.
374
+ if (behaviour.isDd) {
375
+ const ddInput = /^if=(.+)$/.exec(tok.value);
376
+ if (ddInput?.[1]) {
377
+ refs.paths.push(ddInput[1]);
378
+ continue;
379
+ }
380
+ }
381
+ if (behaviour.firstOperandIsPatternOrScript && !patternSkipped) {
382
+ patternSkipped = true; // the pattern, expression or script name
383
+ // An awk or sed program can name a file inside itself, the way inline code
384
+ // does: `awk 'BEGIN{ while ((getline l < "secrets") > 0) print l }'` reads
385
+ // one without ever naming it as an operand.
386
+ refs.paths.push(...extractQuotedLiterals(tok.value));
387
+ continue;
388
+ }
389
+ if (behaviour.printsOperands ||
390
+ behaviour.firstOperandIsPatternOrScript ||
391
+ inlineCodeSeen) {
392
+ refs.paths.push(tok.value);
393
+ }
394
+ }
395
+ // A searcher handed no file searches where it is run. `rg PATTERN` and
396
+ // `grep -r PATTERN` are the ordinary forms and name nothing, so the loop above
397
+ // collects the pattern and stops with no path — and the tree they print from
398
+ // is the working directory. The caller is what knows which directory that is.
399
+ if (refs.paths.length === 0 &&
400
+ (RECURSIVE_BY_DEFAULT.has(cmd) ||
401
+ (GREP_FAMILY.has(cmd) && asksForRecursion(operands)))) {
402
+ refs.searchesWorkingDirectory = true;
403
+ }
404
+ return refs;
405
+ }