@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
@@ -1,153 +1,143 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import fs from "node:fs";
4
+ import os from "node:os";
4
5
  import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { extractCommandRefs } from "./lib/bash-commands.ts";
8
+ import type { CommandRefs } from "./lib/command-tables.ts";
9
+ import { detectUtf16, looksBinary, utf8Runs } from "./lib/encoding.ts";
10
+ import { blockOnUnhandledError, failClosed } from "./lib/fail-closed.ts";
5
11
  import {
12
+ allowTagLines,
6
13
  applyAllowTags,
7
14
  dedupeFindings,
8
15
  findingsToLines,
9
- type Message,
10
- parseAllowTags,
16
+ forOutput,
11
17
  randomBird,
12
18
  } from "./lib/inspector.ts";
13
- import { enabledCategoriesFromEnv, type Finding, scan } from "./lib/rules.ts";
19
+ import {
20
+ beginScanBudget,
21
+ enabledCategoriesFromEnv,
22
+ type Finding,
23
+ scan,
24
+ } from "./lib/rules.ts";
25
+ import {
26
+ extractEnvVarNames,
27
+ extractQuotedLiterals,
28
+ tokenizeCommand,
29
+ } from "./lib/shell.ts";
30
+ import {
31
+ collectCommandFields,
32
+ collectPathFields,
33
+ isWritingTool,
34
+ TOOLS_WITHOUT_FILE_OUTPUT,
35
+ } from "./lib/tool-inputs.ts";
36
+ import { loadAllowTagsFromTranscript } from "./lib/transcript.ts";
37
+
38
+ blockOnUnhandledError();
14
39
 
