@yagni-app/code 0.2.1 → 0.3.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.
Files changed (77) hide show
  1. package/dist/cli.d.ts +30 -0
  2. package/dist/cli.js +135 -3
  3. package/dist/doctor.d.ts +1 -1
  4. package/dist/doctor.js +1 -1
  5. package/dist/extension/advisor.d.ts +4 -4
  6. package/dist/extension/advisor.js +6 -7
  7. package/dist/extension/approvedPrefixes.d.ts +92 -0
  8. package/dist/extension/approvedPrefixes.js +252 -0
  9. package/dist/extension/askAdvisorTool.d.ts +2 -2
  10. package/dist/extension/askAdvisorTool.js +5 -5
  11. package/dist/extension/askYagniTool.js +49 -0
  12. package/dist/extension/branding.d.ts +24 -3
  13. package/dist/extension/branding.js +71 -10
  14. package/dist/extension/chipEditor.d.ts +30 -9
  15. package/dist/extension/chipEditor.js +173 -59
  16. package/dist/extension/claudeRules.d.ts +0 -2
  17. package/dist/extension/claudeRules.js +0 -8
  18. package/dist/extension/cmux/dispatcher.d.ts +25 -0
  19. package/dist/extension/cmux/dispatcher.js +266 -0
  20. package/dist/extension/cmux/hooks.d.ts +12 -0
  21. package/dist/extension/cmux/hooks.js +192 -0
  22. package/dist/extension/cmux/index.d.ts +3 -0
  23. package/dist/extension/cmux/index.js +155 -0
  24. package/dist/extension/cmux/naming.d.ts +5 -0
  25. package/dist/extension/cmux/naming.js +23 -0
  26. package/dist/extension/cmux/state.d.ts +33 -0
  27. package/dist/extension/cmux/state.js +142 -0
  28. package/dist/extension/config.d.ts +32 -1
  29. package/dist/extension/config.js +36 -4
  30. package/dist/extension/costHud.d.ts +16 -22
  31. package/dist/extension/costHud.js +8 -47
  32. package/dist/extension/crashReport.js +1 -3
  33. package/dist/extension/execPolicy.d.ts +119 -0
  34. package/dist/extension/execPolicy.js +805 -0
  35. package/dist/extension/footer.d.ts +111 -0
  36. package/dist/extension/footer.js +294 -0
  37. package/dist/extension/guardian.d.ts +129 -0
  38. package/dist/extension/guardian.js +213 -0
  39. package/dist/extension/index.d.ts +15 -4
  40. package/dist/extension/index.js +250 -24
  41. package/dist/extension/permission.d.ts +123 -10
  42. package/dist/extension/permission.js +586 -40
  43. package/dist/extension/pipeline/childRegistry.d.ts +41 -0
  44. package/dist/extension/pipeline/childRegistry.js +118 -0
  45. package/dist/extension/pipeline/finish.js +5 -1
  46. package/dist/extension/pipeline/goCommand.d.ts +1 -1
  47. package/dist/extension/pipeline/goCommand.js +35 -6
  48. package/dist/extension/pipeline/goStatusCommands.d.ts +10 -0
  49. package/dist/extension/pipeline/goStatusCommands.js +61 -1
  50. package/dist/extension/pipeline/personas.js +25 -0
  51. package/dist/extension/pipeline/runRegistry.d.ts +14 -0
  52. package/dist/extension/pipeline/runRegistry.js +35 -0
  53. package/dist/extension/pipeline/runner.js +4 -0
  54. package/dist/extension/pipeline/verify.d.ts +4 -0
  55. package/dist/extension/pipeline/verify.js +48 -26
  56. package/dist/extension/redact.d.ts +20 -0
  57. package/dist/extension/redact.js +64 -0
  58. package/dist/extension/rerouteNotice.d.ts +3 -4
  59. package/dist/extension/rerouteNotice.js +20 -11
  60. package/dist/extension/subagentRender.d.ts +129 -0
  61. package/dist/extension/subagentRender.js +441 -0
  62. package/dist/extension/subagents.d.ts +4 -7
  63. package/dist/extension/subagents.js +103 -33
  64. package/dist/extension/ticketTools.d.ts +37 -0
  65. package/dist/extension/ticketTools.js +117 -0
  66. package/dist/extension/tokenProvider.js +46 -5
  67. package/dist/launch.d.ts +7 -0
  68. package/dist/launch.js +24 -12
  69. package/dist/padding.d.ts +22 -0
  70. package/dist/padding.js +25 -0
  71. package/dist/promptEnrichment.d.ts +40 -0
  72. package/dist/promptEnrichment.js +85 -0
  73. package/dist/signalForward.d.ts +60 -0
  74. package/dist/signalForward.js +130 -0
  75. package/package.json +5 -5
  76. package/dist/extension/boostCommand.d.ts +0 -144
  77. package/dist/extension/boostCommand.js +0 -263
