@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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +798 -0
- package/README.md +142 -45
- package/dist/lib/bash-commands.js +405 -0
- package/dist/lib/command-tables.js +462 -0
- package/dist/lib/default-config.json +570 -0
- package/dist/lib/encoding.js +123 -0
- package/dist/lib/fail-closed.js +31 -0
- package/dist/lib/inspector.js +0 -0
- package/dist/lib/rules.js +399 -0
- package/dist/lib/shapes.js +161 -0
- package/dist/lib/shell.js +436 -0
- package/dist/lib/tool-inputs.js +217 -0
- package/dist/lib/transcript.js +115 -0
- package/dist/lib/validators.js +435 -0
- package/dist/pre-tool-use-hook.js +773 -0
- package/dist/user-prompt-submit-hook.js +105 -0
- package/hooks/hooks.json +1 -1
- package/package.json +25 -11
- package/src/lib/bash-commands.ts +455 -0
- package/src/lib/command-tables.ts +518 -0
- package/src/lib/default-config.json +155 -46
- package/src/lib/encoding.ts +135 -0
- package/src/lib/fail-closed.ts +36 -0
- package/src/lib/inspector.ts +0 -0
- package/src/lib/rules.ts +202 -365
- package/src/lib/shapes.ts +175 -0
- package/src/lib/shell.ts +512 -0
- package/src/lib/tool-inputs.ts +235 -0
- package/src/lib/transcript.ts +142 -0
- package/src/lib/validators.ts +435 -0
- package/src/pre-tool-use-hook.ts +774 -198
- package/src/user-prompt-submit-hook.ts +60 -18
- package/src/__tests__/pre-tool-use-hook.test.ts +0 -779
- package/src/__tests__/user-prompt-submit-hook.test.ts +0 -297
- package/src/lib/__tests__/inspector.test.ts +0 -289
- 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
|
+
});
|