@wyattjoh/demur 0.1.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/LICENSE +21 -0
- package/README.md +200 -0
- package/extensions/demur/index.ts +63 -0
- package/package.json +70 -0
- package/src/adapters/claude-code.ts +71 -0
- package/src/analyze.ts +621 -0
- package/src/cli.ts +41 -0
- package/src/guard.internal.ts +164 -0
- package/src/guard.ts +84 -0
- package/src/judge.ts +233 -0
- package/src/key.ts +45 -0
- package/src/policy.ts +215 -0
- package/src/questions.ts +70 -0
- package/src/state.ts +287 -0
- package/src/types.ts +153 -0
package/src/analyze.ts
ADDED
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a path points at, decided by code rather than by the model.
|
|
3
|
+
*
|
|
4
|
+
* Classification is a *fact* ("this resolves inside the OS temp directory"),
|
|
5
|
+
* not a policy ("therefore allow it"). The model still decides what the fact
|
|
6
|
+
* means; code just stops making it guess at string manipulation it cannot do.
|
|
7
|
+
*/
|
|
8
|
+
export type PathClass =
|
|
9
|
+
| "temp"
|
|
10
|
+
| "system"
|
|
11
|
+
| "filesystem-root"
|
|
12
|
+
| "home-root"
|
|
13
|
+
| "home"
|
|
14
|
+
| "inside-working-directory"
|
|
15
|
+
| "relative"
|
|
16
|
+
| "remote"
|
|
17
|
+
| "unresolved-variable"
|
|
18
|
+
| "glob"
|
|
19
|
+
| "unknown";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A path found in a command, with what code could determine about it.
|
|
23
|
+
*/
|
|
24
|
+
export type AnalyzedPath = {
|
|
25
|
+
/**
|
|
26
|
+
* The path exactly as it appeared, after quote removal.
|
|
27
|
+
*/
|
|
28
|
+
value: string;
|
|
29
|
+
/**
|
|
30
|
+
* The path with `~`, known variables, and `..` resolved, when possible.
|
|
31
|
+
*/
|
|
32
|
+
resolved: string | undefined;
|
|
33
|
+
/**
|
|
34
|
+
* What the resolved path points at.
|
|
35
|
+
*/
|
|
36
|
+
class: PathClass;
|
|
37
|
+
/**
|
|
38
|
+
* Whether the path as written contained a variable expansion, whatever it
|
|
39
|
+
* resolved to here.
|
|
40
|
+
*
|
|
41
|
+
* `$TMPDIR/build` can resolve to a temp directory on one machine and anywhere
|
|
42
|
+
* else on another. Resolution at judgment time therefore proves nothing about
|
|
43
|
+
* resolution at execution time.
|
|
44
|
+
*/
|
|
45
|
+
variableRooted: boolean;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* One command in a compound command line.
|
|
50
|
+
*/
|
|
51
|
+
export type Segment = {
|
|
52
|
+
/**
|
|
53
|
+
* The segment text as written.
|
|
54
|
+
*/
|
|
55
|
+
raw: string;
|
|
56
|
+
/**
|
|
57
|
+
* The program actually being invoked, after stripping wrappers, environment
|
|
58
|
+
* assignments, and any leading directory.
|
|
59
|
+
*
|
|
60
|
+
* This is the field that stops `transform data` from reading as `rm`.
|
|
61
|
+
*/
|
|
62
|
+
argv0: string | undefined;
|
|
63
|
+
/**
|
|
64
|
+
* Wrapper programs stripped to find `argv0`, such as `env`, `sudo`, `time`.
|
|
65
|
+
*/
|
|
66
|
+
wrappers: string[];
|
|
67
|
+
/**
|
|
68
|
+
* Arguments after `argv0`, with quotes removed.
|
|
69
|
+
*/
|
|
70
|
+
args: string[];
|
|
71
|
+
/**
|
|
72
|
+
* Paths found among the arguments.
|
|
73
|
+
*/
|
|
74
|
+
paths: AnalyzedPath[];
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A heredoc found in the command.
|
|
79
|
+
*/
|
|
80
|
+
export type Heredoc = {
|
|
81
|
+
/**
|
|
82
|
+
* The delimiter word.
|
|
83
|
+
*/
|
|
84
|
+
tag: string;
|
|
85
|
+
/**
|
|
86
|
+
* Whether the delimiter was quoted, which suppresses shell expansion in the
|
|
87
|
+
* body and is strong evidence the body is inert data.
|
|
88
|
+
*/
|
|
89
|
+
quoted: boolean;
|
|
90
|
+
/**
|
|
91
|
+
* The program the body is fed to, when one could be determined.
|
|
92
|
+
*/
|
|
93
|
+
consumer: string | undefined;
|
|
94
|
+
/**
|
|
95
|
+
* The body text.
|
|
96
|
+
*/
|
|
97
|
+
body: string;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Everything code can determine about a command without running it.
|
|
102
|
+
*/
|
|
103
|
+
export type CommandAnalysis = {
|
|
104
|
+
/**
|
|
105
|
+
* Commands in the line, split on shell operators and unwrapped from
|
|
106
|
+
* groupings and control flow.
|
|
107
|
+
*/
|
|
108
|
+
segments: Segment[];
|
|
109
|
+
/**
|
|
110
|
+
* Commands found inside `$(...)` or backticks. These execute, and are easy to
|
|
111
|
+
* miss when reading the outer command as text.
|
|
112
|
+
*/
|
|
113
|
+
substitutions: string[];
|
|
114
|
+
/**
|
|
115
|
+
* Heredocs, whose bodies are usually data rather than commands.
|
|
116
|
+
*/
|
|
117
|
+
heredocs: Heredoc[];
|
|
118
|
+
/**
|
|
119
|
+
* Whether anything in the command failed to parse cleanly.
|
|
120
|
+
*/
|
|
121
|
+
parsedCleanly: boolean;
|
|
122
|
+
/**
|
|
123
|
+
* Whether what this command will actually do cannot be determined from its
|
|
124
|
+
* text alone.
|
|
125
|
+
*
|
|
126
|
+
* True when a target path is variable-rooted or glob-expanded, when the
|
|
127
|
+
* command runs something from a substitution, or when it did not parse. These
|
|
128
|
+
* are the cases where a judgment reads the command's *apparent* meaning while
|
|
129
|
+
* its real meaning depends on the environment at execution time.
|
|
130
|
+
*/
|
|
131
|
+
staticallyUnresolvable: boolean;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Programs that pass execution through to another program, hiding the real
|
|
136
|
+
* `argv0` behind themselves.
|
|
137
|
+
*/
|
|
138
|
+
const WRAPPERS = new Set([
|
|
139
|
+
"env",
|
|
140
|
+
"sudo",
|
|
141
|
+
"doas",
|
|
142
|
+
"time",
|
|
143
|
+
"nohup",
|
|
144
|
+
"nice",
|
|
145
|
+
"ionice",
|
|
146
|
+
"command",
|
|
147
|
+
"builtin",
|
|
148
|
+
"exec",
|
|
149
|
+
"setsid",
|
|
150
|
+
"stdbuf",
|
|
151
|
+
"timeout",
|
|
152
|
+
"xargs",
|
|
153
|
+
"watch",
|
|
154
|
+
]);
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Shell reserved words that can precede a simple command inside control flow.
|
|
158
|
+
*/
|
|
159
|
+
const KEYWORDS = new Set([
|
|
160
|
+
"if",
|
|
161
|
+
"then",
|
|
162
|
+
"else",
|
|
163
|
+
"elif",
|
|
164
|
+
"fi",
|
|
165
|
+
"do",
|
|
166
|
+
"done",
|
|
167
|
+
"while",
|
|
168
|
+
"until",
|
|
169
|
+
"for",
|
|
170
|
+
"case",
|
|
171
|
+
"esac",
|
|
172
|
+
"select",
|
|
173
|
+
"function",
|
|
174
|
+
"{",
|
|
175
|
+
"}",
|
|
176
|
+
"!",
|
|
177
|
+
]);
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Extract heredocs and replace their bodies with a placeholder.
|
|
181
|
+
*
|
|
182
|
+
* Bodies are pulled out before tokenizing so that a `rm -rf /` sitting inside a
|
|
183
|
+
* quoted heredoc body is never mistaken for a token of the command itself.
|
|
184
|
+
*
|
|
185
|
+
* @param command - The raw command line
|
|
186
|
+
* @returns The heredocs found and the command with bodies removed
|
|
187
|
+
*/
|
|
188
|
+
function extractHeredocs(command: string): {
|
|
189
|
+
heredocs: Heredoc[];
|
|
190
|
+
stripped: string;
|
|
191
|
+
} {
|
|
192
|
+
const heredocs: Heredoc[] = [];
|
|
193
|
+
const lines = command.split("\n");
|
|
194
|
+
const kept: string[] = [];
|
|
195
|
+
|
|
196
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
197
|
+
const line = lines[i];
|
|
198
|
+
if (line === undefined) continue;
|
|
199
|
+
|
|
200
|
+
const match = /<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/.exec(line);
|
|
201
|
+
if (match === null) {
|
|
202
|
+
kept.push(line);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const tag = match[2] ?? "";
|
|
207
|
+
const quoted = match[1] !== "";
|
|
208
|
+
const consumer = /^\s*([A-Za-z0-9_./-]+)/.exec(line)?.[1];
|
|
209
|
+
|
|
210
|
+
const body: string[] = [];
|
|
211
|
+
let j = i + 1;
|
|
212
|
+
for (; j < lines.length; j += 1) {
|
|
213
|
+
const candidate = lines[j];
|
|
214
|
+
if (candidate === undefined) break;
|
|
215
|
+
if (candidate.trim() === tag) break;
|
|
216
|
+
body.push(candidate);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
heredocs.push({ tag, quoted, consumer, body: body.join("\n") });
|
|
220
|
+
kept.push(line.replace(match[0], "<<HEREDOC_BODY"));
|
|
221
|
+
i = j;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return { heredocs, stripped: kept.join("\n") };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Collect the commands inside `$(...)` and backtick substitutions.
|
|
229
|
+
*
|
|
230
|
+
* @param text - Command text to scan
|
|
231
|
+
* @returns The inner command strings
|
|
232
|
+
*/
|
|
233
|
+
function extractSubstitutions(text: string): string[] {
|
|
234
|
+
const found: string[] = [];
|
|
235
|
+
|
|
236
|
+
for (const m of text.matchAll(/\$\(([^()]*)\)/g)) {
|
|
237
|
+
const inner = m[1]?.trim();
|
|
238
|
+
if (inner) found.push(inner);
|
|
239
|
+
}
|
|
240
|
+
for (const m of text.matchAll(/`([^`]*)`/g)) {
|
|
241
|
+
const inner = m[1]?.trim();
|
|
242
|
+
if (inner) found.push(inner);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return found;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Split a command line into segments on shell operators.
|
|
250
|
+
*
|
|
251
|
+
* Also unwraps groupings and drops control-flow keywords, so that
|
|
252
|
+
* `(chmod -R 755 /etc)` and `if true; then chmod -R 755 /etc; fi` both yield a
|
|
253
|
+
* segment whose `argv0` is `chmod` rather than `(chmod` or `then`.
|
|
254
|
+
*
|
|
255
|
+
* @param text - Command text with heredoc bodies already removed
|
|
256
|
+
* @returns Segment strings, trimmed and non-empty
|
|
257
|
+
*/
|
|
258
|
+
function splitSegments(text: string): string[] {
|
|
259
|
+
const parts: string[] = [];
|
|
260
|
+
let current = "";
|
|
261
|
+
let quote: string | undefined;
|
|
262
|
+
|
|
263
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
264
|
+
const ch = text[i];
|
|
265
|
+
if (ch === undefined) continue;
|
|
266
|
+
|
|
267
|
+
if (quote !== undefined) {
|
|
268
|
+
current += ch;
|
|
269
|
+
if (ch === quote) quote = undefined;
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (ch === "'" || ch === '"') {
|
|
274
|
+
quote = ch;
|
|
275
|
+
current += ch;
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const next = text[i + 1];
|
|
280
|
+
if ((ch === "&" && next === "&") || (ch === "|" && next === "|")) {
|
|
281
|
+
parts.push(current);
|
|
282
|
+
current = "";
|
|
283
|
+
i += 1;
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (ch === ";" || ch === "|" || ch === "\n" || ch === "&") {
|
|
287
|
+
parts.push(current);
|
|
288
|
+
current = "";
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
current += ch;
|
|
292
|
+
}
|
|
293
|
+
parts.push(current);
|
|
294
|
+
|
|
295
|
+
return parts
|
|
296
|
+
.map((p) => p.trim().replace(/^[({]\s*/, "").replace(/\s*[)}]$/, "").trim())
|
|
297
|
+
.filter((p) => p !== "");
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Split a segment into tokens, removing quotes.
|
|
302
|
+
*
|
|
303
|
+
* Quote removal is what collapses `rm -rf''` and `rm -r'f` back to `rm -rf`.
|
|
304
|
+
* A backslash is treated as a literal character rather than an escape when it
|
|
305
|
+
* is followed by a non-space, so Windows paths such as `C:\Users\u` survive
|
|
306
|
+
* tokenizing intact.
|
|
307
|
+
*
|
|
308
|
+
* An unterminated quote is reported rather than silently swallowed: `rm -r'f /`
|
|
309
|
+
* is not a valid command, and saying so is more useful to the model than
|
|
310
|
+
* inventing a plausible tokenization for it.
|
|
311
|
+
*
|
|
312
|
+
* @param segment - A single command segment
|
|
313
|
+
* @returns Tokens with quotes stripped, and whether a quote was left open
|
|
314
|
+
*/
|
|
315
|
+
function tokenize(segment: string): { tokens: string[]; unterminated: boolean } {
|
|
316
|
+
const tokens: string[] = [];
|
|
317
|
+
let current = "";
|
|
318
|
+
let quote: string | undefined;
|
|
319
|
+
let started = false;
|
|
320
|
+
|
|
321
|
+
for (let i = 0; i < segment.length; i += 1) {
|
|
322
|
+
const ch = segment[i];
|
|
323
|
+
if (ch === undefined) continue;
|
|
324
|
+
|
|
325
|
+
if (quote !== undefined) {
|
|
326
|
+
if (ch === quote) quote = undefined;
|
|
327
|
+
else current += ch;
|
|
328
|
+
started = true;
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (ch === "'" || ch === '"') {
|
|
333
|
+
quote = ch;
|
|
334
|
+
started = true;
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (ch === "\\" && segment[i + 1] === " ") {
|
|
339
|
+
current += " ";
|
|
340
|
+
i += 1;
|
|
341
|
+
started = true;
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (/\s/.test(ch)) {
|
|
346
|
+
if (started) tokens.push(current);
|
|
347
|
+
current = "";
|
|
348
|
+
started = false;
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
current += ch;
|
|
353
|
+
started = true;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (started) tokens.push(current);
|
|
357
|
+
return { tokens, unterminated: quote !== undefined };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Find the program a segment actually invokes.
|
|
362
|
+
*
|
|
363
|
+
* Drops leading `VAR=value` assignments and shell keywords, unwraps wrapper
|
|
364
|
+
* programs, and reduces a path-qualified program to its base name so that
|
|
365
|
+
* `/usr/bin/git` and `git` read identically.
|
|
366
|
+
*
|
|
367
|
+
* @param tokens - Tokens of one segment
|
|
368
|
+
* @returns The resolved program name, the wrappers stripped, and the remaining arguments
|
|
369
|
+
*/
|
|
370
|
+
function resolveArgv0(tokens: string[]): {
|
|
371
|
+
argv0: string | undefined;
|
|
372
|
+
wrappers: string[];
|
|
373
|
+
args: string[];
|
|
374
|
+
} {
|
|
375
|
+
const wrappers: string[] = [];
|
|
376
|
+
let rest = [...tokens];
|
|
377
|
+
|
|
378
|
+
while (rest.length > 0) {
|
|
379
|
+
const head = rest[0];
|
|
380
|
+
if (head === undefined) break;
|
|
381
|
+
|
|
382
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(head)) {
|
|
383
|
+
rest = rest.slice(1);
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
if (KEYWORDS.has(head)) {
|
|
387
|
+
rest = rest.slice(1);
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const base = head.split(/[/\\]/).pop() ?? head;
|
|
392
|
+
if (WRAPPERS.has(base)) {
|
|
393
|
+
wrappers.push(base);
|
|
394
|
+
rest = rest.slice(1);
|
|
395
|
+
// `env -C dir cmd` and `timeout 5s cmd`: skip the wrapper's own options
|
|
396
|
+
// and their values so the next word really is the wrapped program.
|
|
397
|
+
while (rest.length > 0) {
|
|
398
|
+
const opt = rest[0];
|
|
399
|
+
if (opt === undefined) break;
|
|
400
|
+
if (opt.startsWith("-")) {
|
|
401
|
+
rest = rest.slice(1);
|
|
402
|
+
const value = rest[0];
|
|
403
|
+
if (value !== undefined && !value.startsWith("-") && opt.length === 2) {
|
|
404
|
+
rest = rest.slice(1);
|
|
405
|
+
}
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(opt)) {
|
|
409
|
+
rest = rest.slice(1);
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
if (/^\d+[smh]?$/.test(opt) && wrappers.includes("timeout")) {
|
|
413
|
+
rest = rest.slice(1);
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
break;
|
|
417
|
+
}
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return { argv0: base, wrappers, args: rest.slice(1) };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return { argv0: undefined, wrappers, args: [] };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Normalize `.` and `..` segments in a path without touching the filesystem.
|
|
429
|
+
*
|
|
430
|
+
* This matters for classification: `.../AppData/Local/Temp/../../Documents`
|
|
431
|
+
* must not be read as a temp path.
|
|
432
|
+
*
|
|
433
|
+
* @param path - A path that may contain relative segments
|
|
434
|
+
* @returns The path with `.` and `..` resolved textually
|
|
435
|
+
*/
|
|
436
|
+
function normalizeDots(path: string): string {
|
|
437
|
+
const windows = /^[A-Za-z]:/.test(path) || path.includes("\\");
|
|
438
|
+
const sep = windows ? "\\" : "/";
|
|
439
|
+
const unified = path.replace(/\\/g, "/");
|
|
440
|
+
const absolute = unified.startsWith("/") || /^[A-Za-z]:/.test(unified);
|
|
441
|
+
|
|
442
|
+
const out: string[] = [];
|
|
443
|
+
for (const part of unified.split("/")) {
|
|
444
|
+
if (part === "" || part === ".") continue;
|
|
445
|
+
if (part === ".." && out.length > 0 && out.at(-1) !== "..") {
|
|
446
|
+
out.pop();
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
out.push(part);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const joined = out.join(windows ? sep : "/");
|
|
453
|
+
if (!absolute) return joined;
|
|
454
|
+
return /^[A-Za-z]:/.test(unified) ? joined : `/${joined}`;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Decide what a path points at.
|
|
459
|
+
*
|
|
460
|
+
* @param raw - The path as written, after quote removal
|
|
461
|
+
* @param cwd - The working directory the command will run in
|
|
462
|
+
* @param home - The user's home directory
|
|
463
|
+
* @param tmpdir - The value of `TMPDIR`, when set
|
|
464
|
+
* @returns The path with its resolution and classification
|
|
465
|
+
*/
|
|
466
|
+
export function classifyPath(
|
|
467
|
+
raw: string,
|
|
468
|
+
cwd: string,
|
|
469
|
+
home: string,
|
|
470
|
+
tmpdir: string | undefined,
|
|
471
|
+
): AnalyzedPath {
|
|
472
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) || /^[\w.-]+@[\w.-]+:/.test(raw)) {
|
|
473
|
+
return { value: raw, resolved: raw, class: "remote", variableRooted: false };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
let expanded = raw;
|
|
477
|
+
let hadVariable = false;
|
|
478
|
+
|
|
479
|
+
expanded = expanded.replace(/\$\{?TMPDIR(:-[^}]*)?\}?/g, () => {
|
|
480
|
+
return tmpdir ?? "/tmp";
|
|
481
|
+
});
|
|
482
|
+
expanded = expanded.replace(/%TEMP%|%TMP%/gi, () => tmpdir ?? "/tmp");
|
|
483
|
+
expanded = expanded.replace(/\$\{?HOME\}?/g, home);
|
|
484
|
+
if (expanded.startsWith("~")) expanded = home + expanded.slice(1);
|
|
485
|
+
|
|
486
|
+
if (/\$\{?[A-Za-z_]/.test(expanded)) hadVariable = true;
|
|
487
|
+
|
|
488
|
+
const hasGlob = /[*?]|\[[^\]]+\]/.test(expanded);
|
|
489
|
+
const variableRooted = /\$|%[A-Za-z_]+%/.test(raw);
|
|
490
|
+
|
|
491
|
+
const resolved = normalizeDots(expanded);
|
|
492
|
+
const lower = resolved.toLowerCase().replace(/\\/g, "/");
|
|
493
|
+
|
|
494
|
+
const isTemp =
|
|
495
|
+
/^\/tmp(\/|$)/.test(lower) ||
|
|
496
|
+
/^\/var\/tmp(\/|$)/.test(lower) ||
|
|
497
|
+
/^\/private\/var\/folders\//.test(lower) ||
|
|
498
|
+
/\/appdata\/local\/temp(\/|$)/.test(lower) ||
|
|
499
|
+
/^[a-z]:\/windows\/temp(\/|$)/.test(lower) ||
|
|
500
|
+
(tmpdir !== undefined && lower.startsWith(tmpdir.toLowerCase().replace(/\\/g, "/")));
|
|
501
|
+
|
|
502
|
+
if (isTemp) return { value: raw, resolved, class: "temp", variableRooted };
|
|
503
|
+
|
|
504
|
+
if (hadVariable) {
|
|
505
|
+
return { value: raw, resolved: undefined, class: "unresolved-variable", variableRooted };
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (resolved === "/" || resolved === "") {
|
|
509
|
+
return { value: raw, resolved, class: "filesystem-root", variableRooted };
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (/^\/(etc|usr|bin|sbin|boot|sys|proc|lib|opt|var|root)(\/|$)/.test(lower)) {
|
|
513
|
+
return { value: raw, resolved, class: "system", variableRooted };
|
|
514
|
+
}
|
|
515
|
+
if (/^[a-z]:\/(windows|program files)/.test(lower)) {
|
|
516
|
+
return { value: raw, resolved, class: "system", variableRooted };
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const cwdNorm = normalizeDots(cwd).replace(/\\/g, "/").toLowerCase();
|
|
520
|
+
if (lower === cwdNorm || lower.startsWith(`${cwdNorm}/`)) {
|
|
521
|
+
return { value: raw, resolved, class: "inside-working-directory", variableRooted };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const homeNorm = normalizeDots(home).replace(/\\/g, "/").toLowerCase();
|
|
525
|
+
if (lower === homeNorm) return { value: raw, resolved, class: "home-root", variableRooted };
|
|
526
|
+
if (lower.startsWith(`${homeNorm}/`)) {
|
|
527
|
+
return { value: raw, resolved, class: "home", variableRooted };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (hasGlob) return { value: raw, resolved, class: "glob", variableRooted };
|
|
531
|
+
if (!resolved.startsWith("/") && !/^[A-Za-z]:/.test(resolved)) {
|
|
532
|
+
return { value: raw, resolved, class: "relative", variableRooted };
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
return { value: raw, resolved, class: "unknown", variableRooted };
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Whether a token looks like a path rather than a flag or a bare word.
|
|
540
|
+
*
|
|
541
|
+
* Deliberately inclusive: a token that is not a path costs one noisy state
|
|
542
|
+
* entry, while a missed path costs a misclassified command.
|
|
543
|
+
*
|
|
544
|
+
* @param token - An argument token
|
|
545
|
+
* @returns `true` when the token is worth classifying as a path
|
|
546
|
+
*/
|
|
547
|
+
function looksLikePath(token: string): boolean {
|
|
548
|
+
if (token.startsWith("-")) return false;
|
|
549
|
+
if (token === "") return false;
|
|
550
|
+
return (
|
|
551
|
+
token.includes("/") ||
|
|
552
|
+
token.includes("\\") ||
|
|
553
|
+
token.startsWith("~") ||
|
|
554
|
+
token.startsWith("$") ||
|
|
555
|
+
/^[A-Za-z]:/.test(token) ||
|
|
556
|
+
/\.[A-Za-z0-9]{1,5}$/.test(token)
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Analyze a command without executing it.
|
|
562
|
+
*
|
|
563
|
+
* Everything here is deterministic string work that the model would otherwise
|
|
564
|
+
* have to do by eye: splitting compound commands, removing quotes, finding the
|
|
565
|
+
* real program name, and resolving paths.
|
|
566
|
+
*
|
|
567
|
+
* @param command - The raw command line
|
|
568
|
+
* @param cwd - The working directory it will run in
|
|
569
|
+
* @param home - The user's home directory
|
|
570
|
+
* @param tmpdir - The value of `TMPDIR`, when set
|
|
571
|
+
* @returns The analysis handed to the model as state
|
|
572
|
+
*/
|
|
573
|
+
export function analyze(
|
|
574
|
+
command: string,
|
|
575
|
+
cwd: string,
|
|
576
|
+
home: string,
|
|
577
|
+
tmpdir: string | undefined,
|
|
578
|
+
): CommandAnalysis {
|
|
579
|
+
let parsedCleanly = true;
|
|
580
|
+
|
|
581
|
+
const { heredocs, stripped } = extractHeredocs(command);
|
|
582
|
+
const substitutions = extractSubstitutions(stripped);
|
|
583
|
+
|
|
584
|
+
// Substitutions are pulled out before segmenting so their inner operators
|
|
585
|
+
// don't split the outer command.
|
|
586
|
+
const withoutSubs = stripped
|
|
587
|
+
.replace(/\$\([^()]*\)/g, "SUBST")
|
|
588
|
+
.replace(/`[^`]*`/g, "SUBST");
|
|
589
|
+
|
|
590
|
+
const segments: Segment[] = [];
|
|
591
|
+
for (const raw of splitSegments(withoutSubs)) {
|
|
592
|
+
const { tokens, unterminated } = tokenize(raw);
|
|
593
|
+
if (unterminated) parsedCleanly = false;
|
|
594
|
+
if (tokens.length === 0) {
|
|
595
|
+
parsedCleanly = false;
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
const { argv0, wrappers, args } = resolveArgv0(tokens);
|
|
599
|
+
if (argv0 === undefined) parsedCleanly = false;
|
|
600
|
+
|
|
601
|
+
const paths = args
|
|
602
|
+
.filter(looksLikePath)
|
|
603
|
+
.map((t) => classifyPath(t, cwd, home, tmpdir));
|
|
604
|
+
|
|
605
|
+
segments.push({ raw, argv0, wrappers, args, paths });
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if (segments.length === 0) parsedCleanly = false;
|
|
609
|
+
|
|
610
|
+
const staticallyUnresolvable =
|
|
611
|
+
!parsedCleanly ||
|
|
612
|
+
substitutions.length > 0 ||
|
|
613
|
+
segments.some((seg) =>
|
|
614
|
+
seg.paths.some(
|
|
615
|
+
(p) => p.variableRooted || p.class === "glob" || p.class === "unresolved-variable",
|
|
616
|
+
),
|
|
617
|
+
) ||
|
|
618
|
+
segments.some((seg) => seg.argv0 !== undefined && /[*?$]/.test(seg.argv0));
|
|
619
|
+
|
|
620
|
+
return { segments, substitutions, heredocs, parsedCleanly, staticallyUnresolvable };
|
|
621
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { guard } from "./guard.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Judge a single command from the terminal and print the verdict with the
|
|
6
|
+
* judgments behind it.
|
|
7
|
+
*
|
|
8
|
+
* Usage: `bun run judge "git reset --hard"` — optionally with `--cwd=<path>`.
|
|
9
|
+
*/
|
|
10
|
+
async function main(): Promise<void> {
|
|
11
|
+
const args = Bun.argv.slice(2);
|
|
12
|
+
const cwdArg = args.find((a) => a.startsWith("--cwd="));
|
|
13
|
+
const command = args.filter((a) => !a.startsWith("--")).join(" ");
|
|
14
|
+
|
|
15
|
+
if (command.trim() === "") {
|
|
16
|
+
console.error('Usage: bun run judge "<command>" [--cwd=<path>]');
|
|
17
|
+
process.exit(2);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const cwd = cwdArg?.slice("--cwd=".length) ?? process.cwd();
|
|
21
|
+
const verdict = await guard(command, cwd, "cli");
|
|
22
|
+
|
|
23
|
+
const mark = { allow: "✓", ask: "?", deny: "✗" }[verdict.decision];
|
|
24
|
+
console.log(`${mark} ${verdict.decision.toUpperCase()} ${verdict.reason}`);
|
|
25
|
+
|
|
26
|
+
if (verdict.judgments !== undefined) {
|
|
27
|
+
const j = verdict.judgments;
|
|
28
|
+
console.log("");
|
|
29
|
+
console.log(` executes destruction ${j.executesDestruction.toFixed(3)}`);
|
|
30
|
+
console.log(` unrecoverable ${j.unrecoverable.toFixed(3)}`);
|
|
31
|
+
console.log(` shared infrastructure ${j.targetsSharedInfrastructure.toFixed(3)}`);
|
|
32
|
+
console.log(` blast radius ${j.blastRadius.toFixed(2)}/3 (confidence ${j.blastRadiusConfidence.toFixed(2)})`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
console.log("");
|
|
36
|
+
console.log(
|
|
37
|
+
` ${verdict.latencyMs}ms${verdict.usage ? `, ${verdict.usage.inputTokens} in / ${verdict.usage.outputTokens} out tokens` : ""}`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
await main();
|