@@ -0,0 +1,805 @@
1
+ /**
2
+ * Exec policy engine — classifies bash commands via prefix rules + lightweight
3
+ * shell tokenization (YAG-504, restructured in YAG-510).
4
+ *
5
+ * Pure: no I/O, no network, no model. Loads at startup and classifies
6
+ * synchronously. The curated default set auto-allows read-only commands
7
+ * (ls, cat, rg, git status/log/diff), forbids destructive ones (rm -rf,
8
+ * git reset --hard, git push --force, pipe-to-shell), and prompts for the
9
+ * ambiguous middle band (npm install, git commit, curl, …).
10
+ *
11
+ * The `prompt` band is what the Guardian arbitrates — see guardian.ts.
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
+ *
33
+ * Tokenization is a lightweight inline parser — not shell-quote — because the
34
+ * extension is bundled into @yagni-app/code's dist (a file copy, not a real
35
+ * bundler), and external dependencies aren't resolvable from the bundled path.
36
+ */
37
+ /**
38
+ * Parse a shell command string into tokens and control operators.
39
+ *
40
+ * Handles:
41
+ * - Single and double quoted strings (preserves spaces inside)
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 &
48
+ *
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).
52
+ */
53
+ export function shellParse(command) {
54
+ const tokens = [];
55
+ let i = 0;
56
+ let current = "";
57
+ let inSingle = false;
58
+ let inDouble = false;
59
+ let hasConstruct = false;
60
+ const pushCurrent = () => {
61
+ if (current.length > 0) {
62
+ tokens.push(current);
63
+ current = "";
64
+ }
65
+ };
66
+ while (i < command.length) {
67
+ const ch = command[i];
68
+ if (inSingle) {
69
+ if (ch === "'") {
70
+ inSingle = false;
71
+ }
72
+ else {
73
+ current += ch;
74
+ }
75
+ i++;
76
+ continue;
77
+ }
78
+ if (inDouble) {
79
+ if (ch === '"') {
80
+ inDouble = false;
81
+ i++;
82
+ continue;
83
+ }
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;
97
+ }
98
+ current += ch;
99
+ i++;
100
+ continue;
101
+ }
102
+ switch (ch) {
103
+ case "'":
104
+ inSingle = true;
105
+ i++;
106
+ continue;
107
+ case '"':
108
+ inDouble = true;
109
+ i++;
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;
121
+ case " ":
122
+ case "\t":
123
+ pushCurrent();
124
+ i++;
125
+ continue;
126
+ case "\n":
127
+ case "\r":
128
+ // Newlines separate commands like `;` — NOT whitespace.
129
+ pushCurrent();
130
+ tokens.push({ op: "semi" });
131
+ i++;
132
+ continue;
133
+ case "|":
134
+ if (command[i + 1] === "|") {
135
+ pushCurrent();
136
+ tokens.push({ op: "or" });
137
+ i += 2;
138
+ }
139
+ else {
140
+ pushCurrent();
141
+ tokens.push({ op: "pipe" });
142
+ i++;
143
+ }
144
+ continue;
145
+ case "&":
146
+ if (command[i + 1] === "&") {
147
+ pushCurrent();
148
+ tokens.push({ op: "and" });
149
+ i += 2;
150
+ }
151
+ else {
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" });
157
+ hasConstruct = true;
158
+ i++;
159
+ }
160
+ continue;
161
+ case ";":
162
+ pushCurrent();
163
+ tokens.push({ op: "semi" });
164
+ i++;
165
+ continue;
166
+ case ">":
167
+ case "<":
168
+ pushCurrent();
169
+ tokens.push({ op: "redirect" });
170
+ hasConstruct = true;
171
+ // Skip the operator char(s) and any following space
172
+ i++;
173
+ if (command[i] === ch)
174
+ i++; // >> or <<
175
+ while (command[i] === " " || command[i] === "\t")
176
+ i++;
177
+ continue;
178
+ case "$":
179
+ if (command[i + 1] === "(") {
180
+ pushCurrent();
181
+ tokens.push({ op: "substitution" });
182
+ hasConstruct = true;
183
+ // Skip until matching )
184
+ i += 2;
185
+ let depth = 1;
186
+ while (i < command.length && depth > 0) {
187
+ if (command[i] === "(")
188
+ depth++;
189
+ if (command[i] === ")")
190
+ depth--;
191
+ i++;
192
+ }
193
+ continue;
194
+ }
195
+ current += ch;
196
+ i++;
197
+ continue;
198
+ case "`":
199
+ pushCurrent();
200
+ tokens.push({ op: "substitution" });
201
+ hasConstruct = true;
202
+ i++;
203
+ while (i < command.length && command[i] !== "`")
204
+ i++;
205
+ if (i < command.length)
206
+ i++;
207
+ continue;
208
+ default:
209
+ current += ch;
210
+ i++;
211
+ continue;
212
+ }
213
+ }
214
+ pushCurrent();
215
+ // If we detected constructs but didn't emit them as operator tokens
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"))) {
219
+ tokens.push({ op: "substitution" });
220
+ }
221
+ return tokens;
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
+ }
278
+ /** Interpreters that, when piped into, indicate code execution — always forbidden. */
279
+ const PIPE_TO_SHELL = new Set([
280
+ "sh", "bash", "zsh", "fish", "nc", "ncat", "socat",
281
+ "python", "python3", "perl", "ruby", "node",
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_]*=/;
289
+ /**
290
+ * Parse a command string into tokens using our lightweight tokenizer. Returns
291
+ * string tokens only (control operators and constructs are filtered out —
292
+ * detected separately).
293
+ */
294
+ export function tokenize(command) {
295
+ return shellParse(command).filter((t) => typeof t === "string");
296
+ }
297
+ /** Operator tokens we can safely split on (compound command segments). */
298
+ const SPLIT_OPS = new Set(["pipe", "and", "or", "semi"]);
299
+ /**
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.
304
+ */
305
+ function hasUnhandledConstructs(command) {
306
+ return shellParse(command).some((t) => typeof t === "object" && "op" in t && !SPLIT_OPS.has(t.op));
307
+ }
308
+ /**
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.
313
+ */
314
+ function splitSegmentsTokens(command) {
315
+ const parsed = shellParse(command);
316
+ const segments = [];
317
+ let current = [];
318
+ let skipNext = false;
319
+ for (const t of parsed) {
320
+ if (typeof t === "object") {
321
+ if (SPLIT_OPS.has(t.op)) {
322
+ if (current.length > 0)
323
+ segments.push(current);
324
+ current = [];
325
+ skipNext = false;
326
+ }
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.
332
+ }
333
+ else {
334
+ if (skipNext) {
335
+ skipNext = false;
336
+ continue;
337
+ }
338
+ current.push(t);
339
+ }
340
+ }
341
+ if (current.length > 0)
342
+ segments.push(current);
343
+ return segments;
344
+ }
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) {
421
+ const t = tokens[i];
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;
434
+ }
435
+ break;
436
+ }
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;
443
+ }
444
+ /** Match a token array against a prefix rule's pattern. */
445
+ function matchRule(tokens, rule) {
446
+ if (tokens.length < rule.pattern.length)
447
+ return false;
448
+ for (let i = 0; i < rule.pattern.length; i++) {
449
+ const pat = rule.pattern[i];
450
+ const tok = tokens[i];
451
+ if (typeof pat === "string") {
452
+ if (pat !== tok)
453
+ return false;
454
+ }
455
+ else {
456
+ if (!pat.includes(tok))
457
+ return false;
458
+ }
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
+ }
466
+ if (rule.unlessTokens) {
467
+ for (const tok of tokens.slice(rule.pattern.length)) {
468
+ for (const unless of rule.unlessTokens) {
469
+ if (tokenMatchesEntry(tok, unless))
470
+ return false;
471
+ }
472
+ }
473
+ }
474
+ return true;
475
+ }
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) {
486
+ return { decision: "prompt", justification: "empty command segment" };
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
+ }
518
+ // First match wins (rules are ordered; more specific rules come first).
519
+ for (const rule of policy.rules) {
520
+ if (opts.forbiddenOnly && rule.decision !== "forbidden")
521
+ continue;
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
+ }
529
+ return { decision: rule.decision, justification: rule.justification, matchedRule: rule };
530
+ }
531
+ }
532
+ if (opts.forbiddenOnly) {
533
+ return { decision: "allow", justification: "no forbidden match" };
534
+ }
535
+ // No rule matched → prompt (fail toward review, not toward allow)
536
+ return { decision: "prompt", justification: `no policy rule matched for "${tokens[0]}"` };
537
+ }
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
+ }
588
+ /**
589
+ * Classify a full bash command string against the exec policy.
590
+ *
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
596
+ * forbidden.
597
+ */
598
+ export function classifyCommand(command, policy) {
599
+ // Pipe-to-shell is always forbidden regardless of other rules.
600
+ if (isPipeToShell(command)) {
601
+ return {
602
+ decision: "forbidden",
603
+ justification: "piping into a shell or network interpreter is forbidden",
604
+ };
605
+ }
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;
614
+ }
615
+ if (result === null) {
616
+ result = { decision: "prompt", justification: "empty command segment" };
617
+ }
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
+ };
630
+ }
631
+ return result;
632
+ }
633
+ /** Curated default rules — the shipped safety floor. */
634
+ export const DEFAULT_EXEC_POLICY = {
635
+ rules: [
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) ---
680
+ {
681
+ pattern: ["rm", ["-rf", "-fr", "-r", "-f", "--recursive", "--force"]],
682
+ decision: "forbidden",
683
+ justification: "recursive/forced deletion is destructive and irreversible",
684
+ },
685
+ { pattern: ["git", "reset", "--hard"], decision: "forbidden", justification: "hard reset discards uncommitted changes irreversibly" },
686
+ { pattern: ["git", "checkout", "--"], decision: "forbidden", justification: "discards working tree changes" },
687
+ { pattern: ["dd"], decision: "forbidden", justification: "low-level disk operations are destructive" },
688
+ { pattern: ["mkfs"], decision: "forbidden", justification: "filesystem formatting is destructive" },
689
+ { pattern: ["shutdown"], decision: "forbidden", justification: "system shutdown" },
690
+ { pattern: ["reboot"], decision: "forbidden", justification: "system reboot" },
691
+ { pattern: ["truncate"], decision: "forbidden", justification: "truncates files destructively" },
692
+ // --- allow: read-only commands ---
693
+ { pattern: ["ls"], decision: "allow", justification: "list directory contents" },
694
+ { pattern: ["cat"], decision: "allow", justification: "read file contents" },
695
+ { pattern: ["head"], decision: "allow", justification: "read file head" },
696
+ { pattern: ["tail"], decision: "allow", justification: "read file tail" },
697
+ { pattern: ["wc"], decision: "allow", justification: "count lines/words" },
698
+ { pattern: ["pwd"], decision: "allow", justification: "print working directory" },
699
+ { pattern: ["which"], decision: "allow", justification: "locate a command" },
700
+ { pattern: ["echo"], decision: "allow", justification: "print text" },
701
+ { pattern: ["true"], decision: "allow", justification: "no-op success" },
702
+ { pattern: ["false"], decision: "allow", justification: "no-op failure" },
703
+ { pattern: ["test"], decision: "allow", justification: "test condition" },
704
+ {
705
+ pattern: ["find"],
706
+ decision: "allow",
707
+ justification: "search for files (read-only without -delete/-exec)",
708
+ unlessTokens: ["-delete", "-exec", "-execdir", "-ok", "-okdir", "-fprint*", "-fls"],
709
+ },
710
+ { pattern: ["grep"], decision: "allow", justification: "search text" },
711
+ { pattern: ["rg"], decision: "allow", justification: "search text (ripgrep)" },
712
+ { pattern: ["ag"], decision: "allow", justification: "search text (silver searcher)" },
713
+ { pattern: ["cd"], decision: "allow", justification: "change directory (scoped to this bash invocation)" },
714
+ {
715
+ pattern: ["sed"],
716
+ decision: "allow",
717
+ justification: "stream-edit text to stdout (read-only without -i)",
718
+ unlessTokens: ["-i*", "--in-place*"],
719
+ },
720
+ { pattern: ["awk"], decision: "allow", justification: "text processing to stdout" },
721
+ { pattern: ["sort"], decision: "allow", justification: "sort lines" },
722
+ { pattern: ["uniq"], decision: "allow", justification: "filter duplicate lines" },
723
+ { pattern: ["cut"], decision: "allow", justification: "extract columns" },
724
+ { pattern: ["tr"], decision: "allow", justification: "translate characters" },
725
+ { pattern: ["diff"], decision: "allow", justification: "compare files" },
726
+ { pattern: ["nl"], decision: "allow", justification: "number lines" },
727
+ { pattern: ["jq"], decision: "allow", justification: "filter JSON to stdout" },
728
+ { pattern: ["stat"], decision: "allow", justification: "show file metadata" },
729
+ { pattern: ["file"], decision: "allow", justification: "identify file type" },
730
+ { pattern: ["basename"], decision: "allow", justification: "strip directory from path" },
731
+ { pattern: ["dirname"], decision: "allow", justification: "extract directory from path" },
732
+ { pattern: ["realpath"], decision: "allow", justification: "resolve a path" },
733
+ { pattern: ["readlink"], decision: "allow", justification: "resolve a symlink" },
734
+ { pattern: ["tree"], decision: "allow", justification: "list directory tree" },
735
+ { pattern: ["du"], decision: "allow", justification: "show disk usage" },
736
+ { pattern: ["df"], decision: "allow", justification: "show filesystem usage" },
737
+ { pattern: ["date"], decision: "allow", justification: "show date/time" },
738
+ { pattern: ["printf"], decision: "allow", justification: "print formatted text" },
739
+ { pattern: ["whoami"], decision: "allow", justification: "show current user" },
740
+ { pattern: ["uname"], decision: "allow", justification: "show system info" },
741
+ { pattern: ["git", "status"], decision: "allow", justification: "show working tree status" },
742
+ { pattern: ["git", "log"], decision: "allow", justification: "show commit log" },
743
+ { pattern: ["git", "diff"], decision: "allow", justification: "show changes" },
744
+ { pattern: ["git", "branch"], decision: "allow", justification: "list branches" },
745
+ { pattern: ["git", "show"], decision: "allow", justification: "show a commit" },
746
+ { pattern: ["git", "remote"], decision: "allow", justification: "list remotes" },
747
+ { pattern: ["git", "rev-parse"], decision: "allow", justification: "resolve git refs" },
748
+ { pattern: ["git", "worktree", "list"], decision: "allow", justification: "list worktrees" },
749
+ { pattern: ["git", "blame"], decision: "allow", justification: "show line authorship" },
750
+ { pattern: ["git", "grep"], decision: "allow", justification: "search tracked files" },
751
+ { pattern: ["git", "ls-files"], decision: "allow", justification: "list tracked files" },
752
+ { pattern: ["git", "describe"], decision: "allow", justification: "describe a commit" },
753
+ { pattern: ["git", "shortlog"], decision: "allow", justification: "summarize commit log" },
754
+ { pattern: ["git", "stash", "list"], decision: "allow", justification: "list stashes" },
755
+ { pattern: ["gh", "pr", ["view", "list", "diff", "checks", "status"]], decision: "allow", justification: "read pull request data" },
756
+ { pattern: ["gh", "issue", ["view", "list", "status"]], decision: "allow", justification: "read issue data" },
757
+ { pattern: ["gh", "run", ["view", "list"]], decision: "allow", justification: "read workflow run data" },
758
+ { pattern: ["gh", "repo", "view"], decision: "allow", justification: "read repository data" },
759
+ { pattern: ["gh", "search"], decision: "allow", justification: "search GitHub" },
760
+ { pattern: ["node", "--version"], decision: "allow", justification: "check node version" },
761
+ { pattern: ["node", "-v"], decision: "allow", justification: "check node version" },
762
+ { pattern: ["npm", "ls"], decision: "allow", justification: "list installed packages" },
763
+ { pattern: ["pnpm", "ls"], decision: "allow", justification: "list installed packages" },
764
+ { pattern: ["pnpm", "--version"], decision: "allow", justification: "check pnpm version" },
765
+ { pattern: ["tsc", "--version"], decision: "allow", justification: "check typescript version" },
766
+ // --- prompt: potentially destructive but context-dependent ---
767
+ { pattern: ["rm"], decision: "prompt", justification: "file deletion — review the target" },
768
+ { pattern: ["git", "commit"], decision: "prompt", justification: "creates a commit — confirm intent" },
769
+ { pattern: ["git", "push"], decision: "prompt", justification: "pushes to remote — confirm intent" },
770
+ { pattern: ["git", "merge"], decision: "prompt", justification: "merges branches — may conflict" },
771
+ { pattern: ["git", "rebase"], decision: "prompt", justification: "rebases — rewrites history" },
772
+ { pattern: ["git", "stash"], decision: "prompt", justification: "stashes working changes" },
773
+ { pattern: ["git", "checkout"], decision: "prompt", justification: "switches branch or restores files" },
774
+ { pattern: ["git", "switch"], decision: "prompt", justification: "switches branch" },
775
+ { pattern: ["git", "reset"], decision: "prompt", justification: "resets HEAD — may discard changes" },
776
+ { pattern: ["git", "revert"], decision: "prompt", justification: "creates a revert commit" },
777
+ { pattern: ["git", "cherry-pick"], decision: "prompt", justification: "applies a specific commit" },
778
+ { pattern: ["npm", "install"], decision: "prompt", justification: "installs packages — modifies node_modules" },
779
+ { pattern: ["npm", "ci"], decision: "prompt", justification: "clean install — modifies node_modules" },
780
+ { pattern: ["pnpm", "install"], decision: "prompt", justification: "installs packages — modifies node_modules" },
781
+ { pattern: ["pnpm", "add"], decision: "prompt", justification: "adds a dependency" },
782
+ { pattern: ["pnpm", "remove"], decision: "prompt", justification: "removes a dependency" },
783
+ { pattern: ["npm", "run"], decision: "prompt", justification: "runs a script — may have side effects" },
784
+ { pattern: ["pnpm", ["run", "exec"]], decision: "prompt", justification: "runs a script or binary — may have side effects" },
785
+ { pattern: ["npx"], decision: "prompt", justification: "executes a package — may have side effects" },
786
+ { pattern: ["curl"], decision: "prompt", justification: "network request — review the URL" },
787
+ { pattern: ["wget"], decision: "prompt", justification: "network download — review the URL" },
788
+ { pattern: ["docker"], decision: "prompt", justification: "docker command — may have side effects" },
789
+ { pattern: ["psql"], decision: "prompt", justification: "database command — may modify data" },
790
+ { pattern: ["fly"], decision: "prompt", justification: "Fly.io command — may affect production" },
791
+ { pattern: ["kubectl"], decision: "prompt", justification: "Kubernetes command — may affect production" },
792
+ { pattern: ["mv"], decision: "prompt", justification: "moves files — may overwrite" },
793
+ { pattern: ["cp"], decision: "prompt", justification: "copies files" },
794
+ { pattern: ["mkdir"], decision: "prompt", justification: "creates directories" },
795
+ { pattern: ["touch"], decision: "prompt", justification: "creates or updates file timestamps" },
796
+ { pattern: ["tar"], decision: "prompt", justification: "archive operation" },
797
+ { pattern: ["zip"], decision: "prompt", justification: "archive operation" },
798
+ { pattern: ["unzip"], decision: "prompt", justification: "archive operation" },
799
+ { pattern: ["ps"], decision: "prompt", justification: "lists processes" },
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" },
803
+ ],
804
+ };
805
+ //# sourceMappingURL=execPolicy.js.map