@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.
@@ -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
- * - Shell constructs we flag as unanalyzable: $(), backticks, >, <
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, nested
29
- * subshells beyond the first level. Commands using those are classified
30
- * as "prompt" (let the Guardian review).
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
- else {
62
- current += ch;
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, treat as construct
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 substitution token.
163
- if (hasConstruct && !tokens.some((t) => typeof t === "object")) {
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 classify
185
- * (command substitution, redirects, etc.) — anything that is NOT a splittable
186
- * operator (pipe, and, or, semi).
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
- * Returns the raw string of each segment.
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 splitSegments(command) {
196
- const tokens = shellParse(command);
314
+ function splitSegmentsTokens(command) {
315
+ const parsed = shellParse(command);
197
316
  const segments = [];
198
317
  let current = [];
199
- for (const t of tokens) {
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.join(" "));
323
+ segments.push(current);
204
324
  current = [];
325
+ skipNext = false;
205
326
  }
206
- // Non-splittable operators (redirect, substitution) are already
207
- // handled by hasUnhandledConstructs above — we won't reach splitSegments.
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.join(" "));
342
+ segments.push(current);
215
343
  return segments;
216
344
  }
217
- /** Check if any segment pipes into a known shell/network interpreter. */
218
- function isPipeToShell(command) {
219
- const tokens = shellParse(command);
220
- for (let i = 0; i < tokens.length - 1; i++) {
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 (typeof t === "object" && t.op === "pipe") {
223
- const next = tokens[i + 1];
224
- if (typeof next === "string" && PIPE_TO_SHELL.has(next))
225
- return true;
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
- return false;
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
- const matches = unless.endsWith("*")
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
- /** Classify a single command segment (no shell constructs). */
260
- function classifySegment(segment, rules) {
261
- const tokens = tokenize(segment);
262
- if (tokens.length === 0) {
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
- /** Decision severity: forbidden > prompt > allow. */
275
- const SEVERITY = { allow: 0, prompt: 1, forbidden: 2 };
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, &&, ||, ;) are split into segments and each is
280
- * classified independently. The strictest decision wins (forbidden > prompt >
281
- * allow). Commands with shell constructs we can't parse (command substitution,
282
- * redirects beyond pipe) are classified as prompt. Pipe-to-shell is always
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
- // Command substitution, redirects, and other constructs we can't statically
294
- // analyze → prompt (let the Guardian review). Only checked for constructs
295
- // that are NOT splittable (|, &&, ||, ;) — those are handled below.
296
- if (hasUnhandledConstructs(command)) {
297
- return {
298
- decision: "prompt",
299
- justification: "command uses shell constructs (substitution/redirect) that cannot be statically analyzed",
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
- const segments = splitSegments(command);
303
- if (segments.length <= 1) {
304
- return classifySegment(command, policy.rules);
615
+ if (result === null) {
616
+ result = { decision: "prompt", justification: "empty command segment" };
305
617
  }
306
- // Compound command: classify each segment, take the strictest.
307
- let result = { decision: "allow", justification: "all segments allowed" };
308
- for (const seg of segments) {
309
- const segResult = classifySegment(seg, policy.rules);
310
- if (SEVERITY[segResult.decision] > SEVERITY[result.decision]) {
311
- result = segResult;
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: destructive commands (highest priority) ---
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