@coo-quack/sensitive-canary 0.6.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 +830 -0
  3. package/README.md +269 -43
  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 +570 -0
  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 +482 -267
  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 -281
  37. package/src/lib/__tests__/rules.test.ts +0 -448
@@ -0,0 +1,436 @@
1
+ // Shell syntax, and nothing about what any particular command means.
2
+ //
3
+ // Splitting a command line into segments and tokens, quote removal, heredoc
4
+ // bodies, and the substitutions whose inner text is a command line of its own.
5
+ // Everything here answers "what are the pieces of this command line". What a
6
+ // piece does with its operands is bash-commands.ts.
7
+ // The quoting rule, for the scanners that only need to step over it: a quote
8
+ // character opens a run and its twin closes it, and a backslash escapes the next
9
+ // character everywhere except inside single quotes, where it is literal.
10
+ //
11
+ // Three of them spelled that out for themselves — as `quote === '"' && ch ===
12
+ // "\\"`, as `ch === "\\" && quote !== "'"`, and as `i += quote === "'" ? 1 : 2`.
13
+ // They agreed, which is the point: three spellings of one rule agree until
14
+ // someone corrects one of them.
15
+ //
16
+ // `tokenizeCommand` is not one of the three and keeps its own reading, because
17
+ // it does not step over a quoted run — it builds the token's text out of it,
18
+ // dropping the quotes, decoding `$'…'` escapes and keeping a backslash literal
19
+ // inside single quotes. Returning a position cannot say what the text became.
20
+ function stepQuote(s, i, quote) {
21
+ const ch = s[i];
22
+ if (ch === "\\" && quote !== "'") {
23
+ return { next: i + 2, quote, consumed: true };
24
+ }
25
+ if (quote !== null) {
26
+ return ch === quote
27
+ ? { next: i + 1, quote: null, consumed: true }
28
+ : { next: i + 1, quote, consumed: false };
29
+ }
30
+ if (ch === "'" || ch === '"') {
31
+ return { next: i + 1, quote: ch, consumed: true };
32
+ }
33
+ return { next: i + 1, quote: null, consumed: false };
34
+ }
35
+ // Variable names referenced by the command, including expansion forms that carry
36
+ // a suffix such as `${TOKEN:-fallback}` or `${TOKEN#prefix}`.
37
+ //
38
+ // Every `$` that a name follows counts, rather than each expansion being matched
39
+ // whole. Matching `${NAME…}` as a unit meant the suffix was consumed by the
40
+ // pattern that skipped to the closing brace, and a name inside the suffix went
41
+ // with it: `${A:-$TOKEN}` prints `$TOKEN` when `A` is unset, and named only `A`.
42
+ // An unclosed `${NAME` now yields its name too, which errs towards scanning.
43
+ export function extractEnvVarNames(command) {
44
+ const names = new Set();
45
+ for (const match of command.matchAll(/\$\{?([A-Za-z_][A-Za-z0-9_]*)/g)) {
46
+ const name = match[1];
47
+ if (name)
48
+ names.add(name);
49
+ }
50
+ return [...names];
51
+ }
52
+ // Split a command line into segments (at |, ;, &, &&, || and newlines) and each
53
+ // segment into tokens with quotes removed. Redirection operators become tokens of
54
+ // their own so that `wc -l <f` and `wc -l < f` tokenize alike. Substitutions are
55
+ // left in place; extractSubstitutions handles them against the raw string.
56
+ export function tokenizeCommand(command) {
57
+ const segments = [];
58
+ let tokens = [];
59
+ let current = "";
60
+ let hasCurrent = false;
61
+ let i = 0;
62
+ const endToken = () => {
63
+ if (hasCurrent) {
64
+ tokens.push({ value: current, redirect: false });
65
+ current = "";
66
+ hasCurrent = false;
67
+ }
68
+ };
69
+ const endSegment = () => {
70
+ endToken();
71
+ if (tokens.length > 0)
72
+ segments.push(tokens);
73
+ tokens = [];
74
+ };
75
+ while (i < command.length) {
76
+ const ch = command[i];
77
+ if (ch === "\\") {
78
+ const next = command[i + 1];
79
+ if (next !== undefined && next !== "\n") {
80
+ current += next;
81
+ hasCurrent = true;
82
+ }
83
+ i += next === undefined ? 1 : 2;
84
+ continue;
85
+ }
86
+ if (ch === "'" ||
87
+ ch === '"' ||
88
+ (ch === "$" && (command[i + 1] === "'" || command[i + 1] === '"'))) {
89
+ // $'...' (ANSI-C) and $"..." (locale) are quoting syntax: the `$` is not
90
+ // part of the token. Inside $'...', backslash escapes are decoded.
91
+ let quote = ch;
92
+ let ansiC = false;
93
+ if (ch === "$") {
94
+ quote = command[i + 1];
95
+ ansiC = quote === "'";
96
+ i += 2;
97
+ }
98
+ else {
99
+ i++;
100
+ }
101
+ hasCurrent = true;
102
+ while (i < command.length && command[i] !== quote) {
103
+ if (command[i] === "\\" && command[i + 1] !== undefined) {
104
+ if (quote === '"' || ansiC) {
105
+ current += ansiC
106
+ ? decodeAnsiCEscape(command, i)
107
+ : command[i + 1];
108
+ i += ansiC ? ansiCEscapeLength(command, i) : 2;
109
+ continue;
110
+ }
111
+ // plain single quotes keep backslashes literal
112
+ }
113
+ current += command[i];
114
+ i++;
115
+ }
116
+ i++; // closing quote, or end of input for an unbalanced one
117
+ continue;
118
+ }
119
+ if (ch === "|" || ch === ";" || ch === "&" || ch === "\n") {
120
+ endSegment();
121
+ while (i < command.length && /[|;&\n\s]/.test(command[i]))
122
+ i++;
123
+ continue;
124
+ }
125
+ // A substitution standing among the operands is one word to the command, so
126
+ // it is consumed whole and no token is emitted for it. Ending the segment
127
+ // here instead would cut the operand list in two: `cat <(echo hi) secrets`
128
+ // leaves `secrets` in a segment of its own, where it reads as a command name
129
+ // and its own reading goes unseen.
130
+ //
131
+ // The inner command is still reached: extractSubstitutions walks the raw
132
+ // string for these forms, and the paths are deduplicated.
133
+ if ((ch === "$" || ch === "<" || ch === ">") && command[i + 1] === "(") {
134
+ endToken();
135
+ i = findSubstitutionEnd(command, i + 2, ")") + 1;
136
+ continue;
137
+ }
138
+ // A subshell holds a command line of its own. Without this, `(cat secrets)`
139
+ // tokenized as `(cat` and `secrets)`, naming neither a command this hook
140
+ // classifies nor a path that exists, and the read went unseen.
141
+ if (ch === "(" || ch === ")") {
142
+ endSegment();
143
+ i++;
144
+ continue;
145
+ }
146
+ if (ch === "<" || ch === ">") {
147
+ // A file-descriptor prefix belongs to the operator, not to a token of its
148
+ // own, so `cmd 2>err` tokenizes like `cmd >err`. Left as a token, the `2`
149
+ // would read as an operand of the command — a filename, or a subcommand
150
+ // for whatever later decides what a command does with its operands. It
151
+ // names neither, so it is dropped. Only digits written against the
152
+ // operator count, leaving `sort 1 >out` alone.
153
+ if (hasCurrent && /^\d+$/.test(current)) {
154
+ current = "";
155
+ hasCurrent = false;
156
+ }
157
+ endToken();
158
+ let op = ch;
159
+ i++;
160
+ while (i < command.length && command[i] === ch) {
161
+ op += ch;
162
+ i++;
163
+ }
164
+ tokens.push({ value: op, redirect: true });
165
+ continue;
166
+ }
167
+ if (ch === " " || ch === "\t" || ch === "\r") {
168
+ endToken();
169
+ i++;
170
+ continue;
171
+ }
172
+ current += ch;
173
+ hasCurrent = true;
174
+ i++;
175
+ }
176
+ endSegment();
177
+ return segments;
178
+ }
179
+ // Length of the ANSI-C escape starting at `command[i]` (a backslash), so the
180
+ // tokenizer can skip the whole sequence: \xHH is 4 chars, anything else is 2.
181
+ function ansiCEscapeLength(command, i) {
182
+ return command[i + 1] === "x" &&
183
+ /^[0-9A-Fa-f]{2}$/.test(command.slice(i + 2, i + 4))
184
+ ? 4
185
+ : 2;
186
+ }
187
+ // Decode the ANSI-C escape starting at `command[i]` (a backslash). Covers the
188
+ // escapes that appear in paths: \\, \', \", \xHH and the common letter escapes.
189
+ function decodeAnsiCEscape(command, i) {
190
+ const esc = command[i + 1];
191
+ if (esc === "x" && /^[0-9A-Fa-f]{2}$/.test(command.slice(i + 2, i + 4))) {
192
+ return String.fromCharCode(Number.parseInt(command.slice(i + 2, i + 4), 16));
193
+ }
194
+ const simple = {
195
+ "\\": "\\",
196
+ "'": "'",
197
+ '"': '"',
198
+ n: "\n",
199
+ t: "\t",
200
+ r: "\r",
201
+ "0": "\0",
202
+ };
203
+ return simple[esc] ?? esc;
204
+ }
205
+ // The delimiter word starting at `line[from]`, with quote removal applied the way
206
+ // the shell does it: `<<EOF`, `<<'EOF'`, `<<"EOF"` and `<<E"O"F` all end their
207
+ // body at the line `EOF`. The word ends at whitespace or a shell metacharacter.
208
+ //
209
+ // A narrower character class (`[A-Za-z0-9_.]`) cuts the word short, and a
210
+ // truncated delimiter never matches the real closing line: stripHeredocBodies
211
+ // then swallows the rest of the command, so `cat > f <<EOF-1 … EOF-1` followed
212
+ // by `cat .env` hides the read entirely.
213
+ function readHeredocDelimiter(line, from) {
214
+ let delim = "";
215
+ let i = from;
216
+ while (i < line.length) {
217
+ const ch = line[i];
218
+ if (ch === "'" || ch === '"') {
219
+ i++;
220
+ while (i < line.length && line[i] !== ch) {
221
+ if (ch === '"' && line[i] === "\\" && line[i + 1] !== undefined) {
222
+ delim += line[i + 1];
223
+ i += 2;
224
+ continue;
225
+ }
226
+ delim += line[i];
227
+ i++;
228
+ }
229
+ i++; // closing quote, or end of line for an unbalanced one
230
+ continue;
231
+ }
232
+ if (ch === "\\" && line[i + 1] !== undefined) {
233
+ delim += line[i + 1];
234
+ i += 2;
235
+ continue;
236
+ }
237
+ if (/[\s|&;()<>`]/.test(ch))
238
+ break;
239
+ delim += ch;
240
+ i++;
241
+ }
242
+ return { delim, next: i };
243
+ }
244
+ // Heredoc delimiters introduced by one command line, in order. `<<-` allows a
245
+ // tab-indented closing delimiter; `<<<` is a herestring and is not a heredoc.
246
+ // Matches outside quotes only, so `echo "a <<EOF b"` is not a heredoc start.
247
+ function findHeredocDelimiters(line) {
248
+ const found = [];
249
+ let quote = null;
250
+ let i = 0;
251
+ while (i < line.length) {
252
+ const step = stepQuote(line, i, quote);
253
+ quote = step.quote;
254
+ if (step.consumed || quote !== null) {
255
+ i = step.next;
256
+ continue;
257
+ }
258
+ const ch = line[i];
259
+ if (ch === "<" && line[i + 1] === "<") {
260
+ let j = i + 2;
261
+ let allowTabs = false;
262
+ if (line[j] === "-") {
263
+ allowTabs = true;
264
+ j++;
265
+ }
266
+ if (line[j] === "<") {
267
+ i = j; // herestring
268
+ continue;
269
+ }
270
+ while (line[j] === " " || line[j] === "\t")
271
+ j++;
272
+ const { delim, next } = readHeredocDelimiter(line, j);
273
+ if (delim)
274
+ found.push({ delim, allowTabs });
275
+ i = next;
276
+ continue;
277
+ }
278
+ i++;
279
+ }
280
+ return found;
281
+ }
282
+ // Remove heredoc bodies from a command line. A body is text, not commands —
283
+ // `cat > deploy.sh <<EOF` followed by a script that mentions `.env` reads
284
+ // nothing, and scanning the body as shell blocks exactly that everyday case.
285
+ // The trade-off: a heredoc that *feeds* commands to a remote shell
286
+ // (`ssh host <<EOF\ncat /secret\nEOF`) is not caught. Written up as a
287
+ // known limitation under "② PreToolUse hook" in the README.
288
+ export function stripHeredocBodies(command) {
289
+ const lines = command.split("\n");
290
+ const kept = [];
291
+ const pending = [];
292
+ for (const line of lines) {
293
+ if (pending.length > 0) {
294
+ const first = pending[0];
295
+ const cmp = first.allowTabs ? line.replace(/^\t+/, "") : line;
296
+ if (cmp === first.delim)
297
+ pending.shift();
298
+ continue;
299
+ }
300
+ pending.push(...findHeredocDelimiters(line));
301
+ kept.push(line);
302
+ }
303
+ return kept.join("\n");
304
+ }
305
+ // Substitution syntaxes whose inner text is a command line in its own right.
306
+ // Command substitution and backticks expand inside double quotes; the process
307
+ // substitutions do not, so `echo "<(cat f)"` is a literal string.
308
+ const SUBSTITUTIONS = [
309
+ { open: "$(", close: ")", expandsInDoubleQuotes: true },
310
+ { open: "<(", close: ")", expandsInDoubleQuotes: false },
311
+ { open: ">(", close: ")", expandsInDoubleQuotes: false },
312
+ { open: "`", close: "`", expandsInDoubleQuotes: true },
313
+ ];
314
+ // Index of the character closing a substitution whose body starts at `from`.
315
+ // Parentheses are counted rather than matched with a regex, because a body
316
+ // carries parentheses of its own: `$(python3 -c "print(open('.env').read())")`
317
+ // was cut short at the first `)` by the old `[^()]*` pattern, and the read it
318
+ // contained was never scanned. Quotes and backslashes inside the body are
319
+ // respected. An unbalanced substitution runs to the end of the string.
320
+ function findSubstitutionEnd(command, from, close) {
321
+ let depth = 0;
322
+ let quote = null;
323
+ let i = from;
324
+ while (i < command.length) {
325
+ const step = stepQuote(command, i, quote);
326
+ quote = step.quote;
327
+ if (step.consumed || quote !== null) {
328
+ i = step.next;
329
+ continue;
330
+ }
331
+ const ch = command[i];
332
+ const at = i;
333
+ i = step.next;
334
+ if (close === "`") {
335
+ if (ch === "`")
336
+ return at;
337
+ continue;
338
+ }
339
+ if (ch === "(")
340
+ depth++;
341
+ else if (ch === ")") {
342
+ if (depth === 0)
343
+ return at;
344
+ depth--;
345
+ }
346
+ }
347
+ return command.length;
348
+ }
349
+ // Inner text of every outermost command substitution, process substitution and
350
+ // backtick expression. Only the outermost ones: each is a command line in its
351
+ // own right, so a nested substitution is reached by the caller feeding what this
352
+ // returns back through it.
353
+ export function extractSubstitutions(command) {
354
+ const found = [];
355
+ let quote = null;
356
+ let i = 0;
357
+ // Unlike the scanners above, this one has to look inside double quotes: a
358
+ // command substitution expands there. So it skips only what `stepQuote` calls
359
+ // consumed, and asks the quote state whether an opener counts where it stands.
360
+ while (i < command.length) {
361
+ const step = stepQuote(command, i, quote);
362
+ quote = step.quote;
363
+ if (step.consumed) {
364
+ i = step.next;
365
+ continue;
366
+ }
367
+ const opener = SUBSTITUTIONS.find((s) => command.startsWith(s.open, i) &&
368
+ (quote === null || (quote === '"' && s.expandsInDoubleQuotes)));
369
+ if (opener === undefined) {
370
+ i = step.next;
371
+ continue;
372
+ }
373
+ const bodyStart = i + opener.open.length;
374
+ const end = findSubstitutionEnd(command, bodyStart, opener.close);
375
+ found.push(command.slice(bodyStart, end));
376
+ i = end + 1;
377
+ }
378
+ return found;
379
+ }
380
+ // Shell keywords and the brace-group delimiters. They stand where a command
381
+ // name would, so without this list a segment led by one is classified as a
382
+ // command called `{` or `then` and its operands are never looked at:
383
+ // `{ cat secrets; }`, `if …; then cat secrets; fi` and
384
+ // `while cat secrets; do :; done` each read a file nothing notices. The keywords that open a condition (`if`, `while`,
385
+ // `until`) matter as much as the ones that open a body: the command being tested
386
+ // runs too.
387
+ export const SHELL_KEYWORD_TOKENS = new Set([
388
+ "{",
389
+ "}",
390
+ "!",
391
+ "if",
392
+ "then",
393
+ "else",
394
+ "elif",
395
+ "fi",
396
+ "while",
397
+ "until",
398
+ "for",
399
+ "do",
400
+ "done",
401
+ "case",
402
+ "esac",
403
+ "in",
404
+ "select",
405
+ ]);
406
+ // True for a token that cannot name a command: a redirection operator, a flag, a
407
+ // `VAR=value` assignment placed before one, or a shell keyword.
408
+ export function isNonCommandToken(token) {
409
+ if (token.redirect)
410
+ return true;
411
+ const { value } = token;
412
+ if (value.startsWith("-"))
413
+ return true;
414
+ if (SHELL_KEYWORD_TOKENS.has(value))
415
+ return true;
416
+ return /^[A-Za-z_][A-Za-z0-9_]*=/.test(value);
417
+ }
418
+ // Longest quoted literal inside inline code still treated as a path candidate.
419
+ // Exported so its test reads the cap instead of copying the number.
420
+ export const MAX_QUOTED_LITERAL_LENGTH = 4096;
421
+ // Quoted literals inside inline program text — the ".env" in
422
+ // `python3 -c "print(open('.env').read())"`. Literals containing line breaks or
423
+ // tabs are skipped: those are messages and patterns, not paths. Spaces are kept,
424
+ // so a path like `open('my secret.txt')` is still found.
425
+ export function extractQuotedLiterals(code) {
426
+ const literals = [];
427
+ for (const match of code.matchAll(/'([^']*)'|"([^"]*)"/g)) {
428
+ const value = match[1] ?? match[2];
429
+ if (value &&
430
+ value.length <= MAX_QUOTED_LITERAL_LENGTH &&
431
+ !/[\t\r\n]/.test(value)) {
432
+ literals.push(value);
433
+ }
434
+ }
435
+ return literals;
436
+ }
@@ -0,0 +1,217 @@
1
+ // Which tool calls name a file they are about to read.
2
+ //
3
+ // A tool's semantics are not knowable from its input, so this reads the tool's
4
+ // name and the shape of its input object: the fields that carry a path, and the
5
+ // names that say the tool writes rather than reads.
6
+ // Tools that never surface the contents of a file they name. Scanning these
7
+ // would block writing to a file that already holds a secret, which is not a leak.
8
+ export const TOOLS_WITHOUT_FILE_OUTPUT = new Set([
9
+ "Write",
10
+ "Edit",
11
+ "MultiEdit",
12
+ "NotebookEdit",
13
+ "TodoWrite",
14
+ "Glob",
15
+ "WebFetch",
16
+ "WebSearch",
17
+ "ExitPlanMode",
18
+ "AskUserQuestion",
19
+ ]);
20
+ // A tool whose name says it writes is treated like the built-in Write and Edit:
21
+ // naming a file it does not read is not a leak. Matched on the tool name because
22
+ // an MCP tool's semantics are not otherwise knowable from its input. For MCP
23
+ // tools (`mcp__<server>__<tool>`) only the tool component is matched — a server
24
+ // named "editor" or "readwrite" must not exempt every read tool it offers.
25
+ // The verb has to be the first word of the name, not a substring of it anywhere.
26
+ // As a substring test this would exempt reads: "update" sits inside
27
+ // `get_updates`, and "write" inside `read_and_write_file` — a tool that returns
28
+ // contents read as one that only writes. Word boundaries are the `_`/`-` in snake and
29
+ // kebab names and, in camelCase, a capital that follows a lowercase letter — so
30
+ // `write_file`, `createPage` and `WRITE_FILE` all match while `overwrite_file`
31
+ // and `readwrite` do not.
32
+ //
33
+ // Erring this way costs a false block on a noun-first write tool (`file_write`),
34
+ // which is the direction to fail in. The built-in write tools are named
35
+ // explicitly in TOOLS_WITHOUT_FILE_OUTPUT, so `TodoWrite` and `MultiEdit` do not
36
+ // depend on this at all.
37
+ //
38
+ // What the exemption assumes is that the tool returns no file contents, which is
39
+ // not quite what its name says. `update` and `copy` are where the two come
40
+ // apart: a tool called `update_file` or `copy_file` opens a file to do its work,
41
+ // and one that returned the result would go unscanned. They stay, because the
42
+ // alternative costs more — scanning them blocks writing to a file that already
43
+ // holds a secret, which is not a leak — and the gap that leaves is written up
44
+ // under Known Limitations in the README.
45
+ // Exported so the tests can generate a case per verb rather than list the ones
46
+ // someone remembered: a verb added here without a test is what let `WRITE_FILE`
47
+ // go unexempt for a release.
48
+ export const WRITING_TOOL_VERBS = new Set([
49
+ "write",
50
+ "create",
51
+ "edit",
52
+ "update",
53
+ "append",
54
+ "delete",
55
+ "remove",
56
+ "move",
57
+ "rename",
58
+ "mkdir",
59
+ "copy",
60
+ ]);
61
+ // The first word of a tool name. Splitting on every capital broke the all-caps
62
+ // spelling: `WRITE_FILE` came apart into single letters and its first word was
63
+ // `W`, so a write tool was scanned as a read. A capital only starts a new word
64
+ // when it follows a lowercase letter or a digit, which is what camelCase means;
65
+ // a run of capitals is one word.
66
+ function firstWord(name) {
67
+ const [first] = name
68
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
69
+ .split(/[^A-Za-z0-9]+/)
70
+ .filter(Boolean);
71
+ return first;
72
+ }
73
+ export function isWritingTool(tool) {
74
+ const name = tool.startsWith("mcp__")
75
+ ? (tool.split("__").pop() ?? tool)
76
+ : tool;
77
+ const first = firstWord(name);
78
+ return first !== undefined && WRITING_TOOL_VERBS.has(first.toLowerCase());
79
+ }
80
+ // Input field names that commonly carry a filesystem path, compared with
81
+ // separators and case removed. Listing the spellings instead meant the same
82
+ // field was missed under a different one: `file_path` and `filePath` were both
83
+ // here, but `filepath` was not, and neither was `filename` or `source_path`.
84
+ // Normalising is a rule where a list of spellings is a list of the ones someone
85
+ // happened to think of.
86
+ // A field name with the punctuation taken out, so `file_path`, `file-path`,
87
+ // `filePath`, `file.path` and `file path` are one name. Both collectors here
88
+ // share it: they had a regex each, and the one used for command fields dropped
89
+ // only `-` and `_`, so a field called `command.line` was walked past.
90
+ export function normalizeFieldName(key) {
91
+ return key.replace(/[^A-Za-z0-9]/g, "").toLowerCase();
92
+ }
93
+ export const PATH_FIELD_NAMES = new Set([
94
+ "filepath",
95
+ "filename",
96
+ "filenames",
97
+ "path",
98
+ "paths",
99
+ "file",
100
+ "files",
101
+ "absolutepath",
102
+ "notebookpath",
103
+ "sourcepath",
104
+ ]);
105
+ // `file_path`, `filePath`, `FILE_PATH` and `filepath` are one name here.
106
+ function isPathFieldName(key) {
107
+ return PATH_FIELD_NAMES.has(normalizeFieldName(key));
108
+ }
109
+ // A value that names a path whatever its field is called. The list above can
110
+ // only hold names someone thought of, so a tool carrying its path under `target`
111
+ // or `document` went unscanned; this is the second way in.
112
+ //
113
+ // It is deliberately not "any string". Collecting every string would read a
114
+ // search pattern as a path: `grep` for the literal `.env` arrives as
115
+ // `{ pattern: ".env" }`, `.env` exists, and the name guard would then block a
116
+ // search for that text as though it were a read of the file. A separator is the
117
+ // cheapest test that tells a path from a word, and the cost of using it is that
118
+ // a bare filename under an unlisted field name is still missed.
119
+ function looksLikePath(value) {
120
+ return value.includes("/");
121
+ }
122
+ // Whether a value is worth statting: either its field name says path, or the
123
+ // value is shaped like one.
124
+ function isPathCandidate(key, value) {
125
+ return isPathFieldName(key) || looksLikePath(value);
126
+ }
127
+ // Depth to which a tool's input object is searched for path-bearing fields. Two
128
+ // levels left `{ a: { b: { c: { path } } } }` unscanned, which is not a shape a
129
+ // tool has to be perverse to use; four costs nothing on inputs this size, and
130
+ // the bound is here at all so a deeply nested input cannot make the hook walk
131
+ // an arbitrary tree before a tool call.
132
+ const MAX_PATH_FIELD_DEPTH = 4;
133
+ // Input field names that carry something to run rather than something to read.
134
+ // Compared with separators and case removed, the way path field names are.
135
+ export const COMMAND_FIELD_NAMES = new Set([
136
+ "command",
137
+ "commands",
138
+ "cmd",
139
+ "script",
140
+ "code",
141
+ "shellcommand",
142
+ "commandline",
143
+ ]);
144
+ // How far into a nested input a command field is looked for. The same depth the
145
+ // path fields use, and for the same reason: a tool wraps its arguments.
146
+ const MAX_COMMAND_FIELD_DEPTH = 4;
147
+ export function collectPathFields(input, depth = 0) {
148
+ if (depth > MAX_PATH_FIELD_DEPTH)
149
+ return [];
150
+ const found = [];
151
+ for (const [key, value] of Object.entries(input)) {
152
+ if (typeof value === "string") {
153
+ if (isPathCandidate(key, value))
154
+ found.push(value);
155
+ }
156
+ else if (Array.isArray(value)) {
157
+ for (const item of value) {
158
+ if (typeof item === "string") {
159
+ // An element inherits the array's field name: `{ paths: ["…"] }`.
160
+ if (isPathCandidate(key, item))
161
+ found.push(item);
162
+ }
163
+ else if (Array.isArray(item)) {
164
+ // An array inside an array: `{ paths: [["…"]] }`. The element is not a
165
+ // string and was not an object either, so it fell through and the path
166
+ // in it was never looked at. Re-entered under the same key, so the
167
+ // name rule still applies to what is inside.
168
+ found.push(...collectPathFields({ [key]: item }, depth + 1));
169
+ }
170
+ else if (
171
+ // Paths also arrive as objects inside an array, e.g.
172
+ // `{ paths: [{ path: "…" }] }` — recurse into those elements too.
173
+ item !== null &&
174
+ typeof item === "object") {
175
+ found.push(...collectPathFields(item, depth + 1));
176
+ }
177
+ }
178
+ }
179
+ else if (value !== null && typeof value === "object") {
180
+ found.push(...collectPathFields(value, depth + 1));
181
+ }
182
+ }
183
+ return found;
184
+ }
185
+ // Every command an input carries, whatever shape it arrives in.
186
+ //
187
+ // Reading only top-level strings left two shapes through, and both reach the
188
+ // `.env` name guard by a name with no slash in it, which the path rules do not
189
+ // collect: an argv array (`{"command":["cat",".env"]}`) and a command nested
190
+ // under another key (`{"args":{"command":"cat .env"}}`). Depth-limited the way
191
+ // path fields are, for the same reason.
192
+ //
193
+ // Beside `collectPathFields` rather than in the hook: the two walk the same tree
194
+ // to the same depth and differ only in which field names count, and that
195
+ // question — along with `normalizeFieldName` and both name sets — belongs in one
196
+ // module rather than split across two.
197
+ export function collectCommandFields(input, depth = 0) {
198
+ if (depth > MAX_COMMAND_FIELD_DEPTH)
199
+ return [];
200
+ const found = [];
201
+ for (const [key, value] of Object.entries(input)) {
202
+ const named = COMMAND_FIELD_NAMES.has(normalizeFieldName(key));
203
+ if (named && typeof value === "string") {
204
+ found.push(value);
205
+ }
206
+ else if (named && Array.isArray(value)) {
207
+ // An argv array is one command line with the spaces taken out.
208
+ const argv = value.filter((v) => typeof v === "string");
209
+ if (argv.length > 0)
210
+ found.push(argv.join(" "));
211
+ }
212
+ else if (value !== null && typeof value === "object") {
213
+ found.push(...collectCommandFields(value, depth + 1));
214
+ }
215
+ }
216
+ return found;
217
+ }