prae-cli 0.9.2 → 0.10.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/lib/playground.js CHANGED
@@ -2,124 +2,676 @@
2
2
 
3
3
 
4
4
  //#region ../kernel/src/command.ts
5
- /** Wrappers whose first operand is the program actually being run. */
6
- const WRAPPERS = new Set([
7
- "sudo",
8
- "command",
9
- "env",
10
- "nohup",
11
- "time",
12
- "xargs"
5
+ /** Wrappers whose first operand is the program, and their options that take a value. */
6
+ const WRAPPERS = new Map(Object.entries({
7
+ sudo: [
8
+ "-u",
9
+ "-g",
10
+ "-p",
11
+ "-h",
12
+ "-C",
13
+ "-r",
14
+ "-t",
15
+ "-U",
16
+ "-D",
17
+ "-T",
18
+ "-R"
19
+ ],
20
+ doas: ["-u", "-C"],
21
+ env: [
22
+ "-u",
23
+ "-C",
24
+ "-P",
25
+ "--unset",
26
+ "--chdir"
27
+ ],
28
+ command: [],
29
+ builtin: [],
30
+ exec: ["-a"],
31
+ nohup: [],
32
+ time: ["-f", "-o"],
33
+ nice: ["-n"],
34
+ timeout: [
35
+ "-s",
36
+ "-k",
37
+ "--signal",
38
+ "--kill-after"
39
+ ],
40
+ setsid: [],
41
+ stdbuf: [
42
+ "-i",
43
+ "-o",
44
+ "-e"
45
+ ],
46
+ xargs: [
47
+ "-I",
48
+ "-n",
49
+ "-P",
50
+ "-L",
51
+ "-s",
52
+ "-d",
53
+ "-E",
54
+ "-a",
55
+ "--arg-file"
56
+ ]
57
+ }).map(([name, options]) => [name, new Set(options)]));
58
+ const SHELLS = new Set([
59
+ "sh",
60
+ "bash",
61
+ "zsh",
62
+ "dash",
63
+ "ksh",
64
+ "mksh",
65
+ "ash"
13
66
  ]);
14
- /** Wrapper options that consume the next token, so it is not the program. */
15
- const OPTION_WITH_VALUE = new Set([
16
- "-u",
17
- "-g",
18
- "-p",
19
- "-h",
20
- "-C",
21
- "-r",
22
- "-t",
23
- "-U",
24
- "-I",
25
- "-n",
26
- "-P",
27
- "-L",
28
- "-s"
67
+ /** Words that open or continue a compound command, not a program. */
68
+ const RESERVED = new Set([
69
+ "!",
70
+ "if",
71
+ "then",
72
+ "elif",
73
+ "else",
74
+ "fi",
75
+ "while",
76
+ "until",
77
+ "do",
78
+ "done"
29
79
  ]);
30
- const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
80
+ const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*\+?=/;
81
+ /** Scripts inside scripts (`bash -c`, `eval`, backticks, here-documents). */
82
+ const MAX_DEPTH = 8;
83
+ /** Brackets inside one script. */
84
+ const MAX_NESTING = 64;
85
+ /** A part of the line that cannot be read with confidence. */
86
+ var Undecomposable = class extends Error {};
87
+ const basename = (word) => (word.split("/").pop() ?? word).toLowerCase();
88
+ const isBlank = (c) => c === " " || c === " " || c === "\r";
89
+ const isSpecial = (c) => /^[0-9@*#?$!-]$/.test(c);
90
+ const expands = (w) => /[*?]|\[.*\]|\{.*(,|\.\.).*\}/.test(w.bare);
31
91
  /**
32
- * Split a line into pipeline segments of words, one scan, quote-aware.
33
- *
34
- * Splitting on `|` before parsing quotes is the classic mistake — it turns
35
- * `echo "a | b"` into two segments — so quoting and separators are read
36
- * together.
92
+ * Read one script into `out`. `expansionsOnly` reads it as the body of an
93
+ * unquoted here-document: text, in which only substitutions run.
37
94
  */
38
- function scan(command) {
39
- const parts = [];
40
- let tokens = [];
41
- let current = "";
42
- let quote;
43
- const endToken = () => {
44
- if (current !== "") tokens.push(current);
45
- current = "";
95
+ function read(src, out, depth, expansionsOnly = false) {
96
+ if (depth > MAX_DEPTH) throw new Undecomposable(`scripts nested more than ${MAX_DEPTH} deep`);
97
+ let at = 0;
98
+ let nesting = 0;
99
+ /** Commands here read their standard input from a pipe. */
100
+ let piped = false;
101
+ const pending = [];
102
+ const ch = (k = 0) => src[at + k] ?? "";
103
+ const ahead = (s) => src.startsWith(s, at);
104
+ const ends = (k) => {
105
+ const c = ch(k);
106
+ return c === "" || isBlank(c) || c === "\n" || ";&|()<>".includes(c);
46
107
  };
47
- const endSegment = () => {
48
- endToken();
49
- if (tokens.length > 0) parts.push(tokens);
50
- tokens = [];
108
+ const deeper = () => {
109
+ nesting += 1;
110
+ if (nesting > MAX_NESTING) throw new Undecomposable(`brackets nested more than ${MAX_NESTING} deep`);
51
111
  };
52
- for (let at = 0; at < command.length; at += 1) {
53
- const ch = command[at];
54
- if (quote !== void 0) {
55
- if (ch === quote) quote = void 0;
56
- else current += ch;
57
- continue;
112
+ const fresh = () => ({
113
+ text: "",
114
+ literal: true,
115
+ quoted: false,
116
+ bare: ""
117
+ });
118
+ /** A script inside this one: its unreadable parts are recorded, not fatal. */
119
+ const nested = (text, onlyExpansions = false) => {
120
+ try {
121
+ read(text, out, depth + 1, onlyExpansions);
122
+ } catch (error) {
123
+ if (!(error instanceof Undecomposable)) throw error;
124
+ out.undecomposable.push(error.message);
58
125
  }
59
- if (ch === "'" || ch === "\"") {
60
- quote = ch;
61
- continue;
126
+ };
127
+ function blanks() {
128
+ for (;;) {
129
+ const c = ch();
130
+ if (isBlank(c)) at += 1;
131
+ else if (c === "\\" && ch(1) === "\n") at += 2;
132
+ else if (c === "#") while (at < src.length && ch() !== "\n") at += 1;
133
+ else return;
62
134
  }
63
- if (ch === ";") {
64
- endSegment();
65
- continue;
135
+ }
136
+ /** One unquoted newline, then the here-document bodies it starts. */
137
+ function newline() {
138
+ at += 1;
139
+ for (let doc = pending.shift(); doc; doc = pending.shift()) body(doc);
140
+ }
141
+ function body(doc) {
142
+ let text = "";
143
+ while (at < src.length) {
144
+ const stop = src.indexOf("\n", at);
145
+ const end = stop === -1 ? src.length : stop;
146
+ const line = src.slice(at, end);
147
+ at = Math.min(end + 1, src.length);
148
+ if ((doc.strip ? line.replace(/^\t+/, "") : line) === doc.delim) break;
149
+ text += `${line}\n`;
66
150
  }
67
- if (ch === "|" || ch === "&") {
68
- endSegment();
69
- if (command[at + 1] === ch) at += 1;
70
- continue;
151
+ if (doc.feed.shell !== void 0) if (!doc.quoted && /[$`\\]/.test(text)) out.undecomposable.push(`a here-document with expansions is run by ${doc.feed.shell}`);
152
+ else nested(text);
153
+ else if (!doc.quoted) nested(text, true);
154
+ }
155
+ function list$1(end) {
156
+ for (;;) {
157
+ blanks();
158
+ const c = ch();
159
+ if (c === "") {
160
+ if (end === void 0) return;
161
+ throw new Undecomposable(end === ")" ? "an unclosed `(`" : "an unclosed `{`");
162
+ }
163
+ if (c === "\n") newline();
164
+ else if (c === ";") {
165
+ if (ch(1) === ";") throw new Undecomposable("`;;` outside a case statement");
166
+ at += 1;
167
+ } else if (c === "&" && ch(1) !== ">") at += ch(1) === "&" ? 2 : 1;
168
+ else if (c === "|" && ch(1) === "|") at += 2;
169
+ else if (c === "|") throw new Undecomposable("a pipe with nothing before it");
170
+ else if (c === ")") {
171
+ if (end !== ")") throw new Undecomposable("an unbalanced `)`");
172
+ at += 1;
173
+ return;
174
+ } else if (end === "}" && c === "}" && ends(1)) {
175
+ at += 1;
176
+ return;
177
+ } else pipeline();
71
178
  }
72
- if (/\s/.test(ch)) {
73
- endToken();
74
- continue;
179
+ }
180
+ function pipeline() {
181
+ const names = [];
182
+ for (let position = 0;; position += 1) {
183
+ names.push(...command(position > 0 || piped));
184
+ blanks();
185
+ if (ch() !== "|" || ch(1) === "|") break;
186
+ at += ch(1) === "&" ? 2 : 1;
187
+ for (blanks(); ch() === "\n"; blanks()) newline();
75
188
  }
76
- current += ch;
189
+ if (names.length > 0) out.pipelines.push(names.join("|"));
77
190
  }
78
- endSegment();
79
- return parts;
80
- }
81
- /** Decompose a command line into pipeline segments. */
82
- function segments(command) {
83
- const found = [];
84
- for (const tokens of scan(command)) {
85
- let at = 0;
86
- const wrappers = [];
87
- while (at < tokens.length) {
88
- const token = tokens[at];
89
- if (ASSIGNMENT.test(token)) {
90
- at += 1;
191
+ function command(fromPipe) {
192
+ blanks();
193
+ if (ahead("((") && arithmetic()) {
194
+ trailing();
195
+ return [];
196
+ }
197
+ if (ch() === "(") {
198
+ at += 1;
199
+ return compound(")", fromPipe);
200
+ }
201
+ if (ch() === "{" && (isBlank(ch(1)) || ch(1) === "\n")) {
202
+ at += 1;
203
+ return compound("}", fromPipe);
204
+ }
205
+ return simple(fromPipe);
206
+ }
207
+ function compound(end, fromPipe) {
208
+ deeper();
209
+ const before = out.segments.length;
210
+ const outer = piped;
211
+ piped = fromPipe;
212
+ list$1(end);
213
+ piped = outer;
214
+ nesting -= 1;
215
+ trailing();
216
+ return out.segments.slice(before).map((s) => s.name);
217
+ }
218
+ /** Redirections after a compound command; anything else is not shell. */
219
+ function trailing() {
220
+ blanks();
221
+ while (redirection() !== void 0) blanks();
222
+ if (!ends(0) && ch() !== "}") throw new Undecomposable("a word after a compound command");
223
+ }
224
+ function simple(fromPipe) {
225
+ const mark = out.segments.length;
226
+ const words = [];
227
+ const redirects = [];
228
+ const feed = {};
229
+ let runsNothing = false;
230
+ let conditional = false;
231
+ for (;;) {
232
+ blanks();
233
+ const c = ch();
234
+ if (conditional) {
235
+ if (c === "") throw new Undecomposable("an unclosed `[[`");
236
+ if (c === "\n") {
237
+ newline();
238
+ continue;
239
+ }
240
+ if (ahead("]]") && ends(2)) {
241
+ at += 2;
242
+ conditional = false;
243
+ continue;
244
+ }
245
+ const op = /^(&&|\|\||[()<>!])/.exec(src.slice(at, at + 2));
246
+ if (op !== null) {
247
+ at += op[0].length;
248
+ continue;
249
+ }
250
+ }
251
+ if (c === "" || c === "\n" || c === ";" || c === ")" || c === "|") break;
252
+ if (c === "&" && ch(1) !== ">") break;
253
+ if (c === "(") {
254
+ if (ahead("((")) {
255
+ if (arithmetic()) continue;
256
+ throw new Undecomposable("an unexpected `(`");
257
+ }
258
+ if (words.length === 1) {
259
+ at += 1;
260
+ blanks();
261
+ if (ch() === ")") {
262
+ at += 1;
263
+ for (blanks(); ch() === "\n"; blanks()) newline();
264
+ return command(fromPipe);
265
+ }
266
+ }
267
+ throw new Undecomposable("an unexpected `(`");
268
+ }
269
+ const redirected = redirection(feed);
270
+ if (redirected !== void 0) {
271
+ redirects.push(...redirected);
91
272
  continue;
92
273
  }
93
- const word = (token.split("/").pop() ?? token).toLowerCase();
94
- if (WRAPPERS.has(word)) {
95
- wrappers.push(word);
274
+ const w = word();
275
+ if (w === void 0) throw new Undecomposable("an unexpected character");
276
+ if (words.length === 0 && w.literal && !w.quoted) {
277
+ if (RESERVED.has(w.text)) return command(fromPipe);
278
+ if (w.text === "case" || w.text === "coproc") throw new Undecomposable(`a \`${w.text}\` statement`);
279
+ if (w.text === "function") {
280
+ blanks();
281
+ word();
282
+ blanks();
283
+ if (ahead("()")) at += 2;
284
+ for (blanks(); ch() === "\n"; blanks()) newline();
285
+ return command(fromPipe);
286
+ }
287
+ if (w.text === "for" || w.text === "select") runsNothing = true;
288
+ if (w.text === "[[") {
289
+ runsNothing = true;
290
+ conditional = true;
291
+ }
292
+ }
293
+ words.push(w);
294
+ }
295
+ if (runsNothing || words.length === 0) return [];
296
+ const name = resolve(words, redirects, [], false, mark, feed, fromPipe);
297
+ return name === void 0 ? [] : [name];
298
+ }
299
+ /** A redirection at `at`: the operator and its target, as tokens. */
300
+ function redirection(feed) {
301
+ if ((ch() === "<" || ch() === ">") && ch(1) === "(") return void 0;
302
+ const m = /^(\d*)(<<<|<<-|<<|<>|<&|>>|>\||>&|&>>|&>|<|>)/.exec(src.slice(at, at + 12));
303
+ if (m === null) return void 0;
304
+ at += m[0].length;
305
+ blanks();
306
+ const target = word();
307
+ if (target === void 0) throw new Undecomposable("a redirection with no target");
308
+ const op = m[2];
309
+ if (op === "<<" || op === "<<-") pending.push({
310
+ delim: target.text,
311
+ strip: op === "<<-",
312
+ quoted: target.quoted,
313
+ feed: feed ?? {}
314
+ });
315
+ else if (op === "<<<" && feed !== void 0) feed.herestring = target;
316
+ if (op === ">&" || op === "<&") return [`${m[0]}${target.text}`];
317
+ return [m[0], target.text];
318
+ }
319
+ /** One word at `at`, quotes and expansions read; undefined at an operator. */
320
+ function word() {
321
+ const w = fresh();
322
+ const start$1 = at;
323
+ for (;;) {
324
+ const c = ch();
325
+ if (c === "" || isBlank(c) || c === "\n" || ";&|()".includes(c)) break;
326
+ if (c === "<" || c === ">") {
327
+ if (ch(1) !== "(") break;
328
+ const from = at;
329
+ at += 2;
330
+ substitution();
331
+ w.text += src.slice(from, at);
332
+ w.literal = false;
333
+ } else if (c === "\\") {
334
+ if (ch(1) !== "\n") {
335
+ w.text += ch(1);
336
+ w.quoted = true;
337
+ }
338
+ at += 2;
339
+ } else if (c === "'") {
340
+ const end = src.indexOf("'", at + 1);
341
+ if (end === -1) throw new Undecomposable("an unbalanced single quote");
342
+ w.text += src.slice(at + 1, end);
343
+ w.quoted = true;
344
+ at = end + 1;
345
+ } else if (c === "$" && ch(1) === "'") ansi(w);
346
+ else if (c === "$" && ch(1) === "\"") {
347
+ at += 1;
348
+ double(w, true);
349
+ } else if (c === "\"") double(w, true);
350
+ else if (c === "`") backtick(w, false);
351
+ else if (c === "$") dollar(w);
352
+ else {
353
+ w.text += c;
354
+ w.bare += c;
355
+ at += 1;
356
+ }
357
+ }
358
+ return at === start$1 ? void 0 : w;
359
+ }
360
+ /** `"…"` (or, unclosed, a here-document body): only `$` and backticks are live. */
361
+ function double(w, closed) {
362
+ w.quoted = true;
363
+ if (closed) at += 1;
364
+ for (;;) {
365
+ const c = ch();
366
+ if (c === "") {
367
+ if (closed) throw new Undecomposable("an unbalanced double quote");
368
+ return;
369
+ }
370
+ if (c === "\"" && closed) {
371
+ at += 1;
372
+ return;
373
+ }
374
+ const n = ch(1);
375
+ if (c === "\\" && n !== "" && (n === "\n" || "$`\"\\".includes(n))) {
376
+ if (n !== "\n") w.text += n;
377
+ at += 2;
378
+ } else if (c === "`") backtick(w, closed);
379
+ else if (c === "$") dollar(w);
380
+ else {
381
+ w.text += c;
96
382
  at += 1;
97
- while (at < tokens.length) {
98
- const option = tokens[at];
99
- if (!option.startsWith("-")) break;
100
- at += OPTION_WITH_VALUE.has(option) ? 2 : 1;
383
+ }
384
+ }
385
+ }
386
+ /** `$'…'`: escapes can spell anything, so one with escapes is not a literal. */
387
+ function ansi(w) {
388
+ let k = at + 2;
389
+ for (;;) {
390
+ const c = src[k];
391
+ if (c === void 0) throw new Undecomposable("an unbalanced `$'` quote");
392
+ if (c === "'") break;
393
+ if (c === "\\") {
394
+ w.literal = false;
395
+ w.text += c + (src[k + 1] ?? "");
396
+ k += 2;
397
+ } else {
398
+ w.text += c;
399
+ k += 1;
400
+ }
401
+ }
402
+ w.quoted = true;
403
+ at = k + 1;
404
+ }
405
+ /** `$(…)`, `$((…))`, `${…}`, `$NAME`; a lone `$` is a dollar sign. */
406
+ function dollar(w) {
407
+ const from = at;
408
+ if (ahead("$(")) {
409
+ if (!(ahead("$((") && arithmetic())) {
410
+ at += 2;
411
+ substitution();
412
+ }
413
+ } else if (ahead("${")) {
414
+ at += 2;
415
+ parameter();
416
+ } else if (/[A-Za-z_]/.test(ch(1))) {
417
+ at += 1;
418
+ while (/[A-Za-z0-9_]/.test(ch())) at += 1;
419
+ } else if (isSpecial(ch(1))) at += 2;
420
+ else {
421
+ w.text += "$";
422
+ w.bare += "$";
423
+ at += 1;
424
+ return;
425
+ }
426
+ w.text += src.slice(from, at);
427
+ w.literal = false;
428
+ }
429
+ /** After `$(`, `<(` or `>(`: a list up to its `)`, with its own stdin. */
430
+ function substitution() {
431
+ deeper();
432
+ const outer = piped;
433
+ piped = false;
434
+ list$1(")");
435
+ piped = outer;
436
+ nesting -= 1;
437
+ }
438
+ /** After `${`: up to the matching `}`; a default value can run a command. */
439
+ function parameter() {
440
+ deeper();
441
+ const sink = fresh();
442
+ for (;;) {
443
+ const c = ch();
444
+ if (c === "") throw new Undecomposable("an unclosed `${`");
445
+ if (c === "}") break;
446
+ if (c === "\\") at += 2;
447
+ else if (c === "'") {
448
+ const end = src.indexOf("'", at + 1);
449
+ if (end === -1) throw new Undecomposable("an unbalanced single quote");
450
+ at = end + 1;
451
+ } else if (c === "\"") double(sink, true);
452
+ else if (c === "`") backtick(sink, false);
453
+ else if (c === "$") dollar(sink);
454
+ else at += 1;
455
+ }
456
+ at += 1;
457
+ nesting -= 1;
458
+ }
459
+ /**
460
+ * Where `((…))` / `$((…))` at `at` ends, if bash reads it as arithmetic:
461
+ * only when the `)` matching the second `(` is followed at once by `)`.
462
+ * Otherwise bash reads `(` `(` — nested subshells, or `$(` and a subshell —
463
+ * and `((rm -rf /) )` runs rm. Quotes and backticks are skipped whole;
464
+ * anything unclosed is not arithmetic, so it is read the stricter way.
465
+ */
466
+ function arithmeticEnd() {
467
+ let depth$1 = 1;
468
+ for (let k = at + (ch() === "$" ? 3 : 2); k < src.length; k += 1) {
469
+ const c = src[k];
470
+ if (c === "\\") k += 1;
471
+ else if (c === "'" || c === "\"" || c === "`") {
472
+ let end = k + 1;
473
+ while (end < src.length && src[end] !== c) {
474
+ if (src[end] === "\\" && c !== "'") end += 1;
475
+ end += 1;
101
476
  }
477
+ if (end >= src.length) return void 0;
478
+ k = end;
479
+ } else if (c === "(") depth$1 += 1;
480
+ else if (c === ")") {
481
+ depth$1 -= 1;
482
+ if (depth$1 === 0) return src[k + 1] === ")" ? k + 2 : void 0;
483
+ }
484
+ }
485
+ }
486
+ /**
487
+ * `((…))` or `$((…))` read as arithmetic, in which a substitution still
488
+ * runs; false, and nothing read, when bash would not read it so.
489
+ */
490
+ function arithmetic() {
491
+ const end = arithmeticEnd();
492
+ if (end === void 0) return false;
493
+ deeper();
494
+ at += ch() === "$" ? 3 : 2;
495
+ const sink = fresh();
496
+ while (at < end) {
497
+ const c = ch();
498
+ if (c === "$") dollar(sink);
499
+ else if (c === "`") backtick(sink, false);
500
+ else at += 1;
501
+ }
502
+ if (at !== end) throw new Undecomposable("an ambiguous `((`");
503
+ nesting -= 1;
504
+ return true;
505
+ }
506
+ /** `` `…` ``: its text, unescaped, is a script of its own. */
507
+ function backtick(w, inDouble) {
508
+ let inner = "";
509
+ let k = at + 1;
510
+ for (;;) {
511
+ const c = src[k];
512
+ if (c === void 0) throw new Undecomposable("an unbalanced backtick");
513
+ if (c === "`") break;
514
+ const n = src[k + 1] ?? "";
515
+ const escaped = "`$\\".includes(n) || inDouble && n === "\"";
516
+ if (c === "\\" && n !== "" && escaped) {
517
+ inner += n;
518
+ k += 2;
519
+ } else {
520
+ inner += c;
521
+ k += 1;
522
+ }
523
+ }
524
+ w.text += src.slice(at, k + 1);
525
+ w.literal = false;
526
+ at = k + 1;
527
+ nested(inner);
528
+ }
529
+ /**
530
+ * Words to the program they run, inserted at `insertAt` so a command comes
531
+ * before the substitutions in its words; then whatever that program runs in
532
+ * turn (`bash -c`, `eval`, `find -exec`).
533
+ */
534
+ function resolve(words, redirects, seen, open, insertAt, feed, fromPipe = false) {
535
+ const wrappers = [...seen];
536
+ let openEnded = open;
537
+ let replace;
538
+ let k = 0;
539
+ while (k < words.length) {
540
+ const w = words[k];
541
+ if (ASSIGNMENT.test(w.text)) {
542
+ k += 1;
102
543
  continue;
103
544
  }
104
- break;
545
+ if (!w.literal || expands(w)) break;
546
+ const base = basename(w.text);
547
+ const options = WRAPPERS.get(base);
548
+ if (options === void 0) break;
549
+ wrappers.push(base);
550
+ if (base === "xargs") openEnded = true;
551
+ k += 1;
552
+ while (k < words.length) {
553
+ const option = words[k].text;
554
+ if (option === "--") {
555
+ k += 1;
556
+ break;
557
+ }
558
+ if (!option.startsWith("-")) break;
559
+ if (base === "env" && /^(-S|--split-string)/.test(option)) {
560
+ out.undecomposable.push("`env -S` splits a string into a command");
561
+ return insert(insertAt, {
562
+ name: base,
563
+ tokens: [],
564
+ wrappers
565
+ });
566
+ }
567
+ if (base === "xargs" && option.startsWith("-I")) replace = option.length > 2 ? option.slice(2) : words[k + 1]?.text;
568
+ k += options.has(option) ? 2 : 1;
569
+ }
570
+ if (base === "timeout") k += 1;
105
571
  }
106
- const name = tokens[at];
107
- if (name === void 0) {
108
- const last = wrappers.at(-1);
109
- if (last !== void 0) found.push({
572
+ const program = words[k];
573
+ const rest = words.slice(k + 1);
574
+ const tokens = [...rest.map((w) => w.text), ...redirects];
575
+ const last = wrappers.at(-1);
576
+ if (program === void 0) return last === void 0 || wrappers.length === seen.length ? void 0 : insert(insertAt, {
577
+ name: last,
578
+ tokens: [...redirects],
579
+ wrappers
580
+ });
581
+ if (!program.literal || expands(program) || openEnded && (program.text.includes("{}") || replace !== void 0 && replace !== "" && program.text.includes(replace))) {
582
+ out.undecomposable.push("the program word is only known at run time");
583
+ if (last !== void 0) insert(insertAt, {
110
584
  name: last,
111
- tokens: [],
585
+ tokens: [program.text, ...tokens],
112
586
  wrappers
113
587
  });
114
- continue;
588
+ return;
115
589
  }
116
- found.push({
117
- name: (name.split("/").pop() ?? name).toLowerCase(),
118
- tokens: tokens.slice(at + 1),
119
- wrappers
590
+ const name = basename(program.text);
591
+ insert(insertAt, {
592
+ name,
593
+ tokens,
594
+ wrappers,
595
+ ...openEnded ? { openEnded: true } : {}
120
596
  });
597
+ if (SHELLS.has(name)) shell(name, rest, feed, fromPipe);
598
+ else if (name === "eval") if (rest.every((w) => w.literal)) nested(rest.map((w) => w.text).join(" "));
599
+ else out.undecomposable.push("`eval` of a string that is not a literal");
600
+ else if (name === "source" || name === ".") {
601
+ if (rest[0] !== void 0 && !rest[0].literal) out.undecomposable.push(`\`${name}\` of a file only known at run time`);
602
+ } else if (name === "find") for (let i = 0; i < rest.length; i += 1) {
603
+ const flag = rest[i].text;
604
+ if (![
605
+ "-exec",
606
+ "-execdir",
607
+ "-ok",
608
+ "-okdir"
609
+ ].includes(flag)) continue;
610
+ let end = i + 1;
611
+ while (end < rest.length && !/^[;+]$/.test(rest[end].text)) end += 1;
612
+ resolve(rest.slice(i + 1, end), [], [...wrappers, "find"], true, out.segments.length);
613
+ i = end;
614
+ }
615
+ return name;
616
+ }
617
+ function insert(where, segment) {
618
+ out.segments.splice(where, 0, segment);
619
+ return segment.name;
620
+ }
621
+ /** What a shell program runs: a `-c` string, a script file, or its stdin. */
622
+ function shell(name, args, feed, fromPipe) {
623
+ let inline = false;
624
+ let stdin = false;
625
+ let k = 0;
626
+ while (k < args.length) {
627
+ const option = args[k].text;
628
+ if (option === "--" || option === "-") {
629
+ k += 1;
630
+ break;
631
+ }
632
+ if (option === "--rcfile" || option === "--init-file") k += 2;
633
+ else if (option.startsWith("--")) k += 1;
634
+ else if (/^[-+][A-Za-z]+$/.test(option)) {
635
+ if (option.startsWith("-") && option.includes("c")) inline = true;
636
+ if (option.includes("s")) stdin = true;
637
+ k += option.includes("o") ? 2 : 1;
638
+ } else break;
639
+ }
640
+ const operand = args[k];
641
+ if (inline) {
642
+ if (operand === void 0) return;
643
+ if (operand.literal) nested(operand.text);
644
+ else out.undecomposable.push(`\`${name} -c\` runs a string that is not a literal`);
645
+ return;
646
+ }
647
+ if (operand !== void 0 && !stdin) {
648
+ if (!operand.literal) out.undecomposable.push(`\`${name}\` runs a script only known at run time`);
649
+ return;
650
+ }
651
+ if (fromPipe) out.undecomposable.push(`\`${name}\` runs commands read from a pipe`);
652
+ if (feed === void 0) return;
653
+ feed.shell = `\`${name}\``;
654
+ const string = feed.herestring;
655
+ if (string === void 0) return;
656
+ if (string.literal) nested(string.text);
657
+ else out.undecomposable.push(`a here-string with expansions is run by \`${name}\``);
658
+ }
659
+ if (expansionsOnly) double(fresh(), false);
660
+ else list$1(void 0);
661
+ }
662
+ /** Read a command line into every command it would run, and what could not be read. */
663
+ function decompose(command) {
664
+ const out = {
665
+ segments: [],
666
+ pipelines: [],
667
+ undecomposable: []
668
+ };
669
+ try {
670
+ read(command, out, 0);
671
+ } catch (error) {
672
+ out.undecomposable.push(error instanceof Undecomposable ? error.message : "the line could not be read");
121
673
  }
122
- return found;
674
+ return out;
123
675
  }
124
676
  /** `git*` matches `git`, `github`; bare strings match exactly. */
125
677
  function matches$1(pattern, value) {
@@ -144,17 +696,19 @@
144
696
  }
145
697
  const list = (v) => v === void 0 ? [] : typeof v === "string" ? [v] : v;
146
698
  /**
147
- * Judge one command line against one matcher. Every present field must hold.
699
+ * Every present field holds on what was read. With `open`, a segment whose
700
+ * arguments arrive at run time (`xargs`, `find -exec`) satisfies any token
701
+ * condition, because those arguments could.
148
702
  */
149
- function commandMatches(match, command) {
150
- const parts = segments(command);
703
+ function holds(match, read$1, open) {
704
+ const parts = read$1.segments;
151
705
  if (parts.length === 0) return false;
152
706
  const names = list(match.name);
153
707
  const args = list(match.token);
154
708
  const anyArgs = list(match.anyToken);
155
709
  const pipes = list(match.pipedTo);
156
710
  const shapes = list(match.pipeline);
157
- const argsHold = (segment) => tokensHold(args, segment.tokens) && (anyArgs.length === 0 || anyArgs.some((wanted) => segment.tokens.some((token) => matches$1(wanted, token))));
711
+ const argsHold = (segment) => open && segment.openEnded === true || tokensHold(args, segment.tokens) && (anyArgs.length === 0 || anyArgs.some((wanted) => segment.tokens.some((token) => matches$1(wanted, token))));
158
712
  if (names.length > 0) {
159
713
  const hit = parts.filter((segment) => names.some((pattern) => matches$1(pattern, segment.name) || segment.wrappers.some((wrapper) => matches$1(pattern, wrapper))));
160
714
  if (hit.length === 0) return false;
@@ -166,11 +720,32 @@
166
720
  if (!parts.slice(1).some((segment) => pipes.some((pattern) => matches$1(pattern, segment.name)))) return false;
167
721
  }
168
722
  if (shapes.length > 0) {
169
- const signature = parts.map((segment) => segment.name).join("|");
170
- if (!shapes.some((pattern) => globMatches(pattern, signature))) return false;
723
+ const signatures = [parts.map((segment) => segment.name).join("|"), ...read$1.pipelines];
724
+ if (!shapes.some((pattern) => signatures.some((signature) => globMatches(pattern, signature)))) return false;
171
725
  }
172
726
  return true;
173
727
  }
728
+ /**
729
+ * Judge one command line against one matcher, on what could be read. Every
730
+ * present field must hold.
731
+ */
732
+ function commandMatches(match, command) {
733
+ return holds(match, typeof command === "string" ? decompose(command) : command, false);
734
+ }
735
+ /**
736
+ * Why a matcher that did not hold on what was read might still hold on what
737
+ * runs: part of the line could not be read, or a matched program takes
738
+ * arguments only known at run time. Undefined when there is no such reason.
739
+ * A `deny` or `ask` rule matches on it, conservatively (`evaluate.ts`).
740
+ */
741
+ function commandUnread(match, command) {
742
+ const read$1 = typeof command === "string" ? decompose(command) : command;
743
+ if (read$1.undecomposable.length > 0) return [...new Set(read$1.undecomposable)].join("; ");
744
+ if (!holds(match, read$1, true)) return void 0;
745
+ const open = read$1.segments.find((s) => s.openEnded === true);
746
+ const from = open?.wrappers.includes("xargs") ? "xargs" : "find -exec";
747
+ return `the arguments \`${open?.name ?? "?"}\` gets from ${from} are only known at run time`;
748
+ }
174
749
 
175
750
  //#endregion
176
751
  //#region ../kernel/src/path.ts
@@ -383,15 +958,25 @@
383
958
  }
384
959
  return true;
385
960
  }
386
- function matches(when, facts) {
961
+ /**
962
+ * Whether a rule's matcher holds on a call: `false`, or a hit — carrying a
963
+ * note when the hit is conservative. `strict` rules (deny, ask) match a shell
964
+ * line they cannot fully read, because what was not read could be what they
965
+ * refuse; the note says what that was. Weaker rules never match on a guess.
966
+ */
967
+ function matches(when, facts, strict, shell) {
968
+ let note;
387
969
  if (!listed(facts.tool, when.tool)) return false;
388
970
  if (!listed(facts.capability, when.capability)) return false;
389
971
  if (!listed(facts.plugin, when.plugin)) return false;
390
972
  if (!listed(facts.agent, when.agent)) return false;
391
973
  if (when.command !== void 0) {
392
- const raw = facts.args.command ?? facts.args.cmd ?? facts.args.script;
393
- if (typeof raw !== "string") return false;
394
- if (!commandMatches(when.command, raw)) return false;
974
+ const line = shell();
975
+ if (line === void 0) return false;
976
+ if (!commandMatches(when.command, line)) {
977
+ note = strict ? commandUnread(when.command, line) : void 0;
978
+ if (note === void 0) return false;
979
+ }
395
980
  }
396
981
  if (when.path !== void 0) {
397
982
  const raw = pathArgOf(facts.args, when.path.arg);
@@ -403,7 +988,10 @@
403
988
  const raw = facts.args[when.host.arg ?? "url"];
404
989
  if (typeof raw !== "string") return false;
405
990
  const host = hostOf(raw);
406
- if (host === void 0) return when.host.notIn !== void 0;
991
+ if (host === void 0) {
992
+ if (when.host.notIn === void 0) return false;
993
+ return note === void 0 ? {} : { note };
994
+ }
407
995
  if (when.host.in !== void 0 && !hostIn(host, when.host.in)) return false;
408
996
  if (when.host.notIn !== void 0 && hostIn(host, when.host.notIn)) return false;
409
997
  }
@@ -420,7 +1008,7 @@
420
1008
  const wanted = when.secrets.types;
421
1009
  if ((facts.secrets ?? []).filter((finding) => finding.confidence >= floor && (wanted === void 0 || wanted.includes(finding.type))).length < (when.secrets.atLeast ?? 1)) return false;
422
1010
  }
423
- return true;
1011
+ return note === void 0 ? {} : { note };
424
1012
  }
425
1013
  function step(rule, effect) {
426
1014
  return {
@@ -445,10 +1033,26 @@
445
1033
  */
446
1034
  function evaluate(pack, facts) {
447
1035
  const hits = [];
448
- for (const rule of pack.rules) if (matches(rule.when, facts)) hits.push({
449
- rule,
450
- effect: rule.effect
451
- });
1036
+ let line = null;
1037
+ const shell = () => {
1038
+ if (line === null) {
1039
+ const raw = facts.args.command ?? facts.args.cmd ?? facts.args.script;
1040
+ line = typeof raw === "string" ? decompose(raw) : void 0;
1041
+ }
1042
+ return line;
1043
+ };
1044
+ for (const rule of pack.rules) {
1045
+ const strict = rule.effect.kind === "deny" || rule.effect.kind === "ask";
1046
+ const hit = matches(rule.when, facts, strict, shell);
1047
+ if (hit === false) continue;
1048
+ hits.push({
1049
+ rule,
1050
+ effect: hit.note === void 0 ? rule.effect : {
1051
+ ...rule.effect,
1052
+ because: `${rule.effect.because} (could not decompose: ${hit.note})`
1053
+ }
1054
+ });
1055
+ }
452
1056
  if (hits.length === 0) return {
453
1057
  kind: "allow",
454
1058
  trace: [],
@@ -984,6 +1588,7 @@
984
1588
  "version",
985
1589
  "description",
986
1590
  "owner",
1591
+ "expires_at",
987
1592
  "baseline",
988
1593
  "rules"
989
1594
  ]);
@@ -992,6 +1597,8 @@
992
1597
  const version = check.text(".version", raw.version);
993
1598
  const description = raw.description === void 0 ? void 0 : check.text(".description", raw.description);
994
1599
  const owner = raw.owner === void 0 ? void 0 : check.text(".owner", raw.owner);
1600
+ const expires = raw.expires_at === void 0 ? void 0 : check.text(".expires_at", raw.expires_at);
1601
+ if (expires !== void 0 && (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/.test(expires) || !Number.isFinite(Date.parse(expires)))) check.fail(".expires_at", "expected an ISO 8601 time with a zone, e.g. 2026-10-05T12:00:00Z");
995
1602
  let baseline;
996
1603
  if (raw.baseline !== void 0) if (!isObject(raw.baseline)) check.fail(".baseline", "expected an object");
997
1604
  else {
@@ -1030,6 +1637,7 @@
1030
1637
  version,
1031
1638
  ...description === void 0 ? {} : { description },
1032
1639
  ...owner === void 0 ? {} : { owner },
1640
+ ...expires === void 0 ? {} : { expires_at: expires },
1033
1641
  ...baseline === void 0 ? {} : { baseline },
1034
1642
  rules
1035
1643
  }