@yagni-app/code-staging 0.3.0-staging.1079.1 → 0.3.0-staging.1081.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/dist/extension/approvedPrefixes.d.ts +92 -0
- package/dist/extension/approvedPrefixes.js +252 -0
- package/dist/extension/config.d.ts +21 -0
- package/dist/extension/config.js +36 -2
- package/dist/extension/execPolicy.d.ts +51 -13
- package/dist/extension/execPolicy.js +432 -80
- package/dist/extension/guardian.d.ts +22 -6
- package/dist/extension/guardian.js +38 -11
- package/dist/extension/index.js +77 -10
- package/dist/extension/permission.d.ts +55 -0
- package/dist/extension/permission.js +395 -100
- package/dist/extension/pipeline/personas.js +12 -9
- package/dist/extension/redact.d.ts +20 -0
- package/dist/extension/redact.js +64 -0
- package/package.json +2 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Exec policy engine — classifies bash commands via prefix rules + lightweight
|
|
3
|
-
* shell tokenization (YAG-504).
|
|
3
|
+
* shell tokenization (YAG-504, restructured in YAG-510).
|
|
4
4
|
*
|
|
5
5
|
* Pure: no I/O, no network, no model. Loads at startup and classifies
|
|
6
6
|
* synchronously. The curated default set auto-allows read-only commands
|
|
@@ -10,24 +10,45 @@
|
|
|
10
10
|
*
|
|
11
11
|
* The `prompt` band is what the Guardian arbitrates — see guardian.ts.
|
|
12
12
|
*
|
|
13
|
+
* Classification composes three signals and takes the STRICTEST:
|
|
14
|
+
* 1. prefix-rule matching on every segment (newlines, ;, &&, ||, | split);
|
|
15
|
+
* 2. a construct floor — commands using redirects, substitution, or
|
|
16
|
+
* background & can never be auto-allowed (floor: prompt);
|
|
17
|
+
* 3. dangerScan — a best-effort sweep of command-substitution inner text
|
|
18
|
+
* ($(...) and backticks, including inside double quotes) against the
|
|
19
|
+
* FORBIDDEN rules only. Danger anywhere upgrades to forbidden; the scan
|
|
20
|
+
* can never make anything more permissive.
|
|
21
|
+
* This is the codex two-parser lesson: fail closed to prove safety, scan
|
|
22
|
+
* best-effort to prove danger. A forbidden match must win even when the
|
|
23
|
+
* command also carries constructs (`rm -rf / &` is forbidden, not prompt).
|
|
24
|
+
*
|
|
25
|
+
* Command words are matched through a leading-token strip (env assignments,
|
|
26
|
+
* sudo/env/command wrappers, shell reserved words, a leading backslash) and
|
|
27
|
+
* basename normalization (/bin/rm → rm) — both applied ASYMMETRICALLY: they
|
|
28
|
+
* can make a command land on forbidden/prompt rules, but a stripped or
|
|
29
|
+
* path-prefixed command is never auto-allowed (`sudo ls` and `./ls` stay in
|
|
30
|
+
* the prompt band; an attacker-named local `./rm` binary must not ride the
|
|
31
|
+
* allow list, and `/bin/ls` pays the same price by design).
|
|
32
|
+
*
|
|
13
33
|
* Tokenization is a lightweight inline parser — not shell-quote — because the
|
|
14
34
|
* extension is bundled into @yagni-app/code's dist (a file copy, not a real
|
|
15
35
|
* bundler), and external dependencies aren't resolvable from the bundled path.
|
|
16
|
-
* We only need: split on whitespace (respecting single/double quotes), detect
|
|
17
|
-
* control operators (|, &&, ||, ;), and flag shell constructs ($(...),
|
|
18
|
-
* backticks, redirects) that we can't statically analyze.
|
|
19
36
|
*/
|
|
20
37
|
/**
|
|
21
38
|
* Parse a shell command string into tokens and control operators.
|
|
22
39
|
*
|
|
23
40
|
* Handles:
|
|
24
41
|
* - Single and double quoted strings (preserves spaces inside)
|
|
25
|
-
* - Control operators: |, &&, ||,
|
|
26
|
-
*
|
|
42
|
+
* - Control operators: |, &&, ||, ;, and newlines (a newline separates
|
|
43
|
+
* commands exactly like `;` — treating it as whitespace let multiline
|
|
44
|
+
* commands smuggle anything behind an allow-listed first line)
|
|
45
|
+
* - `#` comments (start-of-word to end-of-line, outside quotes)
|
|
46
|
+
* - Shell constructs we flag as unanalyzable: $(), backticks (INCLUDING
|
|
47
|
+
* inside double quotes — bash executes those), >, <, background &
|
|
27
48
|
*
|
|
28
|
-
* Does NOT handle: variable expansion, glob patterns, heredocs
|
|
29
|
-
* subshells beyond
|
|
30
|
-
* as "prompt"
|
|
49
|
+
* Does NOT handle: variable expansion, glob patterns, heredocs beyond the
|
|
50
|
+
* redirect flag, nested subshells beyond depth tracking. Commands using
|
|
51
|
+
* those are classified as "prompt" at minimum (construct floor).
|
|
31
52
|
*/
|
|
32
53
|
export function shellParse(command) {
|
|
33
54
|
const tokens = [];
|
|
@@ -57,10 +78,24 @@ export function shellParse(command) {
|
|
|
57
78
|
if (inDouble) {
|
|
58
79
|
if (ch === '"') {
|
|
59
80
|
inDouble = false;
|
|
81
|
+
i++;
|
|
82
|
+
continue;
|
|
60
83
|
}
|
|
61
|
-
|
|
62
|
-
|
|
84
|
+
// Backslash escapes that bash honors inside double quotes: \$ \` \" \\.
|
|
85
|
+
// Without this, `echo "\$(safe)"` would false-flag as a substitution.
|
|
86
|
+
if (ch === "\\" && (command[i + 1] === "$" || command[i + 1] === "`" || command[i + 1] === '"' || command[i + 1] === "\\")) {
|
|
87
|
+
current += command[i + 1];
|
|
88
|
+
i += 2;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
// Bash EXECUTES $(...) and backticks inside double quotes; the old
|
|
92
|
+
// tokenizer treated them as literal text, which made
|
|
93
|
+
// `echo "$(rm -rf x)"` classify as a plain echo → allow. Flag as a
|
|
94
|
+
// construct; dangerScan sweeps the inner text separately.
|
|
95
|
+
if ((ch === "$" && command[i + 1] === "(") || ch === "`") {
|
|
96
|
+
hasConstruct = true;
|
|
63
97
|
}
|
|
98
|
+
current += ch;
|
|
64
99
|
i++;
|
|
65
100
|
continue;
|
|
66
101
|
}
|
|
@@ -73,10 +108,26 @@ export function shellParse(command) {
|
|
|
73
108
|
inDouble = true;
|
|
74
109
|
i++;
|
|
75
110
|
continue;
|
|
111
|
+
case "#":
|
|
112
|
+
// Comment: only at a word boundary (bash rule). `echo a#b` keeps the #.
|
|
113
|
+
if (current.length === 0) {
|
|
114
|
+
while (i < command.length && command[i] !== "\n")
|
|
115
|
+
i++;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
current += ch;
|
|
119
|
+
i++;
|
|
120
|
+
continue;
|
|
76
121
|
case " ":
|
|
77
122
|
case "\t":
|
|
123
|
+
pushCurrent();
|
|
124
|
+
i++;
|
|
125
|
+
continue;
|
|
78
126
|
case "\n":
|
|
127
|
+
case "\r":
|
|
128
|
+
// Newlines separate commands like `;` — NOT whitespace.
|
|
79
129
|
pushCurrent();
|
|
130
|
+
tokens.push({ op: "semi" });
|
|
80
131
|
i++;
|
|
81
132
|
continue;
|
|
82
133
|
case "|":
|
|
@@ -98,9 +149,12 @@ export function shellParse(command) {
|
|
|
98
149
|
i += 2;
|
|
99
150
|
}
|
|
100
151
|
else {
|
|
101
|
-
// Single & — background operator
|
|
152
|
+
// Single & — background operator. A construct (floor: prompt), but
|
|
153
|
+
// the command before it must still be rule-matched: `rm -rf / &`
|
|
154
|
+
// has to stay forbidden, so emit a separator rather than gluing.
|
|
155
|
+
pushCurrent();
|
|
156
|
+
tokens.push({ op: "semi" });
|
|
102
157
|
hasConstruct = true;
|
|
103
|
-
current += ch;
|
|
104
158
|
i++;
|
|
105
159
|
}
|
|
106
160
|
continue;
|
|
@@ -159,17 +213,79 @@ export function shellParse(command) {
|
|
|
159
213
|
}
|
|
160
214
|
pushCurrent();
|
|
161
215
|
// If we detected constructs but didn't emit them as operator tokens
|
|
162
|
-
// (e.g. background &), surface that via a
|
|
163
|
-
|
|
216
|
+
// (e.g. background & or double-quoted substitution), surface that via a
|
|
217
|
+
// trailing substitution token so hasUnhandledConstructs sees it.
|
|
218
|
+
if (hasConstruct && !tokens.some((t) => typeof t === "object" && (t.op === "redirect" || t.op === "substitution"))) {
|
|
164
219
|
tokens.push({ op: "substitution" });
|
|
165
220
|
}
|
|
166
221
|
return tokens;
|
|
167
222
|
}
|
|
223
|
+
/**
|
|
224
|
+
* Extract the inner text of every command substitution — $(...) and
|
|
225
|
+
* backticks — respecting single-quote literalness and backslash escapes.
|
|
226
|
+
* Includes substitutions inside double quotes (bash executes those).
|
|
227
|
+
* Best-effort, used ONLY by dangerScan to prove danger, never safety.
|
|
228
|
+
*/
|
|
229
|
+
export function extractSubstitutions(command) {
|
|
230
|
+
const found = [];
|
|
231
|
+
let i = 0;
|
|
232
|
+
let inSingle = false;
|
|
233
|
+
while (i < command.length) {
|
|
234
|
+
const ch = command[i];
|
|
235
|
+
if (inSingle) {
|
|
236
|
+
if (ch === "'")
|
|
237
|
+
inSingle = false;
|
|
238
|
+
i++;
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (ch === "'") {
|
|
242
|
+
inSingle = true;
|
|
243
|
+
i++;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (ch === "\\") {
|
|
247
|
+
i += 2;
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (ch === "$" && command[i + 1] === "(") {
|
|
251
|
+
const start = i + 2;
|
|
252
|
+
let depth = 1;
|
|
253
|
+
let j = start;
|
|
254
|
+
while (j < command.length && depth > 0) {
|
|
255
|
+
if (command[j] === "(")
|
|
256
|
+
depth++;
|
|
257
|
+
if (command[j] === ")")
|
|
258
|
+
depth--;
|
|
259
|
+
j++;
|
|
260
|
+
}
|
|
261
|
+
found.push(command.slice(start, depth === 0 ? j - 1 : j));
|
|
262
|
+
i = j;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (ch === "`") {
|
|
266
|
+
const start = i + 1;
|
|
267
|
+
let j = start;
|
|
268
|
+
while (j < command.length && command[j] !== "`")
|
|
269
|
+
j++;
|
|
270
|
+
found.push(command.slice(start, j));
|
|
271
|
+
i = j < command.length ? j + 1 : j;
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
i++;
|
|
275
|
+
}
|
|
276
|
+
return found;
|
|
277
|
+
}
|
|
168
278
|
/** Interpreters that, when piped into, indicate code execution — always forbidden. */
|
|
169
279
|
const PIPE_TO_SHELL = new Set([
|
|
170
280
|
"sh", "bash", "zsh", "fish", "nc", "ncat", "socat",
|
|
171
281
|
"python", "python3", "perl", "ruby", "node",
|
|
172
282
|
]);
|
|
283
|
+
/** Wrapper words that forward to another command (`sudo rm …` runs rm). */
|
|
284
|
+
const WRAPPER_WORDS = new Set(["sudo", "env", "command", "builtin", "exec", "nohup", "time", "nice"]);
|
|
285
|
+
/** Shell reserved words that can precede a command inside control flow. */
|
|
286
|
+
const RESERVED_WORDS = new Set(["do", "then", "else", "elif", "if", "while", "until", "done", "fi", "esac", "!"]);
|
|
287
|
+
/** Bash env-assignment prefix: FOO=bar cmd … */
|
|
288
|
+
const ENV_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
173
289
|
/**
|
|
174
290
|
* Parse a command string into tokens using our lightweight tokenizer. Returns
|
|
175
291
|
* string tokens only (control operators and constructs are filtered out —
|
|
@@ -181,51 +297,149 @@ export function tokenize(command) {
|
|
|
181
297
|
/** Operator tokens we can safely split on (compound command segments). */
|
|
182
298
|
const SPLIT_OPS = new Set(["pipe", "and", "or", "semi"]);
|
|
183
299
|
/**
|
|
184
|
-
* Detect whether the command uses shell constructs we can't statically
|
|
185
|
-
* (command substitution, redirects,
|
|
186
|
-
* operator
|
|
300
|
+
* Detect whether the command uses shell constructs we can't statically
|
|
301
|
+
* classify (command substitution, redirects, background &) — anything that
|
|
302
|
+
* is NOT a splittable operator. These impose a floor of `prompt`: a command
|
|
303
|
+
* carrying them is never auto-allowed, but forbidden matches still win.
|
|
187
304
|
*/
|
|
188
305
|
function hasUnhandledConstructs(command) {
|
|
189
306
|
return shellParse(command).some((t) => typeof t === "object" && "op" in t && !SPLIT_OPS.has(t.op));
|
|
190
307
|
}
|
|
191
308
|
/**
|
|
192
|
-
* Split a command into segments at control operators (|, &&, ||,
|
|
193
|
-
*
|
|
309
|
+
* Split a command into token-array segments at control operators (|, &&, ||,
|
|
310
|
+
* ;, newline). Redirect targets (the token after > or <) are dropped from the
|
|
311
|
+
* segment — they are filenames, not arguments to rule-match. Token arrays are
|
|
312
|
+
* carried through (never re-joined into strings) so quoting survives.
|
|
194
313
|
*/
|
|
195
|
-
function
|
|
196
|
-
const
|
|
314
|
+
function splitSegmentsTokens(command) {
|
|
315
|
+
const parsed = shellParse(command);
|
|
197
316
|
const segments = [];
|
|
198
317
|
let current = [];
|
|
199
|
-
|
|
318
|
+
let skipNext = false;
|
|
319
|
+
for (const t of parsed) {
|
|
200
320
|
if (typeof t === "object") {
|
|
201
321
|
if (SPLIT_OPS.has(t.op)) {
|
|
202
322
|
if (current.length > 0)
|
|
203
|
-
segments.push(current
|
|
323
|
+
segments.push(current);
|
|
204
324
|
current = [];
|
|
325
|
+
skipNext = false;
|
|
205
326
|
}
|
|
206
|
-
|
|
207
|
-
|
|
327
|
+
else if (t.op === "redirect") {
|
|
328
|
+
skipNext = true;
|
|
329
|
+
}
|
|
330
|
+
// substitution ops are construct markers; the inner text is handled
|
|
331
|
+
// by dangerScan via extractSubstitutions.
|
|
208
332
|
}
|
|
209
333
|
else {
|
|
334
|
+
if (skipNext) {
|
|
335
|
+
skipNext = false;
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
210
338
|
current.push(t);
|
|
211
339
|
}
|
|
212
340
|
}
|
|
213
341
|
if (current.length > 0)
|
|
214
|
-
segments.push(current
|
|
342
|
+
segments.push(current);
|
|
215
343
|
return segments;
|
|
216
344
|
}
|
|
217
|
-
/**
|
|
218
|
-
function
|
|
219
|
-
const
|
|
220
|
-
|
|
345
|
+
/** basename("/usr/bin/git") → "git"; leaves plain words untouched. */
|
|
346
|
+
function basenameToken(token) {
|
|
347
|
+
const idx = token.lastIndexOf("/");
|
|
348
|
+
return idx >= 0 ? token.slice(idx + 1) : token;
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Unified leading-token strip: remove env assignments, wrapper words (plus
|
|
352
|
+
* their immediate dash-flags), shell reserved words, leading `(`/`{` (even
|
|
353
|
+
* glued: `(rm`), and a leading backslash on the command word. Used to FIND
|
|
354
|
+
* the command word for forbidden/prompt matching — callers must treat a
|
|
355
|
+
* stripped result as never-allow (see classifySegmentTokens).
|
|
356
|
+
*/
|
|
357
|
+
function stripLeadingTokens(tokens) {
|
|
358
|
+
const out = [...tokens];
|
|
359
|
+
let stripped = false;
|
|
360
|
+
let guard = 0;
|
|
361
|
+
while (out.length > 0 && guard++ < 32) {
|
|
362
|
+
let t = out[0];
|
|
363
|
+
// Leading ( or { — possibly glued to the command word.
|
|
364
|
+
if (t.startsWith("(") || t.startsWith("{")) {
|
|
365
|
+
const trimmed = t.replace(/^[({]+/, "");
|
|
366
|
+
stripped = true;
|
|
367
|
+
if (trimmed.length === 0) {
|
|
368
|
+
out.shift();
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
out[0] = trimmed;
|
|
372
|
+
}
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
// Trailing ) } on a lone closer token — drop (e.g. segment "rm -rf /)" ).
|
|
376
|
+
if (/^[)}]+$/.test(t)) {
|
|
377
|
+
out.shift();
|
|
378
|
+
stripped = true;
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
if (RESERVED_WORDS.has(t)) {
|
|
382
|
+
out.shift();
|
|
383
|
+
stripped = true;
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
if (ENV_ASSIGNMENT_RE.test(t)) {
|
|
387
|
+
out.shift();
|
|
388
|
+
stripped = true;
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
if (WRAPPER_WORDS.has(basenameToken(t))) {
|
|
392
|
+
out.shift();
|
|
393
|
+
stripped = true;
|
|
394
|
+
// Wrapper flags (env -i, sudo -n, …). Imperfect for flags that take a
|
|
395
|
+
// separate value (sudo -u alice); worst case the "command word" is the
|
|
396
|
+
// value and we land in the prompt band — never allow.
|
|
397
|
+
while (out.length > 0 && out[0].startsWith("-"))
|
|
398
|
+
out.shift();
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
if (t.startsWith("\\") && t.length > 1) {
|
|
402
|
+
out[0] = t.slice(1);
|
|
403
|
+
stripped = true;
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
break;
|
|
407
|
+
}
|
|
408
|
+
return { tokens: out, stripped };
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* git accepts global options between `git` and the subcommand (`git -C /x
|
|
412
|
+
* push --force`). Skip them so subcommand rules and flagsAnywhere see the
|
|
413
|
+
* real shape. Matching-only — never mutates what actually runs.
|
|
414
|
+
*/
|
|
415
|
+
function normalizeGitTokens(tokens) {
|
|
416
|
+
if (tokens[0] !== "git")
|
|
417
|
+
return tokens;
|
|
418
|
+
const out = ["git"];
|
|
419
|
+
let i = 1;
|
|
420
|
+
while (i < tokens.length) {
|
|
221
421
|
const t = tokens[i];
|
|
222
|
-
if (
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
422
|
+
if (t === "-C" || t === "-c") {
|
|
423
|
+
i += 2; // option + its value
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
if (t.startsWith("--git-dir") ||
|
|
427
|
+
t.startsWith("--work-tree") ||
|
|
428
|
+
t.startsWith("--exec-path") ||
|
|
429
|
+
t === "-p" ||
|
|
430
|
+
t === "--paginate" ||
|
|
431
|
+
t === "--no-pager") {
|
|
432
|
+
i += 1;
|
|
433
|
+
continue;
|
|
226
434
|
}
|
|
435
|
+
break;
|
|
227
436
|
}
|
|
228
|
-
|
|
437
|
+
out.push(...tokens.slice(i));
|
|
438
|
+
return out;
|
|
439
|
+
}
|
|
440
|
+
/** Glob-aware token match shared by unlessTokens and flagsAnywhere. */
|
|
441
|
+
function tokenMatchesEntry(token, entry) {
|
|
442
|
+
return entry.endsWith("*") ? token.startsWith(entry.slice(0, -1)) : token === entry;
|
|
229
443
|
}
|
|
230
444
|
/** Match a token array against a prefix rule's pattern. */
|
|
231
445
|
function matchRule(tokens, rule) {
|
|
@@ -243,43 +457,142 @@ function matchRule(tokens, rule) {
|
|
|
243
457
|
return false;
|
|
244
458
|
}
|
|
245
459
|
}
|
|
460
|
+
if (rule.flagsAnywhere) {
|
|
461
|
+
const rest = tokens.slice(rule.pattern.length);
|
|
462
|
+
const hit = rest.some((tok) => rule.flagsAnywhere.some((f) => tokenMatchesEntry(tok, f)));
|
|
463
|
+
if (!hit)
|
|
464
|
+
return false;
|
|
465
|
+
}
|
|
246
466
|
if (rule.unlessTokens) {
|
|
247
467
|
for (const tok of tokens.slice(rule.pattern.length)) {
|
|
248
468
|
for (const unless of rule.unlessTokens) {
|
|
249
|
-
|
|
250
|
-
? tok.startsWith(unless.slice(0, -1))
|
|
251
|
-
: tok === unless;
|
|
252
|
-
if (matches)
|
|
469
|
+
if (tokenMatchesEntry(tok, unless))
|
|
253
470
|
return false;
|
|
254
471
|
}
|
|
255
472
|
}
|
|
256
473
|
}
|
|
257
474
|
return true;
|
|
258
475
|
}
|
|
259
|
-
/**
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
476
|
+
/** Decision severity: forbidden > prompt > allow. */
|
|
477
|
+
const SEVERITY = { allow: 0, prompt: 1, forbidden: 2 };
|
|
478
|
+
function stricter(a, b) {
|
|
479
|
+
return SEVERITY[b.decision] > SEVERITY[a.decision] ? b : a;
|
|
480
|
+
}
|
|
481
|
+
/** Depth cap for substitution/xargs recursion (matches codex's wrapper cap). */
|
|
482
|
+
const MAX_SCAN_DEPTH = 8;
|
|
483
|
+
/** Classify one segment's tokens against the rules. */
|
|
484
|
+
function classifySegmentTokens(rawTokens, policy, opts) {
|
|
485
|
+
if (rawTokens.length === 0) {
|
|
263
486
|
return { decision: "prompt", justification: "empty command segment" };
|
|
264
487
|
}
|
|
488
|
+
const { tokens: strippedTokens, stripped } = stripLeadingTokens(rawTokens);
|
|
489
|
+
if (strippedTokens.length === 0) {
|
|
490
|
+
return opts.forbiddenOnly
|
|
491
|
+
? { decision: "allow", justification: "no forbidden match" }
|
|
492
|
+
: { decision: "prompt", justification: "empty command segment" };
|
|
493
|
+
}
|
|
494
|
+
// Asymmetric basename normalization: /bin/rm → rm for matching, but a
|
|
495
|
+
// path-prefixed command word disqualifies allow (see below).
|
|
496
|
+
const cmdWord = strippedTokens[0];
|
|
497
|
+
const normalizedWord = basenameToken(cmdWord);
|
|
498
|
+
const pathPrefixed = normalizedWord !== cmdWord;
|
|
499
|
+
let tokens = pathPrefixed ? [normalizedWord, ...strippedTokens.slice(1)] : strippedTokens;
|
|
500
|
+
tokens = normalizeGitTokens(tokens);
|
|
501
|
+
const neverAllow = stripped || pathPrefixed;
|
|
502
|
+
// xargs forwards to its argv tail: classify the tail as its own segment so
|
|
503
|
+
// `xargs rm -rf` inherits rm's forbidden. xargs itself is never allow.
|
|
504
|
+
if (tokens[0] === "xargs" && opts.depth < MAX_SCAN_DEPTH) {
|
|
505
|
+
let j = 1;
|
|
506
|
+
while (j < tokens.length && tokens[j].startsWith("-"))
|
|
507
|
+
j++;
|
|
508
|
+
const tail = tokens.slice(j);
|
|
509
|
+
if (tail.length > 0) {
|
|
510
|
+
const tailResult = classifySegmentTokens(tail, policy, { ...opts, depth: opts.depth + 1 });
|
|
511
|
+
if (tailResult.decision === "forbidden")
|
|
512
|
+
return tailResult;
|
|
513
|
+
}
|
|
514
|
+
if (opts.forbiddenOnly)
|
|
515
|
+
return { decision: "allow", justification: "no forbidden match" };
|
|
516
|
+
return { decision: "prompt", justification: "xargs executes its argument command — review the target" };
|
|
517
|
+
}
|
|
265
518
|
// First match wins (rules are ordered; more specific rules come first).
|
|
266
|
-
for (const rule of rules) {
|
|
519
|
+
for (const rule of policy.rules) {
|
|
520
|
+
if (opts.forbiddenOnly && rule.decision !== "forbidden")
|
|
521
|
+
continue;
|
|
267
522
|
if (matchRule(tokens, rule)) {
|
|
523
|
+
if (rule.decision === "allow" && neverAllow) {
|
|
524
|
+
return {
|
|
525
|
+
decision: "prompt",
|
|
526
|
+
justification: "wrapper- or path-prefixed command cannot be auto-allowed",
|
|
527
|
+
};
|
|
528
|
+
}
|
|
268
529
|
return { decision: rule.decision, justification: rule.justification, matchedRule: rule };
|
|
269
530
|
}
|
|
270
531
|
}
|
|
532
|
+
if (opts.forbiddenOnly) {
|
|
533
|
+
return { decision: "allow", justification: "no forbidden match" };
|
|
534
|
+
}
|
|
271
535
|
// No rule matched → prompt (fail toward review, not toward allow)
|
|
272
536
|
return { decision: "prompt", justification: `no policy rule matched for "${tokens[0]}"` };
|
|
273
537
|
}
|
|
274
|
-
/**
|
|
275
|
-
|
|
538
|
+
/** Check if any segment pipes into a known shell/network interpreter. */
|
|
539
|
+
function isPipeToShell(command) {
|
|
540
|
+
const parsed = shellParse(command);
|
|
541
|
+
for (let i = 0; i < parsed.length - 1; i++) {
|
|
542
|
+
const t = parsed[i];
|
|
543
|
+
if (typeof t === "object" && t.op === "pipe") {
|
|
544
|
+
const next = parsed[i + 1];
|
|
545
|
+
if (typeof next !== "string")
|
|
546
|
+
continue;
|
|
547
|
+
const word = basenameToken(next.startsWith("\\") ? next.slice(1) : next);
|
|
548
|
+
if (PIPE_TO_SHELL.has(word))
|
|
549
|
+
return true;
|
|
550
|
+
// `… | env sh` / `… | /usr/bin/env sh`
|
|
551
|
+
if (word === "env") {
|
|
552
|
+
const after = parsed[i + 2];
|
|
553
|
+
if (typeof after === "string" && PIPE_TO_SHELL.has(basenameToken(after)))
|
|
554
|
+
return true;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
return false;
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Best-effort danger sweep of command-substitution inner text ($(...) and
|
|
562
|
+
* backticks, including inside double quotes). Matches FORBIDDEN rules only —
|
|
563
|
+
* can upgrade the classification, never relax it.
|
|
564
|
+
*/
|
|
565
|
+
function dangerScanSubstitutions(command, policy, depth) {
|
|
566
|
+
if (depth > MAX_SCAN_DEPTH)
|
|
567
|
+
return null;
|
|
568
|
+
for (const inner of extractSubstitutions(command)) {
|
|
569
|
+
if (inner.trim().length === 0)
|
|
570
|
+
continue;
|
|
571
|
+
if (isPipeToShell(inner)) {
|
|
572
|
+
return {
|
|
573
|
+
decision: "forbidden",
|
|
574
|
+
justification: "piping into a shell or network interpreter is forbidden",
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
for (const seg of splitSegmentsTokens(inner)) {
|
|
578
|
+
const r = classifySegmentTokens(seg, policy, { forbiddenOnly: true, depth: depth + 1 });
|
|
579
|
+
if (r.decision === "forbidden")
|
|
580
|
+
return r;
|
|
581
|
+
}
|
|
582
|
+
const nested = dangerScanSubstitutions(inner, policy, depth + 1);
|
|
583
|
+
if (nested)
|
|
584
|
+
return nested;
|
|
585
|
+
}
|
|
586
|
+
return null;
|
|
587
|
+
}
|
|
276
588
|
/**
|
|
277
589
|
* Classify a full bash command string against the exec policy.
|
|
278
590
|
*
|
|
279
|
-
* Compound commands (pipes, &&, ||,
|
|
280
|
-
* classified independently
|
|
281
|
-
* allow). Commands with shell constructs
|
|
282
|
-
*
|
|
591
|
+
* Compound commands (pipes, &&, ||, ;, newlines) are split into segments and
|
|
592
|
+
* each is classified independently; the strictest decision wins (forbidden >
|
|
593
|
+
* prompt > allow). Commands with shell constructs (substitution, redirects,
|
|
594
|
+
* background &) have a floor of `prompt`, and their substitution inner text
|
|
595
|
+
* is danger-scanned against the forbidden rules. Pipe-to-shell is always
|
|
283
596
|
* forbidden.
|
|
284
597
|
*/
|
|
285
598
|
export function classifyCommand(command, policy) {
|
|
@@ -290,54 +603,91 @@ export function classifyCommand(command, policy) {
|
|
|
290
603
|
justification: "piping into a shell or network interpreter is forbidden",
|
|
291
604
|
};
|
|
292
605
|
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
606
|
+
const constructFloor = hasUnhandledConstructs(command);
|
|
607
|
+
const segments = splitSegmentsTokens(command);
|
|
608
|
+
let result = null;
|
|
609
|
+
for (const seg of segments) {
|
|
610
|
+
const segResult = classifySegmentTokens(seg, policy, { depth: 0 });
|
|
611
|
+
result = result === null ? segResult : stricter(result, segResult);
|
|
612
|
+
if (result.decision === "forbidden")
|
|
613
|
+
break;
|
|
301
614
|
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
return classifySegment(command, policy.rules);
|
|
615
|
+
if (result === null) {
|
|
616
|
+
result = { decision: "prompt", justification: "empty command segment" };
|
|
305
617
|
}
|
|
306
|
-
//
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
618
|
+
// Danger sweep of substitution inner text — upgrade-only.
|
|
619
|
+
if (result.decision !== "forbidden") {
|
|
620
|
+
const danger = dangerScanSubstitutions(command, policy, 0);
|
|
621
|
+
if (danger)
|
|
622
|
+
result = danger;
|
|
623
|
+
}
|
|
624
|
+
// Construct floor: redirects/substitutions/background can never auto-allow.
|
|
625
|
+
if (constructFloor && result.decision === "allow") {
|
|
626
|
+
result = {
|
|
627
|
+
decision: "prompt",
|
|
628
|
+
justification: "command uses shell constructs (substitution/redirect/background) that cannot be statically analyzed",
|
|
629
|
+
};
|
|
313
630
|
}
|
|
314
631
|
return result;
|
|
315
632
|
}
|
|
316
633
|
/** Curated default rules — the shipped safety floor. */
|
|
317
634
|
export const DEFAULT_EXEC_POLICY = {
|
|
318
635
|
rules: [
|
|
319
|
-
// --- forbidden:
|
|
636
|
+
// --- forbidden: position-independent dangerous-flag rules (checked first;
|
|
637
|
+
// GNU getopt permutes flags, so `rm x -rf` and `git push origin
|
|
638
|
+
// --force` carry the flag after positional args) ---
|
|
639
|
+
{
|
|
640
|
+
pattern: ["rm"],
|
|
641
|
+
flagsAnywhere: ["-r*", "-f*", "--recursive*", "--force*"],
|
|
642
|
+
decision: "forbidden",
|
|
643
|
+
justification: "recursive/forced deletion is destructive and irreversible",
|
|
644
|
+
},
|
|
645
|
+
{
|
|
646
|
+
// Exact --force/-f only: --force-with-lease is the guarded variant and
|
|
647
|
+
// deliberately stays in the prompt band (Guardian reviews it) — but the
|
|
648
|
+
// grants layer fences ALL --force* so a prefix approval never covers it
|
|
649
|
+
// (approvedPrefixes.ts).
|
|
650
|
+
pattern: ["git", "push"],
|
|
651
|
+
flagsAnywhere: ["--force", "-f", "--mirror", "--delete", "-d", "--receive-pack*", "--exec*"],
|
|
652
|
+
decision: "forbidden",
|
|
653
|
+
justification: "force/delete/mirror push rewrites or removes shared history",
|
|
654
|
+
},
|
|
655
|
+
{
|
|
656
|
+
pattern: ["git", "clean"],
|
|
657
|
+
flagsAnywhere: ["-f*", "-d*", "-x*", "--force*"],
|
|
658
|
+
decision: "forbidden",
|
|
659
|
+
justification: "git clean removes untracked files irreversibly",
|
|
660
|
+
},
|
|
661
|
+
{
|
|
662
|
+
pattern: ["chmod"],
|
|
663
|
+
flagsAnywhere: ["777", "0777", "a+rwx"],
|
|
664
|
+
decision: "forbidden",
|
|
665
|
+
justification: "world-writable permission change weakens security",
|
|
666
|
+
},
|
|
667
|
+
{
|
|
668
|
+
pattern: ["chown"],
|
|
669
|
+
flagsAnywhere: ["-R*", "--recursive*"],
|
|
670
|
+
decision: "forbidden",
|
|
671
|
+
justification: "recursive ownership change",
|
|
672
|
+
},
|
|
673
|
+
{
|
|
674
|
+
pattern: ["kill"],
|
|
675
|
+
flagsAnywhere: ["-9", "-KILL", "-SIGKILL"],
|
|
676
|
+
decision: "forbidden",
|
|
677
|
+
justification: "force kill is destructive",
|
|
678
|
+
},
|
|
679
|
+
// --- forbidden: destructive commands (positional) ---
|
|
320
680
|
{
|
|
321
681
|
pattern: ["rm", ["-rf", "-fr", "-r", "-f", "--recursive", "--force"]],
|
|
322
682
|
decision: "forbidden",
|
|
323
683
|
justification: "recursive/forced deletion is destructive and irreversible",
|
|
324
684
|
},
|
|
325
|
-
{ pattern: ["rm", "-r"], decision: "forbidden", justification: "recursive deletion is destructive" },
|
|
326
|
-
{ pattern: ["rm", "-f"], decision: "forbidden", justification: "forced deletion bypasses prompts" },
|
|
327
|
-
{ pattern: ["rm", "--recursive"], decision: "forbidden", justification: "recursive deletion is destructive" },
|
|
328
|
-
{ pattern: ["rm", "--force"], decision: "forbidden", justification: "forced deletion bypasses prompts" },
|
|
329
685
|
{ pattern: ["git", "reset", "--hard"], decision: "forbidden", justification: "hard reset discards uncommitted changes irreversibly" },
|
|
330
|
-
{ pattern: ["git", "push", ["--force", "-f"]], decision: "forbidden", justification: "force-push rewrites shared history" },
|
|
331
|
-
{ pattern: ["git", "clean", ["-fd", "-df", "-f", "-d"]], decision: "forbidden", justification: "git clean removes untracked files irreversibly" },
|
|
332
686
|
{ pattern: ["git", "checkout", "--"], decision: "forbidden", justification: "discards working tree changes" },
|
|
333
|
-
{ pattern: ["chmod", "-R", "777"], decision: "forbidden", justification: "recursive world-writable permission change weakens security" },
|
|
334
|
-
{ pattern: ["chown", "-R"], decision: "forbidden", justification: "recursive ownership change" },
|
|
335
687
|
{ pattern: ["dd"], decision: "forbidden", justification: "low-level disk operations are destructive" },
|
|
336
688
|
{ pattern: ["mkfs"], decision: "forbidden", justification: "filesystem formatting is destructive" },
|
|
337
689
|
{ pattern: ["shutdown"], decision: "forbidden", justification: "system shutdown" },
|
|
338
690
|
{ pattern: ["reboot"], decision: "forbidden", justification: "system reboot" },
|
|
339
|
-
{ pattern: ["kill", "-9"], decision: "forbidden", justification: "force kill is destructive" },
|
|
340
|
-
{ pattern: ["kill", "-KILL"], decision: "forbidden", justification: "force kill is destructive" },
|
|
341
691
|
{ pattern: ["truncate"], decision: "forbidden", justification: "truncates files destructively" },
|
|
342
692
|
// --- allow: read-only commands ---
|
|
343
693
|
{ pattern: ["ls"], decision: "allow", justification: "list directory contents" },
|
|
@@ -448,6 +798,8 @@ export const DEFAULT_EXEC_POLICY = {
|
|
|
448
798
|
{ pattern: ["unzip"], decision: "prompt", justification: "archive operation" },
|
|
449
799
|
{ pattern: ["ps"], decision: "prompt", justification: "lists processes" },
|
|
450
800
|
{ pattern: ["kill"], decision: "prompt", justification: "sends a signal to a process" },
|
|
801
|
+
{ pattern: ["chmod"], decision: "prompt", justification: "permission change — review the mode" },
|
|
802
|
+
{ pattern: ["chown"], decision: "prompt", justification: "ownership change" },
|
|
451
803
|
],
|
|
452
804
|
};
|
|
453
805
|
//# sourceMappingURL=execPolicy.js.map
|