@coo-quack/sensitive-canary 0.7.0 → 0.8.1

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 +798 -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,105 @@
1
+ #!/usr/bin/env node
2
+ import { blockOnUnhandledError, failClosed } from "./lib/fail-closed.js";
3
+ import { allowTagLines, applyAllowTags, dedupeFindings, findingsToLines, randomBird, resolveTagPriority, typedTextOf, } from "./lib/inspector.js";
4
+ import { beginScanBudget, enabledCategoriesFromEnv, scan, } from "./lib/rules.js";
5
+ blockOnUnhandledError();
6
+ // Depth at which a prompt stops being searched. The bound is here so a deeply
7
+ // nested value cannot make the hook walk an arbitrary tree before every prompt.
8
+ const MAX_PROMPT_DEPTH = 4;
9
+ function collectStrings(value, depth = 0) {
10
+ if (typeof value === "string")
11
+ return [value];
12
+ if (depth >= MAX_PROMPT_DEPTH)
13
+ return [];
14
+ if (Array.isArray(value))
15
+ return value.flatMap((item) => collectStrings(item, depth + 1));
16
+ if (value !== null && typeof value === "object")
17
+ return Object.values(value).flatMap((item) => collectStrings(item, depth + 1));
18
+ return [];
19
+ }
20
+ const ENABLED_CATEGORIES = enabledCategoriesFromEnv();
21
+ let raw = "";
22
+ process.stdin.setEncoding("utf8");
23
+ process.stdin.on("data", (chunk) => (raw += chunk));
24
+ process.stdin.on("end", () => {
25
+ // Started here rather than at module load: the wait for stdin belongs to the
26
+ // runtime, and counting it against the scan let a slow handover spend the
27
+ // whole allowance before anything was read.
28
+ beginScanBudget();
29
+ let data;
30
+ try {
31
+ // Empty stdin is nothing to check. Bytes that do not parse are a check that
32
+ // could not read its input, which is not the same as safe: two characters
33
+ // missing from the end of a payload are enough to hide a key.
34
+ if (raw.trim().length === 0)
35
+ process.exit(0);
36
+ const parsed = JSON.parse(raw);
37
+ // `JSON.parse("null")` succeeds and returns null, which then threw on the
38
+ // first field read. A payload that is not an object carries no prompt.
39
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
40
+ process.exit(0);
41
+ data = parsed;
42
+ }
43
+ catch (error) {
44
+ // The check never started, so it vouches for nothing. Everything else that
45
+ // cannot finish stops the call; input that will not parse is the same case.
46
+ failClosed(error);
47
+ }
48
+ // Whatever the runtime sends. Not throwing on a prompt that is not a string
49
+ // was only half of it: coercing to "" made the hook exit 0 on the shapes it
50
+ // could not read, which is the same silence as never running. Every string
51
+ // inside the value is collected instead, to a bounded depth, so
52
+ // `{"prompt":{"text":"…"}}` and `{"prompt":["…"]}` are read like a prompt.
53
+ const prompt = collectStrings(data.prompt).join("\n");
54
+ const allFindings = scan(prompt, ENABLED_CATEGORIES);
55
+ if (allFindings.length === 0)
56
+ process.exit(0);
57
+ // From what the user typed, not from what they pasted: a fenced log or a
58
+ // README quoting `[allow-secret]` would otherwise lift the guard on the key in
59
+ // the same message. Both hooks read tags this way, so the same text gets the
60
+ // same answer whichever one sees it.
61
+ const { effectiveAllow, effectiveMask } = resolveTagPriority(typedTextOf(prompt));
62
+ const afterAllow = dedupeFindings(applyAllowTags(allFindings, effectiveAllow));
63
+ if (afterAllow.length === 0)
64
+ process.exit(0);
65
+ const maskableFindings = afterAllow.filter((f) => (f.category === "secret" && effectiveMask.has("secret")) ||
66
+ (f.category === "pii" && effectiveMask.has("pii")));
67
+ if (maskableFindings.length > 0) {
68
+ const usedTags = effectiveMask.has("all")
69
+ ? "[mask-all]"
70
+ : ["secret", "pii"]
71
+ .filter((d) => effectiveMask.has(d))
72
+ .map((d) => `[mask-${d}]`)
73
+ .join(", ");
74
+ const maskBlockLines = [
75
+ "",
76
+ `${randomBird()} sensitive-canary: prompt masking is not supported`,
77
+ "",
78
+ ` ${usedTags} cannot mask prompt content.`,
79
+ " The following sensitive data was detected:",
80
+ "",
81
+ ...findingsToLines(dedupeFindings(maskableFindings)),
82
+ "",
83
+ " Please choose one of the following:",
84
+ "",
85
+ " 1. Manually redact the values above and resubmit",
86
+ " 2. To send as-is, add an allow tag to your prompt:",
87
+ ...allowTagLines(maskableFindings, { indent: " " }),
88
+ "",
89
+ ];
90
+ process.stderr.write(maskBlockLines.join("\n"));
91
+ process.exit(2);
92
+ }
93
+ const blockLines = [
94
+ "",
95
+ `${randomBird()} sensitive-canary: sensitive data detected — blocked`,
96
+ "",
97
+ ...findingsToLines(afterAllow),
98
+ "",
99
+ "To allow, add a tag to your prompt:",
100
+ ...allowTagLines(afterAllow),
101
+ "",
102
+ ];
103
+ process.stderr.write(blockLines.join("\n"));
104
+ process.exit(2);
105
+ });
package/hooks/hooks.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "hooks": {
4
4
  "PreToolUse": [
5
5
  {
6
- "matcher": "Read|Bash",
6
+ "matcher": "Read|NotebookRead|Bash|Grep|mcp__.*",
7
7
  "hooks": [
8
8
  {
9
9
  "type": "command",
package/package.json CHANGED
@@ -1,15 +1,28 @@
1
1
  {
2
2
  "name": "@coo-quack/sensitive-canary",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "description": "Claude Code hooks that block secrets and PII before they reach the Anthropic API",
5
- "homepage": "https://coo-quack.github.io/sensitive-canary/",
6
- "type": "module",
5
+ "license": "MIT",
6
+ "author": "coo-quack",
7
+ "keywords": [
8
+ "security",
9
+ "secrets",
10
+ "pii",
11
+ "hooks"
12
+ ],
7
13
  "repository": {
8
14
  "type": "git",
9
15
  "url": "git+https://github.com/coo-quack/sensitive-canary.git"
10
16
  },
17
+ "homepage": "https://coo-quack.github.io/sensitive-canary/",
18
+ "bugs": {
19
+ "url": "https://github.com/coo-quack/sensitive-canary/issues"
20
+ },
21
+ "type": "module",
11
22
  "files": [
12
23
  "src/",
24
+ "!src/**/__tests__/**",
25
+ "dist/",
13
26
  ".claude-plugin/",
14
27
  "hooks/",
15
28
  "README.md",
@@ -20,23 +33,24 @@
20
33
  "node": ">=22.6.0"
21
34
  },
22
35
  "devDependencies": {
23
- "@biomejs/biome": "2.5.6",
36
+ "@biomejs/biome": "2.5.8",
24
37
  "@types/node": "25.9.5",
25
38
  "typescript": "7.0.2",
26
- "vitepress": "2.0.0-alpha.18",
39
+ "vitepress": "2.0.0-alpha.19",
27
40
  "vitest": "4.1.10"
28
41
  },
29
42
  "scripts": {
30
43
  "test": "vitest run",
31
44
  "test:watch": "vitest",
32
45
  "typecheck": "tsc --noEmit",
33
- "lint": "biome lint src",
34
- "format": "biome format --write src",
35
- "format:check": "biome ci --linter-enabled=false src",
36
- "fix": "biome check --write src",
37
- "ci": "tsc --noEmit && biome check src && vitest run",
46
+ "lint": "biome lint --error-on-warnings src vitest.config.ts docs/.vitepress/config.ts docs/.vitepress/theme",
47
+ "format": "biome format --write src vitest.config.ts docs/.vitepress/config.ts docs/.vitepress/theme",
48
+ "format:check": "biome ci --linter-enabled=false src vitest.config.ts docs/.vitepress/config.ts docs/.vitepress/theme",
49
+ "fix": "biome check --write src vitest.config.ts docs/.vitepress/config.ts docs/.vitepress/theme",
50
+ "ci": "tsc --noEmit && biome check --error-on-warnings src vitest.config.ts docs/.vitepress/config.ts docs/.vitepress/theme && vitest run",
38
51
  "docs:dev": "vitepress dev docs",
39
52
  "docs:build": "vitepress build docs",
40
- "docs:preview": "vitepress preview docs"
53
+ "docs:preview": "vitepress preview docs",
54
+ "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json && node -e \"require('node:fs').copyFileSync('src/lib/default-config.json','dist/lib/default-config.json')\""
41
55
  }
42
56
  }
@@ -0,0 +1,455 @@
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
+
8
+ import path from "node:path";
9
+ import {
10
+ ARGUMENT_ONLY_COMMANDS,
11
+ asksForRecursion,
12
+ type CommandRefs,
13
+ classifyCommand,
14
+ editsInPlace,
15
+ GIT_GLOBAL_FLAGS_WITH_OPERAND,
16
+ GREP_FAMILY,
17
+ gitLineRangeFile,
18
+ gitSubcommandPrintsFiles,
19
+ isWrapperTarget,
20
+ MAX_NESTING_DEPTH,
21
+ mergeRefs,
22
+ POSIX_SHELLS,
23
+ patternSupplyingFlag,
24
+ RECURSIVE_BY_DEFAULT,
25
+ WRAPPER_COMMANDS,
26
+ WRITE_TARGET_FLAGS,
27
+ } from "./command-tables.ts";
28
+ import {
29
+ extractQuotedLiterals,
30
+ extractSubstitutions,
31
+ isNonCommandToken,
32
+ type ShellToken,
33
+ stripHeredocBodies,
34
+ tokenizeCommand,
35
+ } from "./shell.ts";
36
+
37
+ // Index of the token naming the command whose operands matter.
38
+ //
39
+ // The lead command is the first token that is not a flag, redirection or
40
+ // `VAR=value` assignment. Only a known wrapper (`sudo`, `env`, `timeout`, …)
41
+ // is peeled, by searching the rest of the segment for the first token this
42
+ // hook can classify (`env` and `printenv` included, so their detection behind
43
+ // wrapper operands works); anything else is treated as the command itself. Searching
44
+ // unconditionally mistook operands for commands: `echo cat secrets` resolved
45
+ // to `cat`, and a file that was never read was scanned and blocked. Wrappers
46
+ // are still not peeled by counting their flags: `sudo -u root cat f` would
47
+ // mistake `root` for the command, and `timeout -s KILL 5 cat f` would
48
+ // mistake `5`. Falling back to the lead leaves an unknown command classified
49
+ // as itself.
50
+ //
51
+ // The search past a wrapper carries the same hazard one step further in, which
52
+ // is why it stops at an ARGUMENT_ONLY_COMMANDS name: `sudo echo cat secrets`
53
+ // otherwise resolved to the `cat` sitting in echo's arguments. Past any other
54
+ // unclassifiable name the search continues, since that name may be a wrapper
55
+ // flag's value rather than the command.
56
+ function findCommandIndex(tokens: ShellToken[]): number {
57
+ let lead = -1;
58
+ // A redirection may come before the command — `< secrets cat` is `cat` reading
59
+ // `secrets`. The operator is skipped as a non-command token, but its target is
60
+ // an ordinary word, so `secrets` was taken for the command name and the real
61
+ // one went unclassified: nothing of its operands was collected, and a spelling
62
+ // away from `cat < secrets`, which blocks.
63
+ let skipRedirectTarget = false;
64
+ for (let i = 0; i < tokens.length; i++) {
65
+ const token = tokens[i];
66
+ if (token === undefined) continue;
67
+ if (skipRedirectTarget) {
68
+ skipRedirectTarget = false;
69
+ continue;
70
+ }
71
+ if (token.redirect) {
72
+ skipRedirectTarget = true;
73
+ continue;
74
+ }
75
+ if (isNonCommandToken(token)) continue;
76
+ lead = i;
77
+ break;
78
+ }
79
+ if (lead === -1) return 0;
80
+
81
+ const leadName = path.basename(tokens[lead]?.value ?? "");
82
+ if (!WRAPPER_COMMANDS.has(leadName)) return lead;
83
+
84
+ for (let i = lead + 1; i < tokens.length; i++) {
85
+ const tok = tokens[i];
86
+ if (tok === undefined || isNonCommandToken(tok)) continue;
87
+ const name = path.basename(tok.value);
88
+ if (isWrapperTarget(name) || ARGUMENT_ONLY_COMMANDS.has(name)) return i;
89
+ }
90
+ return lead;
91
+ }
92
+
93
+ // `env` and `printenv` print the environment unless they are being used to run
94
+ // another command. `sudo printenv` counts; `env FOO=1 cat f` does not. The
95
+ // command is located with findCommandIndex, so wrapper flags and operands
96
+ // (`sudo -u root printenv`, `timeout 5 env`) cannot hide it.
97
+ function inspectEnvironmentCommand(tokens: ShellToken[]): {
98
+ dumps: boolean;
99
+ named: string[];
100
+ } {
101
+ const nothing = { dumps: false, named: [] };
102
+
103
+ const start = findCommandIndex(tokens);
104
+ const cmdToken = tokens[start];
105
+ if (cmdToken === undefined) return nothing;
106
+
107
+ const name = path.basename(cmdToken.value);
108
+ if (name !== "env" && name !== "printenv") return nothing;
109
+
110
+ // `printenv` prints the whole environment unless it is given variables to
111
+ // print. A redirection target is not one of them: `printenv > out.txt` prints
112
+ // everything, and counting `out.txt` as a named variable left the environment
113
+ // unscanned.
114
+ if (name === "printenv") {
115
+ const rest = tokens.slice(start + 1);
116
+ const named: string[] = [];
117
+ for (let j = 0; j < rest.length; j++) {
118
+ const t = rest[j];
119
+ if (t === undefined) continue;
120
+ if (t.redirect) {
121
+ j++; // its target, or a heredoc delimiter
122
+ continue;
123
+ }
124
+ if (isNonCommandToken(t)) continue; // flags
125
+ named.push(t.value);
126
+ }
127
+ return named.length === 0
128
+ ? { dumps: true, named: [] }
129
+ : { dumps: false, named };
130
+ }
131
+
132
+ // `env` prints the environment unless a subcommand follows its own
133
+ // arguments: assignments (`FOO=1`), flags, and the values of flags that
134
+ // take one (`-u FOO`, `-C dir`) are all env's own. The `-S` split string
135
+ // is the subcommand itself (`env -S "cat f"` runs cat), so it rules a dump
136
+ // out. With no subcommand the whole environment is printed — `env FOO=1`
137
+ // and `env -u FOO` included. Exception: `-i` starts from an empty
138
+ // environment, so only the given assignments (already scanned as command
139
+ // text) print.
140
+ const rest = tokens.slice(start + 1);
141
+ let ignoreEnvironment = false;
142
+ let hasCommand = false;
143
+ for (let j = 0; j < rest.length; j++) {
144
+ const t = rest[j];
145
+ if (t === undefined) continue;
146
+ const value = t.value;
147
+ if (value === "-i" || value === "--ignore-environment") {
148
+ ignoreEnvironment = true;
149
+ continue;
150
+ }
151
+ if (t.redirect) {
152
+ j++; // redirection operator: its target is env's own argument here
153
+ continue;
154
+ }
155
+ if (
156
+ value === "-u" ||
157
+ value === "--unset" ||
158
+ value === "-C" ||
159
+ value === "--chdir"
160
+ ) {
161
+ j++; // flag value
162
+ continue;
163
+ }
164
+ if (value === "-S" || value === "--split-string") {
165
+ hasCommand = true; // the split string is the subcommand
166
+ break;
167
+ }
168
+ if (isNonCommandToken(t)) continue; // flags and FOO=1 assignments
169
+ hasCommand = true;
170
+ break;
171
+ }
172
+ return { dumps: !hasCommand && !ignoreEnvironment, named: [] };
173
+ }
174
+
175
+ // True when `token` introduces inline program text for `cmd`. POSIX shells only
176
+ // take code after -c; other interpreters also use -e and combined forms (-pe).
177
+ function isInlineCodeFlag(cmd: string, token: string): boolean {
178
+ if (token === "-c" || token === "--command" || token === "--eval")
179
+ return true;
180
+ if (POSIX_SHELLS.has(cmd)) {
181
+ // A shell bundles its switches too: `bash -lc 'cat secrets'` runs the same
182
+ // string `bash -c` would, and only the exact spelling was recognised. The
183
+ // letters before the `c` have to be switches that take no value of their
184
+ // own, or the `c` is part of something else's value.
185
+ return /^-[abefhilmnpuvxCPT]*c$/.test(token);
186
+ }
187
+ if (token === "-r") return true; // php -r
188
+ return /^-[A-Za-z]*[eE]$/.test(token);
189
+ }
190
+
191
+ // Everything a Bash command reveals that the hook can inspect before it runs:
192
+ // the files whose contents it may print, and the environment it may expose.
193
+ export function extractCommandRefs(command: string, depth = 0): CommandRefs {
194
+ const refs: CommandRefs = {
195
+ paths: [],
196
+ envVars: [],
197
+ dumpsEnvironment: false,
198
+ searchesWorkingDirectory: false,
199
+ };
200
+
201
+ if (depth > MAX_NESTING_DEPTH) return refs;
202
+
203
+ // Heredoc bodies are text, not commands; strip them before any other pass.
204
+ const text = stripHeredocBodies(command);
205
+
206
+ // `echo $(cat secrets)` reads secrets just as `cat secrets` does.
207
+ for (const inner of extractSubstitutions(text)) {
208
+ mergeRefs(refs, extractCommandRefs(inner, depth + 1));
209
+ }
210
+
211
+ for (const tokens of tokenizeCommand(text)) {
212
+ const environment = inspectEnvironmentCommand(tokens);
213
+ if (environment.dumps) refs.dumpsEnvironment = true;
214
+ refs.envVars.push(...environment.named);
215
+
216
+ mergeRefs(refs, collectSegmentRefs(tokens, depth));
217
+ }
218
+
219
+ return {
220
+ paths: [...new Set(refs.paths)],
221
+ envVars: [...new Set(refs.envVars)],
222
+ dumpsEnvironment: refs.dumpsEnvironment,
223
+ searchesWorkingDirectory: refs.searchesWorkingDirectory,
224
+ };
225
+ }
226
+
227
+ // File paths one segment of a command line may print, plus anything found inside
228
+ // inline program text it carries.
229
+ function collectSegmentRefs(tokens: ShellToken[], depth: number): CommandRefs {
230
+ const refs: CommandRefs = {
231
+ paths: [],
232
+ envVars: [],
233
+ dumpsEnvironment: false,
234
+ searchesWorkingDirectory: false,
235
+ };
236
+
237
+ const start = findCommandIndex(tokens);
238
+ const cmdToken = tokens[start];
239
+ if (cmdToken === undefined) return refs;
240
+
241
+ const cmd = path.basename(cmdToken.value);
242
+ const operands = tokens.slice(start + 1);
243
+
244
+ // `< secrets cat` puts the redirection before the command, so its target is
245
+ // not among the operands and the loop below never sees it. The command still
246
+ // reads it.
247
+ //
248
+ // `$(<secrets)` has no command at all: bash reads the file and substitutes its
249
+ // contents. There the whole token list is in front of the "command", which is
250
+ // the redirection operator itself.
251
+ const beforeCommand = cmdToken.redirect ? tokens.length : start;
252
+ for (let i = 0; i < beforeCommand; i++) {
253
+ if (tokens[i]?.redirect !== true) continue;
254
+ if (tokens[i]?.value !== "<") continue;
255
+ const target = tokens[i + 1];
256
+ if (target === undefined || target.redirect) continue;
257
+ if (!classifyCommand(cmd).printsNoFileContents) {
258
+ refs.paths.push(target.value);
259
+ }
260
+ }
261
+
262
+ // In-place editing sends the result back to the file, so nothing reaches
263
+ // stdout and nothing is read into the conversation.
264
+ if (editsInPlace(cmd, operands)) {
265
+ return refs;
266
+ }
267
+
268
+ // `eval 'cat secrets'` is a command line in a single word, the same shape
269
+ // `env -S` carries. Stepping past `eval` finds that word as the command name,
270
+ // which classifies as nothing at all.
271
+ if (cmd === "eval") {
272
+ for (const operand of operands) {
273
+ if (operand.redirect) continue;
274
+ mergeRefs(refs, extractCommandRefs(operand.value, depth + 1));
275
+ refs.paths.push(...extractQuotedLiterals(operand.value));
276
+ }
277
+ }
278
+
279
+ // `env -S "cmd args"` splits the string into the command it runs, so scan
280
+ // inside it the way inline code is scanned.
281
+ if (cmd === "env") {
282
+ for (let k = 0; k < operands.length; k++) {
283
+ const t = operands[k]?.value;
284
+ if (t !== "-S" && t !== "--split-string") continue;
285
+ const script = operands[k + 1]?.value;
286
+ if (script === undefined) continue;
287
+ mergeRefs(refs, extractCommandRefs(script, depth + 1));
288
+ k++;
289
+ }
290
+ }
291
+
292
+ const behaviour = classifyCommand(cmd);
293
+
294
+ let skipNext = false;
295
+ let collectNext = false;
296
+ let codeNext = false;
297
+ let inlineCodeSeen = false;
298
+ let patternSkipped = false;
299
+ let gitSubcommandSeen = false;
300
+ let gitReadsFiles = false;
301
+ let optionsEnded = false;
302
+ let lineRangeNext = false;
303
+
304
+ for (const tok of operands) {
305
+ if (skipNext) {
306
+ skipNext = false;
307
+ continue;
308
+ }
309
+ if (collectNext) {
310
+ collectNext = false;
311
+ refs.paths.push(tok.value);
312
+ continue;
313
+ }
314
+ if (codeNext) {
315
+ codeNext = false;
316
+ // The expression came from -e/-c, so a later operand is a file, not the
317
+ // script `perl file` would have run. That is `inlineCodeSeen` below: no
318
+ // command takes inline code *and* a leading pattern, an assumption the
319
+ // tests pin, so there is no pattern here to mark as supplied.
320
+ if (behaviour.inlineCodeReadsOperands) inlineCodeSeen = true;
321
+ mergeRefs(refs, extractCommandRefs(tok.value, depth + 1));
322
+ refs.paths.push(...extractQuotedLiterals(tok.value));
323
+ continue;
324
+ }
325
+
326
+ if (tok.redirect) {
327
+ if (tok.value === "<") {
328
+ // stdin is fed from the next token, unless nothing of it is printed
329
+ collectNext = !behaviour.printsNoFileContents;
330
+ skipNext = behaviour.printsNoFileContents;
331
+ } else {
332
+ // A heredoc or herestring delimiter, or an output target: never read.
333
+ skipNext = true;
334
+ }
335
+ continue;
336
+ }
337
+
338
+ // `--` ends option parsing: every token after it is an operand, whatever it
339
+ // is spelled like. `grep -- -aws secrets` searches for `-aws` in `secrets`,
340
+ // so the file is a file — read as a flag, `-aws` left the pattern
341
+ // unaccounted for and `secrets` was consumed in its place.
342
+ if (!optionsEnded && tok.value === "--") {
343
+ optionsEnded = true;
344
+ continue;
345
+ }
346
+
347
+ if (
348
+ !optionsEnded &&
349
+ behaviour.takesInlineCode &&
350
+ isInlineCodeFlag(cmd, tok.value)
351
+ ) {
352
+ codeNext = true;
353
+ continue;
354
+ }
355
+
356
+ // `git log -L1,10:f` prints the lines of `f` themselves, and the file is
357
+ // written inside the range spec after the last `:`. The flag branch below
358
+ // consumes any `-`-shaped token, so this has to come before it, and the
359
+ // operand branch would never see the file anyway.
360
+ if (
361
+ behaviour.isGit &&
362
+ (tok.value.startsWith("-L") || tok.value.startsWith("--line-range"))
363
+ ) {
364
+ const inFlag = gitLineRangeFile(tok.value);
365
+ if (inFlag !== null) refs.paths.push(inFlag);
366
+ else lineRangeNext = true;
367
+ continue;
368
+ }
369
+ if (lineRangeNext) {
370
+ lineRangeNext = false;
371
+ const separate = gitLineRangeFile(tok.value);
372
+ if (separate !== null) refs.paths.push(separate);
373
+ continue;
374
+ }
375
+
376
+ if (!optionsEnded && tok.value.startsWith("-")) {
377
+ // A global git flag with a separate value consumes the next token too:
378
+ // in `git -C repo show f`, `repo` is not the subcommand.
379
+ if (
380
+ behaviour.isGit &&
381
+ !gitSubcommandSeen &&
382
+ GIT_GLOBAL_FLAGS_WITH_OPERAND.has(tok.value)
383
+ ) {
384
+ skipNext = true;
385
+ }
386
+
387
+ // The value of an output flag is written, not read.
388
+ if (WRITE_TARGET_FLAGS[cmd]?.has(tok.value)) skipNext = true;
389
+
390
+ // The pattern arrived as a flag, so no operand stands in for it.
391
+ if (behaviour.firstOperandIsPatternOrScript) {
392
+ const supply = patternSupplyingFlag(tok.value);
393
+ if (supply !== null) {
394
+ patternSkipped = true;
395
+ // Only a flag still waiting for its value consumes the next token. A
396
+ // value already attached — `--regexp=aws`, or `-eaws` — is part of
397
+ // this one.
398
+ if (supply === "separate") skipNext = true;
399
+ }
400
+ }
401
+ continue;
402
+ }
403
+
404
+ if (behaviour.isGit) {
405
+ if (!gitSubcommandSeen) {
406
+ gitSubcommandSeen = true;
407
+ gitReadsFiles = gitSubcommandPrintsFiles(tok.value, operands);
408
+ continue;
409
+ }
410
+ if (gitReadsFiles) refs.paths.push(tok.value);
411
+ continue;
412
+ }
413
+
414
+ // `if=<file>` names an input only for `dd`; other commands taking an
415
+ // `if=` argument are not reading the file it names.
416
+ if (behaviour.isDd) {
417
+ const ddInput = /^if=(.+)$/.exec(tok.value);
418
+ if (ddInput?.[1]) {
419
+ refs.paths.push(ddInput[1]);
420
+ continue;
421
+ }
422
+ }
423
+
424
+ if (behaviour.firstOperandIsPatternOrScript && !patternSkipped) {
425
+ patternSkipped = true; // the pattern, expression or script name
426
+ // An awk or sed program can name a file inside itself, the way inline code
427
+ // does: `awk 'BEGIN{ while ((getline l < "secrets") > 0) print l }'` reads
428
+ // one without ever naming it as an operand.
429
+ refs.paths.push(...extractQuotedLiterals(tok.value));
430
+ continue;
431
+ }
432
+
433
+ if (
434
+ behaviour.printsOperands ||
435
+ behaviour.firstOperandIsPatternOrScript ||
436
+ inlineCodeSeen
437
+ ) {
438
+ refs.paths.push(tok.value);
439
+ }
440
+ }
441
+
442
+ // A searcher handed no file searches where it is run. `rg PATTERN` and
443
+ // `grep -r PATTERN` are the ordinary forms and name nothing, so the loop above
444
+ // collects the pattern and stops with no path — and the tree they print from
445
+ // is the working directory. The caller is what knows which directory that is.
446
+ if (
447
+ refs.paths.length === 0 &&
448
+ (RECURSIVE_BY_DEFAULT.has(cmd) ||
449
+ (GREP_FAMILY.has(cmd) && asksForRecursion(operands)))
450
+ ) {
451
+ refs.searchesWorkingDirectory = true;
452
+ }
453
+
454
+ return refs;
455
+ }