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