15
40
  interface HookInput {
16
41
  transcript_path?: string;
42
+ // The directory Claude Code runs the tool in. A relative path in a command is
43
+ // relative to this, and reading it is the difference between scanning
44
+ // `cat secrets.txt` and dropping it as a file that is not there.
45
+ cwd?: string;
17
46
  tool_name?: string;
18
- tool_input?: {
47
+ tool_input?: Record<string, unknown> & {
19
48
  file_path?: string;
20
49
  command?: string;
21
50
  };
22
51
  }
23
52
 
24
- interface TranscriptLine {
25
- message?: Message;
53
+ // Whether a tool is searching, and named nothing to search. `Grep` is the one
54
+ // Claude Code ships; an MCP server offering the same thing is recognised by the
55
+ // shape of its input rather than by name, since the names are the server's to
56
+ // choose. A pattern with no path is a search of the working directory.
57
+ function searchesWithoutAPath(
58
+ tool: string,
59
+ input: Record<string, unknown>,
60
+ ): boolean {
61
+ const hasSearchTerm =
62
+ typeof input["pattern"] === "string" ||
63
+ typeof input["query"] === "string" ||
64
+ typeof input["regex"] === "string";
65
+ return hasSearchTerm && (tool === "Grep" || tool.startsWith("mcp__"));
26
66
  }
27
67
 
28
68
  // ── Constants ─────────────────────────────────────────────────────────────────
29
69
 
30
- // Maximum bytes to read from the tail of a transcript file.
31
- const MAX_TRANSCRIPT_TAIL_BYTES = 65_536; // 64 KB
70
+ // Maximum bytes scanned from the head of a file. `readFileSync` has no size
71
+ // limit, so without a cap a multi-GB log is read whole and every rule run over
72
+ // all of it, which is the hang this hook cannot afford: a hook killed by the
73
+ // PreToolUse timeout does not block the call (see isRegularFile). A secret past
74
+ // the cut is missed, the same trade the transcript read makes for its tail.
75
+ const MAX_FILE_SCAN_BYTES = 1_048_576; // 1 MiB
76
+
77
+ // Total bytes one hook invocation will read across every file it scans. The
78
+ // per-file cap bounds one file and nothing else bounds the number of them: a
79
+ // glob naming three hundred large files costs half a minute, which is long
80
+ // enough for the PreToolUse timeout to kill the hook, and a killed hook does
81
+ // not block the call.
82
+ //
83
+ // Files past the budget are not scanned, so the budget is also a way through:
84
+ // enough large files named before the one that matters and it is spent.
85
+ // Sixty-four megabytes is about two seconds of scanning here, which keeps the
86
+ // hook well inside the timeout while making that trick need sixty-four files
87
+ // rather than eight. It does not remove it — written up as a limitation.
88
+ const MAX_TOTAL_SCAN_BYTES = 64 * 1_048_576; // 64 MiB
32
89
 
33
90
  const ENABLED_CATEGORIES = enabledCategoriesFromEnv();
34
91
 
35
- // ── Transcript ────────────────────────────────────────────────────────────────
36
-
37
- // Returns true when the message contains at least one text content block
38
- // (or is a plain string). Tool-result-only messages are not real user input.
39
- function hasTextContent(msg: Message): boolean {
40
- if (typeof msg.content === "string") return true;
41
- return msg.content.some((b) => b.type === "text");
92
+ // Mutable for one run of the process: what has been read, and what has already
93
+ // been looked at. Overlapping globs name the same file several times over, and
94
+ // each of those is a file read and a scan.
95
+ const scanned = new Set<string>();
96
+ let bytesScanned = 0;
97
+
98
+ // When this invocation has to stop reading files, whatever it has read.
99
+ //
100
+ // A byte budget bounds the reading and not the walking: a pattern reaching one
101
+ // level under a home directory costs ten seconds, close enough to the PreToolUse
102
+ // timeout to matter, and a hook killed by that timeout does not block. This is
103
+ // checked between files, so it bounds the reading of many files where a byte
104
+ // count would not. It does not bound a single `globSync` call, which cannot be
105
+ // interrupted: a pattern several directories deep still costs what the walk
106
+ // costs. Files after the deadline are not scanned, and a `.env` name reached
107
+ // after it falls back on the name.
108
+ //
109
+ // Both clocks start when the payload arrives, not when the process does. The
110
+ // wait for stdin belongs to the runtime, and counting it against the scan lets
111
+ // a slow handover spend the whole allowance before a single file is read.
112
+ let DEADLINE = Number.POSITIVE_INFINITY;
113
+
114
+ function startTheClock(): void {
115
+ DEADLINE = Date.now() + 5_000;
116
+ // One budget for the whole invocation rather than one per `scan()` call: a
117
+ // call is made per environment variable and per end of each file, so the
118
+ // payload sets how many there are.
119
+ beginScanBudget();
42
120
  }
43
121
 
44
- // Load allow tags from the Claude Code session transcript.
45
- // Transcript format (JSONL): { "type": "user"|"assistant", "message": { role, content }, … }
46
- // Only the most recent user *text* message is consulted, and only if no tool_result
47
- // entries have been recorded after it. This means allow tags are consumed by the first
48
- // tool call — subsequent tool calls in the same AI turn will be blocked.
49
- function loadAllowTagsFromTranscript(transcriptPath: string): Set<string> {
50
- let raw: string;
122
+ // The directory a relative path is relative to. Set from the payload before any
123
+ // scanning; `process.cwd()` is where the hook was started, which is not
124
+ // necessarily where the command will run.
125
+ let baseDirectory = currentDirectoryOrRoot();
126
+
127
+ // `process.cwd()` throws when the directory the hook was started in has been
128
+ // removed, which a build script does every time it runs `rm -rf dist` from
129
+ // inside `dist`, and `git worktree remove` does to a worktree. The throw would
130
+ // happen while this module is still being evaluated, before the transcript is
131
+ // read, so it would stop every tool call with a message telling the user to add
132
+ // an allow tag that could not be honoured. There is nothing sensitive about a
133
+ // missing directory: a relative path simply has no base, and one that resolves
134
+ // to nothing is scanned as the name it is.
135
+ function currentDirectoryOrRoot(): string {
51
136
  try {
52
- const stat = fs.statSync(transcriptPath);
53
- if (stat.size <= MAX_TRANSCRIPT_TAIL_BYTES) {
54
- raw = fs.readFileSync(transcriptPath, "utf8");
55
- } else {
56
- const buf = Buffer.alloc(MAX_TRANSCRIPT_TAIL_BYTES);
57
- const fd = fs.openSync(transcriptPath, "r");
58
- try {
59
- const bytesRead = fs.readSync(
60
- fd,
61
- buf,
62
- 0,
63
- MAX_TRANSCRIPT_TAIL_BYTES,
64
- stat.size - MAX_TRANSCRIPT_TAIL_BYTES,
65
- );
66
- raw = buf.subarray(0, bytesRead).toString("utf8");
67
- } finally {
68
- fs.closeSync(fd);
69
- }
70
- }
137
+ return process.cwd();
71
138
  } catch {
72
- return new Set();
139
+ return path.sep;
73
140
  }
74
-
75
- let lastUserMessage: Message | null = null;
76
- let toolResultAfterLastText = false;
77
- for (const line of raw.split("\n")) {
78
- const trimmed = line.trim();
79
- if (!trimmed) continue;
80
- try {
81
- const parsed = JSON.parse(trimmed) as TranscriptLine;
82
- const msg = parsed.message;
83
- if (msg?.role === "user" && msg.content !== undefined) {
84
- if (hasTextContent(msg)) {
85
- lastUserMessage = msg;
86
- toolResultAfterLastText = false;
87
- } else {
88
- toolResultAfterLastText = true;
89
- }
90
- }
91
- } catch {
92
- // skip malformed lines
93
- }
94
- }
95
-
96
- if (!lastUserMessage || toolResultAfterLastText) return new Set();
97
- return parseAllowTags([lastUserMessage]);
98
- }
99
-
100
- // ── Bash helpers ──────────────────────────────────────────────────────────────
101
-
102
- const FILE_READ_COMMANDS = new Set([
103
- "cat",
104
- "head",
105
- "tail",
106
- "less",
107
- "more",
108
- "bat",
109
- "nl",
110
- ]);
111
-
112
- function extractEnvVarNames(command: string): string[] {
113
- const names = new Set<string>();
114
- const re = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g;
115
- for (const match of command.matchAll(re)) {
116
- const name = match[1] ?? match[2];
117
- if (name) names.add(name);
118
- }
119
- return [...names];
120
- }
121
-
122
- function extractFilePathsFromCommand(command: string): string[] {
123
- const paths: string[] = [];
124
- const segments = command.split(/\s*[|;&]+\s*/);
125
-
126
- for (const seg of segments) {
127
- const tokens = seg.trim().split(/\s+/).filter(Boolean);
128
- if (tokens.length < 2) continue;
129
-
130
- const cmd = path.basename(tokens[0] ?? "");
131
- if (!FILE_READ_COMMANDS.has(cmd)) continue;
132
-
133
- let skipNext = false;
134
- for (let i = 1; i < tokens.length; i++) {
135
- if (skipNext) {
136
- skipNext = false;
137
- continue;
138
- }
139
- const tok = tokens[i];
140
- if (!tok) continue;
141
- if (tok.startsWith("-")) continue;
142
- if (tok === ">" || tok === ">>" || tok === "<") {
143
- skipNext = true;
144
- continue;
145
- }
146
- paths.push(tok);
147
- }
148
- }
149
-
150
- return [...new Set(paths)];
151
141
  }
152
142
 
153
143
  // ── .env pattern ──────────────────────────────────────────────────────────────
@@ -155,12 +145,40 @@ function extractFilePathsFromCommand(command: string): string[] {
155
145
  // .env and .env.* (e.g. .env.local, .env.production) match the env filename pattern.
156
146
  // The block only applies while the "secret" category is enabled (see shouldBlockEnvFile).
157
147
  // Files that merely end in .env (e.g. production.env) are handled by content scanning.
158
- function isBlockedEnvFile(filePath: string): boolean {
148
+ // Said in two places — the name guard and the partial-read guard — and a
149
+ // difference between them would read as two different rules.
150
+ const ENV_BLOCK_REASON =
151
+ "🚫 Blocked: .env and .env.* files contain secrets and must not be read into the conversation.";
152
+
153
+ // Suffixes that say a file is the template rather than the filled-in thing.
154
+ // These are committed on purpose, carry placeholders, and a tool that refuses to
155
+ // read `.env.example` is refusing the file people write in order to explain the
156
+ // other one. Their contents are still scanned like any file's, so a template
157
+ // with a real key in it is still caught — by what is in it, not by its name.
158
+ const ENV_TEMPLATE_SUFFIXES = [
159
+ ".example",
160
+ ".sample",
161
+ ".template",
162
+ ".dist",
163
+ ".defaults",
164
+ ];
165
+
166
+ // Any `.env` name, template or not.
167
+ function isEnvName(filePath: string): boolean {
159
168
  if (!filePath) return false;
160
169
  const base = path.basename(filePath);
161
170
  return base === ".env" || base.startsWith(".env.");
162
171
  }
163
172
 
173
+ // The same names, minus the templates. Stated in terms of `isEnvName` rather
174
+ // than repeating the test: the two answered the question separately, so a change
175
+ // to one silently disagreed with the other.
176
+ function isBlockedEnvFile(filePath: string): boolean {
177
+ if (!isEnvName(filePath)) return false;
178
+ const base = path.basename(filePath);
179
+ return !ENV_TEMPLATE_SUFFIXES.some((suffix) => base.endsWith(suffix));
180
+ }
181
+
164
182
  // The .env name-based block is a secret guard: it only applies while the
165
183
  // "secret" category is enabled.
166
184
  function shouldBlockEnvFile(filePath: string): boolean {
@@ -181,11 +199,7 @@ function buildAllowHints(
181
199
  showAllTags || findings.some((f) => f.category === "secret");
182
200
  const hasPii = showAllTags || findings.some((f) => f.category === "pii");
183
201
 
184
- const lines: string[] = [];
185
- if (hasSecret) lines.push(" [allow-secret] — allow secrets");
186
- if (hasPii) lines.push(" [allow-pii] — allow PII");
187
- lines.push(" [allow-all] — bypass all sensitive-canary checks");
188
- lines.push("");
202
+ const lines = [...allowTagLines(findings, { showAll: showAllTags }), ""];
189
203
 
190
204
  const example =
191
205
  hasSecret && hasPii
@@ -208,7 +222,7 @@ function block(
208
222
  const bird = randomBird();
209
223
  const terminalMessage = [
210
224
  "",
211
- `${bird} sensitive-canary: blocked — ${source}`,
225
+ `${bird} sensitive-canary: blocked — ${forOutput(source)}`,
212
226
  "",
213
227
  ...detectionLines,
214
228
  "",
@@ -222,11 +236,12 @@ function block(
222
236
  fs.closeSync(fd);
223
237
  }
224
238
  } catch {
225
- process.stderr.write(terminalMessage);
239
+ // No controlling terminal. The reason written to stderr below carries the
240
+ // same detection lines, so there is nothing to fall back to.
226
241
  }
227
242
 
228
243
  const reasonLines = [
229
- `${bird} sensitive-canary blocked: ${source}`,
244
+ `${bird} sensitive-canary blocked: ${forOutput(source)}`,
230
245
  "",
231
246
  ...detectionLines,
232
247
  "",
@@ -236,56 +251,499 @@ function block(
236
251
  "Please tell the user about this block and suggest the appropriate tag.",
237
252
  ];
238
253
 
239
- process.stdout.write(
240
- `${JSON.stringify({
241
- decision: "block",
242
- reason: reasonLines.join("\n"),
243
- })}\n`,
244
- );
254
+ // Exit 2 blocks the tool call and stderr is the documented way to say why.
255
+ //
256
+ // Not stdout as `{"decision":"block", …}`. That form does reach Claude on the
257
+ // current version — measured with a probe hook, not assumed — but the
258
+ // documentation says stdout is ignored on a non-zero exit, and that PreToolUse
259
+ // takes its decision from `hookSpecificOutput` rather than a top-level
260
+ // `decision` field. It would work by way of behaviour described nowhere, which
261
+ // a release could drop without breaking a documented contract. Blocking would
262
+ // survive that (exit 2 is the block); the reason and the allow-tag guidance
263
+ // would not.
264
+ //
265
+ // When both channels carry text, stdout wins and stderr is discarded, so
266
+ // writing both would leave the documented one dead. Hence stderr alone.
267
+ // Wrapped, and the exit is outside it: a closed stderr made this write throw,
268
+ // and an exception exits 1, which passes the call through. The block became
269
+ // its opposite because the message could not be delivered.
270
+ try {
271
+ process.stderr.write(`${reasonLines.join("\n")}\n`);
272
+ } catch {
273
+ // Nothing to say and nowhere to say it. The verdict stands.
274
+ }
245
275
  process.exit(2);
246
276
  }
247
277
 
278
+ // Scan a piece of text and block if anything survives the tags.
279
+ //
280
+ // Five places did this — an environment variable's value, each end of a file, a
281
+ // Bash command line, and a command carried in a tool input — and each wrote out
282
+ // the same four steps and the same message shape, differing only in the source,
283
+ // the header and the hint. Gathered here so the shape is one thing rather than
284
+ // five things that agree for now.
285
+ //
286
+ // `[allow-secret]` never lifts a PII block, and what holds that is the
287
+ // deduplication key: it carries the category, so one value matched by a rule of
288
+ // each kind stays two findings and the tag removes only its own. Tagging before
289
+ // deduplicating gives the same answer — measured, by swapping the two and
290
+ // watching the suite stay green — and is kept because it is also the order that
291
+ // survives the key ever narrowing back to the value alone.
292
+ function scanTextAndBlock(
293
+ text: string,
294
+ source: string,
295
+ header: string,
296
+ hintContext: string,
297
+ allowTags: Set<string>,
298
+ ): void {
299
+ const findings = dedupeFindings(
300
+ applyAllowTags(scan(text, ENABLED_CATEGORIES), allowTags),
301
+ );
302
+ if (findings.length === 0) return;
303
+ block(
304
+ source,
305
+ [header, "", ...findingsToLines(findings)],
306
+ buildAllowHints(hintContext, findings),
307
+ );
308
+ }
309
+
248
310
  // ── Core scan logic ───────────────────────────────────────────────────────────
249
311
 
312
+ // Characters that make a token a pattern rather than a filename. `{` is here
313
+ // because the shell expands `{a,b}` too, and without it `cat .env{,.bak}`
314
+ // reaches the name guard as the single name `.env{`, which is nothing on disk.
315
+ const GLOB_METACHARACTERS = /[*?[{]/;
316
+
317
+ // How many matches of one pattern are scanned. This bounds the reading, not the
318
+ // walk: `globSync` builds the whole expansion before this takes a slice of it, so
319
+ // a pattern over a large tree still costs the walk.
320
+ const MAX_GLOB_MATCHES = 256;
321
+
322
+ // The paths a candidate stands for.
323
+ //
324
+ // A token carrying glob metacharacters names whatever the shell will expand it
325
+ // to, and the file is in the expansion rather than in the token. Without this,
326
+ // `cat sec*` collects `sec*`, finds no file by that name and allows the read;
327
+ // `cat .env*` does the same, one character away from `cat .env`, which is
328
+ // blocked on its name — the guard this hook is most sure of, a wildcard away
329
+ // from being skipped.
330
+ //
331
+ // Expanded here rather than in the tokenizer because it needs the filesystem,
332
+ // which is also why it can differ from what the shell will do a moment later.
333
+ function expandCandidate(candidate: string): string[] {
334
+ const literal = path.resolve(
335
+ baseDirectory,
336
+ expandPath(fromFileUrl(candidate)),
337
+ );
338
+ if (!GLOB_METACHARACTERS.test(literal)) return [literal];
339
+ // `**` matches across directories, and expanding it walks a whole tree:
340
+ // `cat ~/**/*` runs until the hook is killed, which is the failure this file
341
+ // spends a `stat` per path to avoid. Refusing the pattern outright is worse,
342
+ // since the shell still expands it and reads the files. Collapsed to one `*`,
343
+ // which costs a listing per matching directory the way every other pattern
344
+ // here does, and reaches one level rather than every level. Written up as a
345
+ // limitation.
346
+ const pattern = literal.replace(/\*{2,}/g, "*");
347
+ let matches: string[] = [];
348
+ try {
349
+ matches = fs.globSync(pattern).slice(0, MAX_GLOB_MATCHES);
350
+ } catch {
351
+ matches = [];
352
+ }
353
+ // The literal is kept as well as the expansion. Returning only the matches
354
+ // would be a way through that this hook did not have before the expansion
355
+ // existed: `cat /nonexistent/.env.*` matches nothing, so nothing would be
356
+ // scanned and the `.env` name guard — which reads the name, not the disk —
357
+ // would never run. A file really named `report[2].txt` goes the same way,
358
+ // since glob reads `[2]` as a character class and expands it to
359
+ // `report2.txt`.
360
+ return [literal, ...matches];
361
+ }
362
+
363
+ // A `file://` URI names a path, and MCP tools pass one under `uri` where another
364
+ // would pass `path`. Resolved as a relative path it named nothing, so it fell to
365
+ // the not-a-file check before the `.env` name guard could read it.
366
+ function fromFileUrl(candidate: string): string {
367
+ if (!candidate.startsWith("file://")) return candidate;
368
+ try {
369
+ return fileURLToPath(candidate);
370
+ } catch {
371
+ return candidate;
372
+ }
373
+ }
374
+
375
+ // `$VAR` and `${VAR}` in a path, substituted from this process's environment,
376
+ // which is the one the command will inherit.
377
+ //
378
+ // An unset variable is left as written rather than removed: a shell expands it
379
+ // to nothing, and the shortened path names a different file. Finding nothing is
380
+ // the safer of the two wrong answers.
381
+ function expandShellVars(candidate: string): string {
382
+ return candidate.replace(
383
+ /\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))/g,
384
+ (whole, braced: string | undefined, bare: string | undefined) =>
385
+ process.env[braced ?? bare ?? ""] ?? whole,
386
+ );
387
+ }
388
+
389
+ // A path as the shell will see it: variables substituted, then `~` and `~/…`
390
+ // resolved to the home directory. `~user/…` is left alone, since resolving it
391
+ // needs the password database.
392
+ function expandPath(candidate: string): string {
393
+ const expanded = expandShellVars(candidate);
394
+ if (expanded === "~") return os.homedir();
395
+ if (!expanded.startsWith("~/")) return expanded;
396
+ return path.join(os.homedir(), expanded.slice(2));
397
+ }
398
+
399
+ // The regular files directly inside a directory, for the tools that read a
400
+ // directory's contents — `grep -r`, and a Grep whose `path` names a folder.
401
+ //
402
+ // One level, and capped at the same limit a glob is, because the walk is the
403
+ // part that cannot be interrupted. `readdirSync` rather than a glob: `*` does
404
+ // not match a leading dot, and `.env` is the file this most needs to find.
405
+ //
406
+ // Binaries are skipped here, though a file the user names outright is still
407
+ // scanned whole. Nobody asked for these: they are swept up because the
408
+ // directory was named, and a folder of images cost three seconds and reported
409
+ // the compressed bytes as email addresses.
410
+ function filesDirectlyUnder(candidate: string): string[] {
411
+ try {
412
+ return (
413
+ fs
414
+ .readdirSync(candidate, { withFileTypes: true })
415
+ .filter((entry) => entry.isFile())
416
+ .slice(0, MAX_GLOB_MATCHES)
417
+ .map((entry) => path.join(candidate, entry.name))
418
+ // An `.env` is kept whatever its bytes look like, because `scanFile`
419
+ // judges it on its name when the contents cannot speak for it. Dropping
420
+ // it here runs first, so a binary-looking one would never reach that
421
+ // guard — and eight bytes of NUL are all it takes to look binary.
422
+ .filter((file) => isEnvName(file) || !looksBinary(file))
423
+ );
424
+ } catch {
425
+ // Not a directory, or not readable.
426
+ return [];
427
+ }
428
+ }
429
+
430
+ // Whether a path names something whose bytes can be read to the end.
431
+ //
432
+ // A character device or a FIFO can be opened and read from forever: `cat
433
+ // /dev/zero` never returns, and neither did the hook, until Claude Code's
434
+ // PreToolUse timeout killed it. A killed hook does not block the call, so a hang
435
+ // is a fail-open — the one failure mode worth spending a `stat` on every path to
436
+ // avoid.
437
+ function isDirectory(candidate: string): boolean {
438
+ try {
439
+ return fs.statSync(candidate).isDirectory();
440
+ } catch {
441
+ return false;
442
+ }
443
+ }
444
+
445
+ function isRegularFile(candidate: string): boolean {
446
+ try {
447
+ return fs.statSync(candidate).isFile();
448
+ } catch {
449
+ return false;
450
+ }
451
+ }
452
+
453
+ // Scan a candidate only when it names an existing regular file. A tool input
454
+ // whose "path" means something else (a URL route, an object key) names nothing on
455
+ // disk, so it is dropped here and never reaches the `.env` name guard — which is
456
+ // the difference from the Bash path, where a name that exists on no disk is still
457
+ // blocked. Existing files go on to `scanFile` and are name-guarded there.
458
+ function scanIfRegularFile(
459
+ candidate: string | undefined,
460
+ allowTags: Set<string>,
461
+ options: { namesOnly?: boolean } = {},
462
+ ): void {
463
+ if (!candidate) return;
464
+ for (const p of expandCandidate(candidate)) {
465
+ if (isRegularFile(p)) {
466
+ if (options.namesOnly)
467
+ blockUnreadEnvFile(p, allowTags, SEARCH_ROOT_ENV_REASON);
468
+ else scanFile(p, allowTags);
469
+ continue;
470
+ }
471
+ for (const child of filesDirectlyUnder(p)) {
472
+ if (options.namesOnly)
473
+ blockUnreadEnvFile(child, allowTags, SEARCH_ROOT_ENV_REASON);
474
+ else scanFile(child, allowTags);
475
+ }
476
+ }
477
+ }
478
+
479
+ const UNREAD_ENV_REASON =
480
+ "Its contents were not read — it is not a regular file, or the scan for this call had already stopped — so the name is what decides.";
481
+
482
+ const SEARCH_ROOT_ENV_REASON =
483
+ "The search names no path, so it runs here and prints from whatever it matches. This file was not read; it is the name that decides.";
484
+
485
+ // A `.env` file that will not be read is judged on its name, template or not.
486
+ function blockUnreadEnvFile(
487
+ filePath: string,
488
+ allowTags: Set<string>,
489
+ reason: string = UNREAD_ENV_REASON,
490
+ ): void {
491
+ if (!isEnvName(filePath) || !ENABLED_CATEGORIES.has("secret")) return;
492
+ // A path that names nothing has nothing to leak. `cat .env.example` in a
493
+ // checkout without one is an ordinary command, and the plain `.env` guard
494
+ // above already decides the names that are blocked whether they exist or not.
495
+ if (!fs.existsSync(filePath)) return;
496
+ if (allowTags.has("secret") || allowTags.has("all")) return;
497
+ block(
498
+ filePath,
499
+ [ENV_BLOCK_REASON, "", reason],
500
+ buildAllowHints(`please read ${forOutput(filePath)}`, [], true),
501
+ );
502
+ }
503
+
504
+ // The variables a command puts in play: the ones it names, plus the ones the
505
+ // command line references directly. A bare `env` or `printenv` prints
506
+ // everything, so there the answer is every variable there is.
507
+ //
508
+ // Asked in two places — the Bash branch and the tool that runs a shell through
509
+ // an input field — and a difference between them would be a difference in what
510
+ // each of those two paths protects.
511
+ function environmentNamed(commandText: string, refs: CommandRefs): string[] {
512
+ if (refs.dumpsEnvironment) return Object.keys(process.env);
513
+ return [...new Set([...extractEnvVarNames(commandText), ...refs.envVars])];
514
+ }
515
+
516
+ // The values a command would print, whichever tool is running it.
517
+ function scanEnvironment(names: string[], allowTags: Set<string>): void {
518
+ for (const varName of names) {
519
+ const value = process.env[varName];
520
+ if (!value) continue;
521
+ scanTextAndBlock(
522
+ value,
523
+ "bash command",
524
+ `🚫 Blocked: environment variable $${varName} contains sensitive data`,
525
+ "please run the command",
526
+ allowTags,
527
+ );
528
+ }
529
+ }
530
+
531
+ // Files named outright, then the ones a pattern has to be expanded to find.
532
+ //
533
+ // Expanding a pattern costs what the walk costs and cannot be interrupted, so a
534
+ // deep one spent the call's whole deadline — and every file named after it in
535
+ // the same command went unscanned. `cat ~/*/*/*/*/* secrets` read nothing of
536
+ // `secrets`. Ordering does not make the walk cheaper; it stops one token
537
+ // starving the rest.
538
+ function scanPathsLiteralsFirst(
539
+ paths: string[],
540
+ allowTags: Set<string>,
541
+ opts?: { onlyExisting?: boolean },
542
+ ): void {
543
+ const scanOne = (candidate: string): void => {
544
+ if (opts?.onlyExisting) {
545
+ scanIfRegularFile(candidate, allowTags);
546
+ return;
547
+ }
548
+ scanFile(candidate, allowTags);
549
+ // `grep -r AKIA .` names a directory, and `scanFile` has nothing to read
550
+ // from one.
551
+ for (const child of filesDirectlyUnder(candidate)) {
552
+ scanFile(child, allowTags);
553
+ }
554
+ };
555
+ const patterns: string[] = [];
556
+ for (const candidate of paths) {
557
+ if (GLOB_METACHARACTERS.test(candidate)) patterns.push(candidate);
558
+ else for (const p of expandCandidate(candidate)) scanOne(p);
559
+ }
560
+ for (const candidate of patterns) {
561
+ for (const p of expandCandidate(candidate)) scanOne(p);
562
+ }
563
+ }
564
+
250
565
  function scanFile(filePath: string, allowTags: Set<string>): void {
251
566
  if (shouldBlockEnvFile(filePath)) {
252
- if (allowTags.size > 0) return;
567
+ // The guard is a secret guard — `shouldBlockEnvFile` already asks whether the
568
+ // secret category is on — so the tag that lifts it has to be one that allows
569
+ // secrets. Asking only whether any tag was present let `[allow-banana]` and
570
+ // a mistyped `[allow-pi]` turn it off.
571
+ //
572
+ // Lifting the name guard is not permission to skip the file: `[allow-secret]`
573
+ // says nothing about the PII in it, and returning here skipped the content
574
+ // scan along with the name. So this falls through, and `applyAllowTags`
575
+ // below drops the findings the tag really covers.
576
+ if (!allowTags.has("secret") && !allowTags.has("all")) {
577
+ block(
578
+ filePath,
579
+ [ENV_BLOCK_REASON],
580
+ buildAllowHints(`please read ${forOutput(filePath)}`, [], true),
581
+ );
582
+ }
583
+ }
584
+
585
+ // The name guard above runs first and on the name alone, so `cat .env.missing`
586
+ // is still blocked. Everything past here opens the file.
587
+ //
588
+ // Each of these is a way of not reading it: something that is not a regular
589
+ // file, a budget already spent, a deadline already passed. For a `.env` name
590
+ // the contents were what the template exemption relied on, so when they are
591
+ // not going to be read the name decides — otherwise naming enough large files
592
+ // first was a way past the guard, and so was a FIFO called `.env.x.example`.
593
+ if (!isRegularFile(filePath)) {
594
+ blockUnreadEnvFile(filePath, allowTags);
595
+ return;
596
+ }
597
+ if (scanned.has(filePath)) return;
598
+ scanned.add(filePath);
599
+ if (bytesScanned >= MAX_TOTAL_SCAN_BYTES) {
600
+ blockUnreadEnvFile(filePath, allowTags);
601
+ return;
602
+ }
603
+ if (Date.now() > DEADLINE) {
604
+ blockUnreadEnvFile(filePath, allowTags);
605
+ return;
606
+ }
607
+
608
+ let content: string;
609
+ let tailContent = "";
610
+ let readWasPartial = false;
611
+ try {
612
+ // The buffer is the cap, not the reported size. Sizing it from `stat` made
613
+ // the read believe the file: procfs and sysfs entries are regular files that
614
+ // report a size of zero and produce content anyway, so their content was
615
+ // read as empty and passed. `readFileSync` handled that case by reading to
616
+ // EOF, and replacing it took the handling with it.
617
+ //
618
+ // The cap is what bounds it: the first megabyte and, on a larger file, the
619
+ // last. Every run of text between NUL separators inside those windows is
620
+ // scanned, so a NUL-separated file such as `/proc/self/environ` is read
621
+ // through rather than cut at its first entry.
622
+ const buf = Buffer.alloc(MAX_FILE_SCAN_BYTES);
623
+ const fd = fs.openSync(filePath, "r");
624
+ let raw: Buffer;
625
+ let tail: Buffer | null = null;
626
+ try {
627
+ const bytesRead = fs.readSync(fd, buf, 0, buf.length, 0);
628
+ raw = buf.subarray(0, bytesRead);
629
+ // The end as well as the beginning, when there is more than the cap
630
+ // between them. `tail -2 app.log` prints the last two lines and the cap
631
+ // reads the first megabyte, so on a large log the scan looked at exactly
632
+ // the part that was not shown — and the last lines of a log are where a
633
+ // failure has just printed a connection string.
634
+ const size = fs.fstatSync(fd).size;
635
+ if (size > MAX_FILE_SCAN_BYTES) {
636
+ const end = Buffer.alloc(MAX_FILE_SCAN_BYTES);
637
+ const read = fs.readSync(
638
+ fd,
639
+ end,
640
+ 0,
641
+ end.length,
642
+ size - MAX_FILE_SCAN_BYTES,
643
+ );
644
+ tail = end.subarray(0, read);
645
+ }
646
+ } finally {
647
+ fs.closeSync(fd);
648
+ }
649
+ bytesScanned += raw.length;
650
+ if (tail !== null) {
651
+ bytesScanned += tail.length;
652
+ const tailUtf16 = detectUtf16(tail);
653
+ const windows: string[] = [];
654
+ if (tailUtf16 !== null) {
655
+ const text = tailUtf16.bytes.toString("utf16le");
656
+ // Past the last NUL rather than up to the first: this window is the end
657
+ // of the file, so what follows a separator is the part that gets
658
+ // printed.
659
+ const nul = text.lastIndexOf("\0");
660
+ windows.push(nul === -1 ? text : text.slice(nul + 1));
661
+ }
662
+ if (tailUtf16 === null || !tailUtf16.fromBom)
663
+ windows.push(utf8Runs(tail));
664
+ tailContent = windows.join("\n");
665
+ }
666
+ // UTF-16 puts a NUL in every other byte, so the rule below stopped after one
667
+ // character and the file went through unread. PowerShell 5.1 writes UTF-16LE
668
+ // by default, which makes `Get-Something > creds.txt` a file this tool did
669
+ // not look at. Detected by the byte-order mark, or by NULs falling on one
670
+ // side of every pair through the prefix.
671
+ const utf16 = detectUtf16(raw);
672
+ // Whether the file was read to its end. Recorded here and acted on below,
673
+ // outside the catch: `block` exits the process, and anything it threw on the
674
+ // way — a closed stderr, say — would be swallowed by the `catch` and turn a
675
+ // block into a pass.
676
+ //
677
+ // A NUL counts as partial for the `.env` template guard below even though
678
+ // every run is scanned: the guard turns on whether the contents can speak
679
+ // for the name, and a file that is part binary cannot. The NUL that counts
680
+ // is one in the text, not one in the bytes — UTF-16 is half NUL by
681
+ // construction, and reading those bytes directly makes every UTF-16 file
682
+ // partial.
683
+ const hitTheCut = raw.length >= MAX_FILE_SCAN_BYTES;
684
+ if (utf16 === null) {
685
+ readWasPartial = raw.indexOf(0) !== -1 || hitTheCut;
686
+ content = utf8Runs(raw);
687
+ } else {
688
+ const text = utf16.bytes.toString("utf16le").replace(/^\uFEFF/, "");
689
+ const nul = text.indexOf("\0");
690
+ readWasPartial = nul !== -1 || hitTheCut;
691
+ const decoded = nul === -1 ? text : text.split("\0").join("\n");
692
+ // Without a byte-order mark that reading is a guess, and the NUL counts
693
+ // it rests on cannot tell a UTF-8 file carrying a few NULs from a page of
694
+ // Japanese UTF-16. Both readings are scanned rather than one of them
695
+ // chosen: the cost is a second pass over the prefix, and what it buys is
696
+ // that a wrong guess hides nothing. Sixteen bytes of NUL in front of a
697
+ // file are otherwise enough to decode the rest of it out of reach of
698
+ // every rule.
699
+ content = utf16.fromBom ? decoded : `${decoded}\n${utf8Runs(raw)}`;
700
+ }
701
+ } catch {
702
+ return;
703
+ }
704
+
705
+ // Exempting a template name from the guard assumed the contents would be read
706
+ // instead. A NUL byte and the per-file cut both stop that, and a file named
707
+ // `.env.something.example` carrying either was passing on its name after all.
708
+ // When the read is partial, the name is what is left to decide on.
709
+ if (
710
+ readWasPartial &&
711
+ isEnvName(filePath) &&
712
+ ENABLED_CATEGORIES.has("secret") &&
713
+ !allowTags.has("secret") &&
714
+ !allowTags.has("all")
715
+ ) {
253
716
  block(
254
717
  filePath,
255
718
  [
256
- "🚫 Blocked: .env and .env.* files contain secrets and must not be read into the conversation.",
719
+ ENV_BLOCK_REASON,
720
+ "",
721
+ "This one is named as a template, which is normally read. It could not be read whole — it holds a NUL byte or runs past the scan limit — so the name is what decides.",
257
722
  ],
258
- buildAllowHints(`please read ${filePath}`, [], true),
723
+ buildAllowHints(`please read ${forOutput(filePath)}`, [], true),
259
724
  );
260
725
  }
261
726
 
262
- let content: string;
263
- try {
264
- const raw = fs.readFileSync(filePath);
265
- // Binary files: scan only the text prefix before the first NUL byte
266
- const nulIndex = raw.indexOf(0);
267
- content = (nulIndex === -1 ? raw : raw.subarray(0, nulIndex)).toString(
268
- "utf8",
727
+ // A second window over the same file, judged on its own: a finding in either
728
+ // end is a finding.
729
+ if (tailContent.length > 0) {
730
+ scanTextAndBlock(
731
+ tailContent,
732
+ filePath,
733
+ "🚫 Blocked: file contains sensitive data",
734
+ `please read ${forOutput(filePath)}`,
735
+ allowTags,
269
736
  );
270
- if (content.length === 0) return;
271
- } catch {
272
- return;
273
737
  }
274
738
 
275
- const findings = applyAllowTags(
276
- dedupeFindings(scan(content, ENABLED_CATEGORIES)),
277
- allowTags,
278
- );
279
- if (findings.length === 0) return;
739
+ if (content.length === 0) return;
280
740
 
281
- block(
741
+ scanTextAndBlock(
742
+ content,
282
743
  filePath,
283
- [
284
- "🚫 Blocked: file contains sensitive data",
285
- "",
286
- ...findingsToLines(findings),
287
- ],
288
- buildAllowHints(`please read ${filePath}`, findings),
744
+ "🚫 Blocked: file contains sensitive data",
745
+ `please read ${forOutput(filePath)}`,
746
+ allowTags,
289
747
  );
290
748
  }
291
749
 
@@ -295,14 +753,31 @@ let raw = "";
295
753
  process.stdin.setEncoding("utf8");
296
754
  process.stdin.on("data", (chunk: string) => (raw += chunk));
297
755
  process.stdin.on("end", () => {
756
+ startTheClock();
298
757
  let data: HookInput;
299
758
  try {
300
- data = JSON.parse(raw) as HookInput;
301
- } catch {
302
- process.exit(0);
759
+ // Empty stdin is nothing to check. Bytes that do not parse are a check that
760
+ // could not read its input, which is not the same as safe: two characters
761
+ // missing from the end of a payload are enough to hide a key.
762
+ if (raw.trim().length === 0) process.exit(0);
763
+ const parsed: unknown = JSON.parse(raw);
764
+ // `JSON.parse("null")` succeeds and returns null, which then threw on the
765
+ // first field read. A payload that is not an object names nothing, so
766
+ // there is nothing to scan and nothing to stop.
767
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
768
+ process.exit(0);
769
+ data = parsed as HookInput;
770
+ } catch (error) {
771
+ // The check never started, so it vouches for nothing. Everything else that
772
+ // cannot finish stops the call; input that will not parse is the same case.
773
+ failClosed(error);
303
774
  }
304
775
 
305
- const tool = data.tool_name ?? "";
776
+ const tool = typeof data.tool_name === "string" ? data.tool_name : "";
777
+ // Before anything resolves a path: a relative path in a command is relative to
778
+ // where Claude Code will run it, not to where this hook was started.
779
+ if (typeof data.cwd === "string" && data.cwd) baseDirectory = data.cwd;
780
+
306
781
  const input = data.tool_input ?? {};
307
782
 
308
783
  const allowTags = data.transcript_path
@@ -310,54 +785,155 @@ process.stdin.on("end", () => {
310
785
  : new Set<string>();
311
786
 
312
787
  if (tool === "Read") {
313
- scanFile(input.file_path ?? "", allowTags);
788
+ // Through the same expansion as everything else: this branch resolved
789
+ // nothing, so a relative `file_path`, a `~`, a `file://` URI and a pattern
790
+ // all named something that is not on disk.
791
+ //
792
+ // And through the same collector, so a `file_path` that is not a string is
793
+ // read rather than dropped. Coercing it to "" exited 0 here while every
794
+ // other tool name reached `collectPathFields` and blocked — the same shape,
795
+ // two answers.
796
+ for (const target of collectPathFields(input)) {
797
+ for (const candidate of expandCandidate(target)) {
798
+ scanFile(candidate, allowTags);
799
+ }
800
+ }
314
801
  process.exit(0);
315
802
  }
316
803
 
317
804
  if (tool === "Bash") {
318
- const command = input.command ?? "";
319
-
320
- for (const varName of extractEnvVarNames(command)) {
321
- const value = process.env[varName];
322
- if (!value) continue;
323
- const findings = applyAllowTags(
324
- dedupeFindings(scan(value, ENABLED_CATEGORIES)),
325
- allowTags,
326
- );
327
- if (findings.length === 0) continue;
328
- block(
329
- `bash command: ${command.slice(0, 80)}`,
330
- [
331
- `🚫 Blocked: environment variable $${varName} contains sensitive data`,
332
- "",
333
- ...findingsToLines(findings),
334
- ],
335
- buildAllowHints("please run the command", findings),
336
- );
805
+ // A tool input is whatever the tool declares, so a `command` that is not a
806
+ // string is a shape this really receives. Throwing on it exits 1, which does
807
+ // not block, so the call goes through unscanned.
808
+ const command = Array.isArray(input.command)
809
+ ? input.command
810
+ .filter((v): v is string => typeof v === "string")
811
+ .join(" ")
812
+ : typeof input.command === "string"
813
+ ? input.command
814
+ : "";
815
+ // `cd build && cat secrets` runs the read somewhere else, and the payload's
816
+ // cwd is where the command starts rather than where it ends up.
817
+ //
818
+ // Only a `cd` before the first other command counts. Folding every `cd` in
819
+ // the line moved the base for reads that happen earlier — `cat secrets && cd
820
+ // /tmp` resolved against `/tmp` and found nothing — and for a `cd` inside a
821
+ // subshell, which the shell undoes on the way out. The tokenizer ends a
822
+ // segment at a paren, so a subshell's `cd` is not distinguishable once split;
823
+ // a command that opens with one is left resolving against the payload's cwd.
824
+ if (!command.trimStart().startsWith("(")) {
825
+ for (const segment of tokenizeCommand(command)) {
826
+ const [head, target] = segment;
827
+ // `head.redirect` is defensive rather than reachable: the tokenizer
828
+ // marks a token as a redirect only when it built it from `<` or `>`, so
829
+ // no input produces one whose value is `cd`. Kept because the guard
830
+ // costs nothing and the tokenizer is free to change.
831
+ if (head?.value !== "cd" || head.redirect) break;
832
+ if (target === undefined || target.redirect) break;
833
+ // `cd -`, `cd b*ld` and a `cd` into an unset variable name a directory
834
+ // this cannot work out. Following one anyway moves the base somewhere
835
+ // the command will not read, and every relative path after it resolves
836
+ // against the wrong directory.
837
+ const destination = expandPath(target.value);
838
+ if (
839
+ target.value === "-" ||
840
+ target.value === "--" ||
841
+ destination.includes("$") ||
842
+ GLOB_METACHARACTERS.test(destination)
843
+ ) {
844
+ break;
845
+ }
846
+ // A `cd` the shell will fail is a `cd` that does not happen. Following
847
+ // it moved the base somewhere neither the command nor this will read.
848
+ const moved = path.resolve(baseDirectory, destination);
849
+ if (!isDirectory(moved)) break;
850
+ baseDirectory = moved;
851
+ }
337
852
  }
853
+ const refs = extractCommandRefs(command);
854
+
855
+ scanEnvironment(environmentNamed(command, refs), allowTags);
338
856
 
339
- const cmdFindings = applyAllowTags(
340
- dedupeFindings(scan(command, ENABLED_CATEGORIES)),
857
+ scanTextAndBlock(
858
+ command,
859
+ "bash command",
860
+ "🚫 Blocked: bash command contains sensitive data",
861
+ "please run the command",
341
862
  allowTags,
342
863
  );
343
- if (cmdFindings.length > 0) {
344
- block(
345
- `bash command: ${command.slice(0, 80)}`,
346
- [
347
- "🚫 Blocked: bash command contains sensitive data",
348
- "",
349
- ...findingsToLines(cmdFindings),
350
- ],
351
- buildAllowHints("please run the command", cmdFindings),
352
- );
353
- }
354
864
 
355
- for (const fp of extractFilePathsFromCommand(command)) {
356
- scanFile(fp, allowTags);
865
+ scanPathsLiteralsFirst(refs.paths, allowTags);
866
+ // `rg PATTERN` and `grep -r PATTERN` name nothing and print from the working
867
+ // directory. Judged on names alone, for the reason set out where the same
868
+ // thing is done for the search tools.
869
+ if (refs.searchesWorkingDirectory) {
870
+ scanIfRegularFile(baseDirectory, allowTags, { namesOnly: true });
357
871
  }
358
872
 
359
873
  process.exit(0);
360
874
  }
361
875
 
876
+ // Every other tool, Grep and the MCP tools included: those can return file
877
+ // contents the same way Read does, so a field naming an existing file is
878
+ // scanned before the call. Grep needs no branch of its own — its `path` is one
879
+ // of the fields collected here, and it is neither exempt nor named as a writer.
880
+ if (!TOOLS_WITHOUT_FILE_OUTPUT.has(tool)) {
881
+ // An MCP server that runs a shell takes the command as an input field, and
882
+ // an IDE bridge takes code the same way. Only `Bash` was read as a command,
883
+ // so `mcp__desktop-commander__start_process` with `{"command":"cat .env"}`
884
+ // was looked at as a path, found not to be a file, and let through — with
885
+ // the default matcher sending every `mcp__*` tool here.
886
+ for (const value of collectCommandFields(input)) {
887
+ // What the command says, as well as what it opens. A key in an argument
888
+ // list is a key whichever tool runs the shell.
889
+ scanTextAndBlock(
890
+ value,
891
+ `${tool} command`,
892
+ "🚫 Blocked: tool command contains sensitive data",
893
+ "please run the command",
894
+ allowTags,
895
+ );
896
+
897
+ const refs = extractCommandRefs(value);
898
+ // Code is not a command line: `print(open("secrets").read())` has no
899
+ // command in it this can classify, and the path is a quoted literal — the
900
+ // same shape inline `-c` text is read for.
901
+ refs.paths.push(...extractQuotedLiterals(value));
902
+ scanPathsLiteralsFirst(refs.paths, allowTags, { onlyExisting: true });
903
+
904
+ // A command names an environment as readily as a file. The Bash branch
905
+ // has always scanned the variables a command would print; a tool that
906
+ // runs a shell was collected for paths and nothing else, so
907
+ // `{"command":"printenv"}` handed the environment back whole.
908
+ scanEnvironment(environmentNamed(value, refs), allowTags);
909
+ }
910
+
911
+ // The write-verb exemption is about a tool's *output*: naming a file it only
912
+ // writes to is not a leak. A command is not output — `create_process` runs
913
+ // what it is handed — so the command fields above are read whatever the name
914
+ // says, and only the path fields are exempt.
915
+ if (!isWritingTool(tool)) {
916
+ const candidates = collectPathFields(input);
917
+ for (const candidate of candidates) {
918
+ scanIfRegularFile(candidate, allowTags);
919
+ }
920
+ // A search tool given no path searches where it is run, and that is its
921
+ // ordinary form: `Grep {pattern}` with no `path` is what Claude reaches
922
+ // for first. With no field to collect there is nothing to scan, so the
923
+ // directory the search prints from is the one directory never looked at.
924
+ //
925
+ // Names only. A directory the user pointed at is one they asked about, and
926
+ // reading its files is answering the question they asked; a directory
927
+ // nobody named is every repository anyone searches, and content-scanning
928
+ // those stopped an ordinary `rg TODO` in a third of the checkouts on this
929
+ // machine — a README quoting a connection string is enough. The name guard
930
+ // keeps the case worth keeping, since a `.env` sitting in the search root
931
+ // is both the likeliest leak and the one no pattern has to match for.
932
+ if (candidates.length === 0 && searchesWithoutAPath(tool, input)) {
933
+ scanIfRegularFile(baseDirectory, allowTags, { namesOnly: true });
934
+ }
935
+ }
936
+ }
937
+
362
938
  process.exit(0);
363
939
  });