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