@inhuman.tools/policy-bash-disassembler 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/prep.ts ADDED
@@ -0,0 +1,850 @@
1
+ /**
2
+ * Question prep: unbash AST → prepared questions + unparsed acts.
3
+ * Hermetic — no PATH, no process.env, no execution. Word.parts is a lazy
4
+ * getter; this file always reads it. Nested parse errors live on the nested
5
+ * script, not the root.
6
+ *
7
+ * Partition builds I: a known value-flag attaches; an unknown flag followed
8
+ * by a non-dash, non-`--` token forks attached and boolean readings.
9
+ * `valueFlagsFor(argv0, suffix)` sees the leading positional spine — not
10
+ * every descendant under argv0. `=value` and glued shorts stay one reading.
11
+ * `--` never attaches as a value. Cap 16 is open ruling 5; overflow is ⊤I.
12
+ * A nameless command still emits its redirects. `xargs -a FILE` is a read
13
+ * of FILE as well as the inner command.
14
+ */
15
+
16
+ import {
17
+ parse,
18
+ type ArithmeticExpression,
19
+ type AssignmentPrefix,
20
+ type Command,
21
+ type Node as BashNode,
22
+ type ParseError,
23
+ type ParsedScript,
24
+ type Redirect,
25
+ type Statement,
26
+ type TestExpression,
27
+ type Word,
28
+ type WordPart,
29
+ } from "unbash";
30
+
31
+ import { resolveKnown, type Which } from "./identity.js";
32
+ import type { Question, Reading } from "./types.js";
33
+
34
+ type UnparsedVia =
35
+ | "parse-error"
36
+ | "control-flow"
37
+ | "function"
38
+ | "source"
39
+ | "test"
40
+ | "arithmetic"
41
+ | "coproc"
42
+ | "find-expression"
43
+ | "non-literal-argv0"
44
+ | "script-string";
45
+
46
+ type UnparsedAct = {
47
+ readonly via: UnparsedVia;
48
+ readonly source: string;
49
+ };
50
+
51
+ type RedirectEmit = {
52
+ readonly operator: string;
53
+ readonly target: string;
54
+ };
55
+
56
+ type PreparedQuestion = Question & {
57
+ readonly wrapChain: readonly string[];
58
+ readonly elevated: boolean;
59
+ readonly envDelta: Readonly<Record<string, string>>;
60
+ /** Some argument (or env value) is not fully literal: `$x`, backticks, `$(…)`, brace/bracket/`*`/`?` globs. */
61
+ readonly nonLiteral: boolean;
62
+ readonly redirects: readonly RedirectEmit[];
63
+ readonly background: boolean;
64
+ readonly stdinIsPipe: boolean;
65
+ };
66
+
67
+ type PrepResult = {
68
+ readonly questions: readonly PreparedQuestion[];
69
+ readonly unparsed: readonly UnparsedAct[];
70
+ readonly errors: readonly ParseError[];
71
+ /** File effects from commands that have no argv0 (`> .env`). */
72
+ readonly redirects: readonly RedirectEmit[];
73
+ };
74
+
75
+ const TRANSPARENT_FLAG_ARGS: Readonly<Record<string, readonly string[]>> = {
76
+ xargs: ["-a", "-d", "-E", "-e", "-I", "-i", "-L", "-l", "-n", "-P", "-s"],
77
+ env: ["-C", "-S", "-u"],
78
+ timeout: ["-k", "--kill-after", "-s", "--signal"],
79
+ nice: ["-n"],
80
+ watch: ["-n", "-d", "-t", "-e"],
81
+ time: ["-p"],
82
+ stdbuf: ["-i", "-o", "-e"],
83
+ exec: ["-a"],
84
+ sudo: ["-C", "-D", "-g", "-l", "-p", "-r", "-U", "-u"],
85
+ doas: ["-C", "-u"],
86
+ };
87
+
88
+ /** Duration is the first positional; COMMAND follows. */
89
+ const HOF_SKIP_POSITIONALS: Readonly<Record<string, number>> = { timeout: 1 };
90
+
91
+ const PARTLESS_FORBIDDEN = /[*?[\\$`]/;
92
+
93
+ type PrepOpts = {
94
+ readonly valueFlags?: ReadonlySet<string>;
95
+ readonly valueFlagsFor?: (argv0: string, suffix: readonly string[]) => ReadonlySet<string> | undefined;
96
+ /** Path-form peelers are transparent only when this agrees. */
97
+ readonly which?: Which;
98
+ };
99
+
100
+ export function prep(source: string, opts?: PrepOpts): PrepResult {
101
+ const script = parse(source);
102
+ const errors = [...(script.errors ?? [])];
103
+ if (errors.length > 0) {
104
+ return { questions: [], unparsed: [{ via: "parse-error", source }], errors, redirects: [] };
105
+ }
106
+ const ctx: Ctx = {
107
+ questions: [],
108
+ unparsed: [],
109
+ wrapChain: [],
110
+ elevated: false,
111
+ envDelta: {},
112
+ nonLiteral: false,
113
+ source,
114
+ valueFlags: opts?.valueFlags,
115
+ valueFlagsFor: opts?.valueFlagsFor,
116
+ which: opts?.which,
117
+ accessReads: [],
118
+ orphans: [],
119
+ };
120
+ walkScript(script, ctx, { background: false, stdinIsPipe: false, redirects: [] });
121
+ return { questions: ctx.questions, unparsed: ctx.unparsed, errors, redirects: ctx.orphans };
122
+ }
123
+
124
+ type Frame = {
125
+ readonly background: boolean;
126
+ readonly stdinIsPipe: boolean;
127
+ readonly redirects: readonly Redirect[];
128
+ };
129
+
130
+ type Ctx = {
131
+ questions: PreparedQuestion[];
132
+ unparsed: UnparsedAct[];
133
+ wrapChain: string[];
134
+ elevated: boolean;
135
+ envDelta: Record<string, string>;
136
+ nonLiteral: boolean;
137
+ source: string;
138
+ valueFlags: ReadonlySet<string> | undefined;
139
+ valueFlagsFor: ((argv0: string, suffix: readonly string[]) => ReadonlySet<string> | undefined) | undefined;
140
+ which: Which | undefined;
141
+ accessReads: readonly string[];
142
+ orphans: RedirectEmit[];
143
+ };
144
+
145
+ function walkScript(script: ParsedScript, ctx: Ctx, frame: Frame): void {
146
+ if (script.errors !== undefined && script.errors.length > 0) {
147
+ ctx.unparsed.push({ via: "parse-error", source: ctx.source.slice(script.pos, script.end) });
148
+ return;
149
+ }
150
+ for (const statement of script.commands) walkStatement(statement, ctx, frame);
151
+ }
152
+
153
+ function walkStatement(statement: Statement, ctx: Ctx, frame: Frame): void {
154
+ walkNode(statement.command, ctx, {
155
+ background: statement.background === true || frame.background,
156
+ stdinIsPipe: frame.stdinIsPipe,
157
+ redirects: [...frame.redirects, ...statement.redirects],
158
+ });
159
+ }
160
+
161
+ function walkNode(node: BashNode, ctx: Ctx, frame: Frame): void {
162
+ switch (node.type) {
163
+ case "Statement":
164
+ walkStatement(node, ctx, frame);
165
+ return;
166
+ case "CompoundList":
167
+ for (const statement of node.commands) walkStatement(statement, ctx, frame);
168
+ return;
169
+ case "Pipeline": {
170
+ const wrap = node.time === true ? withWrap(ctx, "time") : ctx;
171
+ for (const [i, child] of node.commands.entries()) {
172
+ walkNode(child, wrap, { ...frame, stdinIsPipe: frame.stdinIsPipe || i > 0 });
173
+ }
174
+ return;
175
+ }
176
+ case "AndOr":
177
+ for (const child of node.commands) walkNode(child, ctx, frame);
178
+ return;
179
+ case "Subshell":
180
+ case "BraceGroup":
181
+ walkNode(node.body, ctx, frame);
182
+ return;
183
+ case "If":
184
+ case "For":
185
+ case "ArithmeticFor":
186
+ case "While":
187
+ case "Select":
188
+ case "Case":
189
+ ctx.unparsed.push({ via: "control-flow", source: slice(ctx, node) });
190
+ walkControlInner(node, ctx, frame);
191
+ return;
192
+ case "Function":
193
+ ctx.unparsed.push({ via: "function", source: slice(ctx, node) });
194
+ return;
195
+ case "Coproc":
196
+ ctx.unparsed.push({ via: "coproc", source: slice(ctx, node) });
197
+ walkNode(node.body, ctx, frame);
198
+ return;
199
+ case "TestCommand":
200
+ ctx.unparsed.push({ via: "test", source: slice(ctx, node) });
201
+ walkTestWords(node.expression, ctx, frame);
202
+ return;
203
+ case "ArithmeticCommand":
204
+ ctx.unparsed.push({ via: "arithmetic", source: slice(ctx, node) });
205
+ walkArith(node.expression, ctx, frame);
206
+ return;
207
+ case "Command":
208
+ walkCommand(node, ctx, frame);
209
+ return;
210
+ }
211
+ }
212
+
213
+ function walkControlInner(node: BashNode, ctx: Ctx, frame: Frame): void {
214
+ switch (node.type) {
215
+ case "If":
216
+ walkNode(node.clause, ctx, frame);
217
+ walkNode(node.then, ctx, frame);
218
+ if (node.else !== undefined) walkNode(node.else, ctx, frame);
219
+ return;
220
+ case "For":
221
+ case "Select":
222
+ walkWord(node.name, ctx, frame);
223
+ for (const word of node.wordlist) walkWord(word, ctx, frame);
224
+ walkNode(node.body, ctx, frame);
225
+ return;
226
+ case "ArithmeticFor":
227
+ walkArith(node.initialize, ctx, frame);
228
+ walkArith(node.test, ctx, frame);
229
+ walkArith(node.update, ctx, frame);
230
+ walkNode(node.body, ctx, frame);
231
+ return;
232
+ case "While":
233
+ walkNode(node.clause, ctx, frame);
234
+ walkNode(node.body, ctx, frame);
235
+ return;
236
+ case "Case":
237
+ walkWord(node.word, ctx, frame);
238
+ for (const item of node.items) {
239
+ for (const pattern of item.pattern) walkWord(pattern, ctx, frame);
240
+ walkNode(item.body, ctx, frame);
241
+ }
242
+ return;
243
+ default:
244
+ return;
245
+ }
246
+ }
247
+
248
+ function walkCommand(command: Command, ctx: Ctx, frame: Frame): void {
249
+ const envDelta = { ...ctx.envDelta };
250
+ let nonLiteral = ctx.nonLiteral;
251
+ for (const prefix of command.prefix) {
252
+ takeAssignment(prefix, envDelta);
253
+ if (prefix.value !== undefined && !isInertWord(prefix.value)) nonLiteral = true;
254
+ if (prefix.array !== undefined) nonLiteral = true;
255
+ walkAssignment(prefix, ctx, frame);
256
+ }
257
+ const redirects = [...frame.redirects, ...command.redirects];
258
+ for (const redirect of redirects) walkRedirect(redirect, ctx, frame);
259
+
260
+ if (command.name === undefined) {
261
+ ctx.orphans.push(...redirects.flatMap(emitRedirect));
262
+ return;
263
+ }
264
+
265
+ const argv0Word = command.name;
266
+ walkWord(argv0Word, ctx, frame);
267
+
268
+ if (!isLiteralWord(argv0Word)) {
269
+ for (const word of command.suffix) walkWord(word, ctx, frame);
270
+ ctx.unparsed.push({ via: "non-literal-argv0", source: slice(ctx, command) });
271
+ return;
272
+ }
273
+
274
+ const raw = argv0Word.value;
275
+ const argv0 = peelName(raw, ctx.which);
276
+ const suffix = command.suffix.map((word) => word.value);
277
+ if (!command.suffix.every(isInertWord)) nonLiteral = true;
278
+ const innerCtx: Ctx = { ...ctx, envDelta, nonLiteral };
279
+
280
+ switch (argv0) {
281
+ case undefined:
282
+ break;
283
+ case "sudo":
284
+ case "doas":
285
+ emitCommand(innerCtx, raw, suffix, { ...frame, redirects }, true);
286
+ peelInner(command, argv0, suffix, { ...innerCtx, elevated: true }, frame, redirects, true);
287
+ return;
288
+ case "source":
289
+ case ".":
290
+ for (const word of command.suffix) walkWord(word, ctx, frame);
291
+ ctx.unparsed.push({ via: "source", source: slice(ctx, command) });
292
+ return;
293
+ case "find":
294
+ for (const word of command.suffix) walkWord(word, ctx, frame);
295
+ ctx.unparsed.push({ via: "find-expression", source: slice(ctx, command) });
296
+ return;
297
+ case "python":
298
+ case "python3":
299
+ case "node":
300
+ if (hasScriptFlag(suffix)) {
301
+ for (const word of command.suffix) walkWord(word, ctx, frame);
302
+ ctx.unparsed.push({ via: "script-string", source: slice(ctx, command) });
303
+ emitCommand(innerCtx, raw, suffix, { ...frame, redirects }, false);
304
+ return;
305
+ }
306
+ break;
307
+ case "eval":
308
+ peelEval(command, innerCtx, frame, redirects);
309
+ return;
310
+ case "bash":
311
+ case "sh":
312
+ case "zsh":
313
+ case "dash":
314
+ case "ksh":
315
+ peelShellC(command, argv0, suffix, innerCtx, frame, redirects);
316
+ return;
317
+ case "env":
318
+ if (envHasDashFlags(suffix)) {
319
+ for (const word of command.suffix) walkWord(word, ctx, frame);
320
+ emitCommand(innerCtx, raw, suffix, { ...frame, redirects }, false);
321
+ return;
322
+ }
323
+ peelInner(command, argv0, suffix, innerCtx, frame, redirects, false);
324
+ return;
325
+ case "xargs":
326
+ case "timeout":
327
+ case "nice":
328
+ case "nohup":
329
+ case "stdbuf":
330
+ case "watch":
331
+ case "time":
332
+ case "command":
333
+ case "exec":
334
+ case "builtin":
335
+ peelInner(command, argv0, suffix, innerCtx, frame, redirects, false);
336
+ return;
337
+ }
338
+ for (const word of command.suffix) walkWord(word, ctx, frame);
339
+ emitCommand(innerCtx, raw, suffix, { ...frame, redirects }, false);
340
+ }
341
+
342
+ function peelInner(
343
+ command: Command,
344
+ argv0: string,
345
+ suffix: readonly string[],
346
+ ctx: Ctx,
347
+ frame: Frame,
348
+ redirects: readonly Redirect[],
349
+ keepWrapper: boolean,
350
+ ): void {
351
+ const flagArgs = TRANSPARENT_FLAG_ARGS[argv0];
352
+ const skipAssign = argv0 === "env";
353
+ const boundary = scanPassthroughBoundary(suffix, flagArgs, skipAssign) + (HOF_SKIP_POSITIONALS[argv0] ?? 0);
354
+ const reads = wrapperFileReads(argv0, suffix);
355
+ const innerCtx = reads.length === 0 ? ctx : { ...ctx, accessReads: [...ctx.accessReads, ...reads] };
356
+ if (boundary >= suffix.length) {
357
+ if (keepWrapper) {
358
+ for (const word of command.suffix) walkWord(word, innerCtx, frame);
359
+ } else {
360
+ emitCommand(innerCtx, argv0, suffix, { ...frame, redirects }, false);
361
+ }
362
+ return;
363
+ }
364
+ for (const word of command.suffix.slice(0, boundary)) walkWord(word, innerCtx, frame);
365
+ const envDelta = { ...innerCtx.envDelta };
366
+ if (skipAssign) {
367
+ for (const word of suffix.slice(0, boundary)) {
368
+ if (isVarAssignment(word)) takeEnvText(word, envDelta);
369
+ }
370
+ }
371
+ const nameWord = command.suffix[boundary]!;
372
+ const wrapped = withWrap({ ...innerCtx, envDelta }, argv0);
373
+ const inner: Command = {
374
+ type: "Command",
375
+ pos: nameWord.pos,
376
+ end: command.end,
377
+ name: nameWord,
378
+ prefix: [],
379
+ suffix: command.suffix.slice(boundary + 1),
380
+ redirects: [],
381
+ };
382
+ walkCommand(inner, wrapped, { ...frame, redirects });
383
+ }
384
+
385
+ function peelShellC(
386
+ command: Command,
387
+ argv0: string,
388
+ suffix: readonly string[],
389
+ ctx: Ctx,
390
+ frame: Frame,
391
+ redirects: readonly Redirect[],
392
+ ): void {
393
+ for (const word of command.suffix) walkWord(word, ctx, frame);
394
+ const body = scriptFlagBody(suffix, "-c");
395
+ if (body === undefined) {
396
+ emitCommand(ctx, argv0, suffix, { ...frame, redirects }, false);
397
+ return;
398
+ }
399
+ const inner = parse(body);
400
+ if (inner.errors !== undefined && inner.errors.length > 0) {
401
+ ctx.unparsed.push({ via: "script-string", source: body });
402
+ return;
403
+ }
404
+ walkScript(inner, withWrap(ctx, argv0), {
405
+ background: frame.background,
406
+ stdinIsPipe: frame.stdinIsPipe,
407
+ redirects,
408
+ });
409
+ }
410
+
411
+ function peelEval(command: Command, ctx: Ctx, frame: Frame, redirects: readonly Redirect[]): void {
412
+ const suffix = command.suffix.map((word) => word.value);
413
+ emitCommand(ctx, "eval", suffix, { ...frame, redirects }, false);
414
+ if (suffix.length === 0) return;
415
+ const body = suffix.join(" ");
416
+ const inner = parse(body);
417
+ if (inner.errors !== undefined && inner.errors.length > 0) {
418
+ ctx.unparsed.push({ via: "script-string", source: body });
419
+ return;
420
+ }
421
+ walkScript(inner, withWrap(ctx, "eval"), {
422
+ background: frame.background,
423
+ stdinIsPipe: frame.stdinIsPipe,
424
+ redirects,
425
+ });
426
+ }
427
+
428
+ function emitCommand(ctx: Ctx, argv0: string, suffix: readonly string[], frame: Frame, elevated: boolean): void {
429
+ const { readings, overflow } = partition(suffix, ctx.valueFlagsFor?.(argv0, suffix) ?? ctx.valueFlags);
430
+ ctx.questions.push({
431
+ argv0,
432
+ readings,
433
+ ...(overflow ? { overflow: true as const } : {}),
434
+ wrapChain: ctx.wrapChain,
435
+ elevated: elevated || ctx.elevated,
436
+ envDelta: ctx.envDelta,
437
+ nonLiteral: ctx.nonLiteral,
438
+ redirects: [
439
+ ...frame.redirects.flatMap(emitRedirect),
440
+ ...ctx.accessReads.map((target) => ({ operator: "<", target })),
441
+ ],
442
+ background: frame.background,
443
+ stdinIsPipe: frame.stdinIsPipe,
444
+ });
445
+ }
446
+
447
+ function emitRedirect(redirect: Redirect): RedirectEmit[] {
448
+ if (redirect.target === undefined) return [];
449
+ return [{ operator: redirect.operator, target: redirect.target.value }];
450
+ }
451
+
452
+ const READING_CAP = 16; // open ruling 5 (policy-algebra.md); overflow cannot prove deny/allow
453
+
454
+ /**
455
+ * Tokenize argv into positionals + flag bag.
456
+ * `closed`: only those names are flags; other dash tokens are positionals.
457
+ * Used when a `"--"` field reopens the suffix — undeclared `-file` stays a path.
458
+ */
459
+ export function partition(
460
+ words: readonly string[],
461
+ valueFlags: ReadonlySet<string> | undefined,
462
+ closed?: ReadonlySet<string>,
463
+ ): { readings: Reading[]; overflow: boolean } {
464
+ const readings: Reading[] = [];
465
+ let overflow = false;
466
+ const rec = (
467
+ i: number,
468
+ cut: boolean,
469
+ tokens: readonly string[],
470
+ flags: Readonly<Record<string, readonly string[]>>,
471
+ ): void => {
472
+ if (overflow) return;
473
+ if (i >= words.length) {
474
+ if (readings.length >= READING_CAP) {
475
+ overflow = true;
476
+ return;
477
+ }
478
+ readings.push({ tokens, flags });
479
+ return;
480
+ }
481
+ const word = words[i]!;
482
+ if (cut || word === "--" || !word.startsWith("-") || word === "-" || !isDeclaredFlag(word, closed)) {
483
+ rec(i + 1, cut || word === "--", [...tokens, word], flags);
484
+ return;
485
+ }
486
+ if (word.startsWith("--")) {
487
+ const eq = word.indexOf("=");
488
+ if (eq !== -1) {
489
+ rec(i + 1, cut, tokens, withFlag(flags, word.slice(0, eq), word.slice(eq + 1)));
490
+ return;
491
+ }
492
+ takeFlag(words, i, cut, tokens, flags, word, true, valueFlags, rec);
493
+ return;
494
+ }
495
+ const eq = word.indexOf("=");
496
+ if (eq >= 2) {
497
+ rec(i + 1, cut, tokens, withFlag(flags, word.slice(0, eq), word.slice(eq + 1)));
498
+ return;
499
+ }
500
+ if (/^-[a-z]+$/i.test(word) && word.length > 2) {
501
+ let nextFlags = flags;
502
+ for (const ch of word.slice(1)) nextFlags = withFlag(nextFlags, `-${ch}`, "");
503
+ rec(i + 1, cut, tokens, nextFlags);
504
+ return;
505
+ }
506
+ takeFlag(words, i, cut, tokens, flags, word, word.length === 2, valueFlags, rec);
507
+ };
508
+ rec(0, false, [], {});
509
+ return { readings, overflow };
510
+ }
511
+
512
+ type Rec = (
513
+ i: number,
514
+ cut: boolean,
515
+ tokens: readonly string[],
516
+ flags: Readonly<Record<string, readonly string[]>>,
517
+ ) => void;
518
+
519
+ function takeFlag(
520
+ words: readonly string[],
521
+ i: number,
522
+ cut: boolean,
523
+ tokens: readonly string[],
524
+ flags: Readonly<Record<string, readonly string[]>>,
525
+ name: string,
526
+ mayAttach: boolean,
527
+ valueFlags: ReadonlySet<string> | undefined,
528
+ rec: Rec,
529
+ ): void {
530
+ const next = words[i + 1];
531
+ const canAttach = mayAttach && i + 1 < words.length && next !== "--" && !next.startsWith("-");
532
+ if (canAttach && valueFlags?.has(name) === true) {
533
+ rec(i + 2, cut, tokens, withFlag(flags, name, next!));
534
+ return;
535
+ }
536
+ if (canAttach) {
537
+ rec(i + 2, cut, tokens, withFlag(flags, name, next!));
538
+ rec(i + 1, cut, tokens, withFlag(flags, name, ""));
539
+ return;
540
+ }
541
+ rec(i + 1, cut, tokens, withFlag(flags, name, ""));
542
+ }
543
+
544
+ function isDeclaredFlag(word: string, closed: ReadonlySet<string> | undefined): boolean {
545
+ if (closed === undefined) return true;
546
+ if (word.startsWith("--")) {
547
+ const eq = word.indexOf("=");
548
+ return closed.has(eq === -1 ? word : word.slice(0, eq));
549
+ }
550
+ const eq = word.indexOf("=");
551
+ if (eq >= 2) return closed.has(word.slice(0, eq));
552
+ if (/^-[a-z]+$/i.test(word) && word.length > 2) {
553
+ for (const ch of word.slice(1)) {
554
+ if (!closed.has(`-${ch}`)) return false;
555
+ }
556
+ return true;
557
+ }
558
+ return closed.has(word);
559
+ }
560
+
561
+ function withFlag(
562
+ flags: Readonly<Record<string, readonly string[]>>,
563
+ name: string,
564
+ val: string,
565
+ ): Readonly<Record<string, readonly string[]>> {
566
+ return { ...flags, [name]: [...(flags[name] ?? []), val] };
567
+ }
568
+
569
+ function scanPassthroughBoundary(
570
+ args: readonly string[],
571
+ flagArgs: readonly string[] | undefined,
572
+ skipVarAssignments: boolean,
573
+ ): number {
574
+ let i = 0;
575
+ while (i < args.length) {
576
+ const arg = args[i]!;
577
+ if (skipVarAssignments && isVarAssignment(arg)) {
578
+ i++;
579
+ continue;
580
+ }
581
+ if (arg === "--") return i + 1;
582
+ if (!arg.startsWith("-")) break;
583
+ i += flagSpan(arg, i, args, flagArgs);
584
+ }
585
+ return i;
586
+ }
587
+
588
+ function flagSpan(arg: string, i: number, args: readonly string[], flagArgs: readonly string[] | undefined): number {
589
+ if (arg.includes("=")) return 1;
590
+ if (arg.length > 2 && !arg.startsWith("--") && takesValue(arg.slice(0, 2), flagArgs)) return 1;
591
+ if (takesValue(arg, flagArgs) && i + 1 < args.length && !args[i + 1]!.startsWith("-")) {
592
+ return 2;
593
+ }
594
+ return 1;
595
+ }
596
+
597
+ /** Wrapper-owned file reads that peeling must not drop. `xargs -a FILE` reads FILE. */
598
+ function wrapperFileReads(argv0: string, suffix: readonly string[]): string[] {
599
+ if (argv0 !== "xargs") return [];
600
+ const reads: string[] = [];
601
+ for (let i = 0; i < suffix.length; i++) {
602
+ const arg = suffix[i]!;
603
+ if (arg === "--") break;
604
+ if (arg === "-a" || arg === "--arg-file") {
605
+ const next = suffix[i + 1];
606
+ if (next !== undefined && next !== "--" && !next.startsWith("-")) {
607
+ reads.push(next);
608
+ i += 1;
609
+ }
610
+ continue;
611
+ }
612
+ if (arg.startsWith("--arg-file=") && arg.length > 11) reads.push(arg.slice(11));
613
+ if (arg.startsWith("-a=") && arg.length > 3) reads.push(arg.slice(3));
614
+ }
615
+ return reads;
616
+ }
617
+
618
+ function takesValue(flag: string, flagArgs: readonly string[] | undefined): boolean {
619
+ if (flagArgs === undefined) return false;
620
+ const stripped = flag.replace(/^-+/, "");
621
+ return flagArgs.some((name) => name.replace(/^-+/, "") === stripped);
622
+ }
623
+
624
+ function scriptFlagBody(suffix: readonly string[], flag: string): string | undefined {
625
+ for (let i = 0; i < suffix.length; i++) {
626
+ const arg = suffix[i]!;
627
+ if (arg === flag) return suffix[i + 1];
628
+ if (arg.startsWith(`${flag}=`) && arg.length > flag.length + 1) return arg.slice(flag.length + 1);
629
+ }
630
+ return undefined;
631
+ }
632
+
633
+ function hasScriptFlag(suffix: readonly string[]): boolean {
634
+ return suffix.some((arg) => arg === "-c" || arg === "-e" || arg.startsWith("-c=") || arg.startsWith("-e="));
635
+ }
636
+
637
+ function isVarAssignment(arg: string): boolean {
638
+ return /^[a-z_]\w*=/i.test(arg);
639
+ }
640
+
641
+ function takeAssignment(prefix: AssignmentPrefix, envDelta: Record<string, string>): void {
642
+ if (prefix.name === undefined) return;
643
+ envDelta[prefix.name] = prefix.value?.value ?? "";
644
+ }
645
+
646
+ function takeEnvText(text: string, envDelta: Record<string, string>): void {
647
+ const eq = text.indexOf("=");
648
+ if (eq <= 0) return;
649
+ envDelta[text.slice(0, eq)] = text.slice(eq + 1);
650
+ }
651
+
652
+ function isLiteralWord(word: Word): boolean {
653
+ if (word.parts === undefined) {
654
+ return !PARTLESS_FORBIDDEN.test(word.text) && !word.text.startsWith("~");
655
+ }
656
+ return word.parts.every(partIsLiteral);
657
+ }
658
+
659
+ /**
660
+ * Stricter than `isLiteralWord`: a word bash would pass through unchanged. Quoted
661
+ * text is literal (`'a*'`, `"a b"`); an unquoted `*` `?` `[` glob, a `~` home, a
662
+ * variable, a substitution, a brace/extglob form is not.
663
+ */
664
+ function isInertWord(word: Word): boolean {
665
+ const unescaped = (text: string): string => text.replaceAll(/\\./g, "");
666
+ if (word.parts === undefined || word.parts.length === 0) {
667
+ const text = unescaped(word.text);
668
+ return !/[*?[$`]/.test(text) && !text.startsWith("~");
669
+ }
670
+ return word.parts.every((part, i) => {
671
+ if (part.type === "Literal") {
672
+ const text = unescaped(part.value);
673
+ return !/[*?[$`]/.test(text) && !(i === 0 && text.startsWith("~"));
674
+ }
675
+ return partIsLiteral(part);
676
+ });
677
+ }
678
+
679
+ function partIsLiteral(part: WordPart): boolean {
680
+ switch (part.type) {
681
+ case "Literal":
682
+ case "SingleQuoted":
683
+ return true;
684
+ case "AnsiCQuoted":
685
+ return !part.value.includes("\0");
686
+ case "DoubleQuoted":
687
+ return part.parts.every((child) => child.type === "Literal");
688
+ default:
689
+ return false;
690
+ }
691
+ }
692
+
693
+ const PEEL_NAMES = new Set([
694
+ "sudo",
695
+ "doas",
696
+ "source",
697
+ ".",
698
+ "find",
699
+ "python",
700
+ "python3",
701
+ "node",
702
+ "eval",
703
+ "bash",
704
+ "sh",
705
+ "zsh",
706
+ "dash",
707
+ "ksh",
708
+ "env",
709
+ "xargs",
710
+ "timeout",
711
+ "nice",
712
+ "nohup",
713
+ "stdbuf",
714
+ "watch",
715
+ "time",
716
+ "command",
717
+ "exec",
718
+ "builtin",
719
+ ]);
720
+
721
+ /** Named peeler is itself. Path-form peels only when `which` agrees. */
722
+ function peelName(raw: string, which: Which | undefined): string | undefined {
723
+ return resolveKnown(raw, PEEL_NAMES, which);
724
+ }
725
+
726
+ /** `env -C` / `-S` / `-u` change context this matcher does not apply. Do not peel. */
727
+ function envHasDashFlags(suffix: readonly string[]): boolean {
728
+ for (const arg of suffix) {
729
+ if (arg === "--") return false;
730
+ if (isVarAssignment(arg)) continue;
731
+ if (arg.startsWith("-")) return true;
732
+ }
733
+ return false;
734
+ }
735
+
736
+ function withWrap(ctx: Ctx, name: string): Ctx {
737
+ return { ...ctx, wrapChain: [...ctx.wrapChain, name] };
738
+ }
739
+
740
+ function slice(ctx: Ctx, node: { pos: number; end: number }): string {
741
+ return ctx.source.slice(node.pos, node.end);
742
+ }
743
+
744
+ function walkWord(word: Word | undefined, ctx: Ctx, frame: Frame): void {
745
+ if (word === undefined) return;
746
+ walkParts(word.parts, ctx, frame);
747
+ }
748
+
749
+ function walkAssignment(prefix: AssignmentPrefix, ctx: Ctx, frame: Frame): void {
750
+ walkWord(prefix.value, ctx, frame);
751
+ if (prefix.array !== undefined) {
752
+ for (const word of prefix.array) walkWord(word, ctx, frame);
753
+ }
754
+ walkParts(prefix.indexParts, ctx, frame);
755
+ }
756
+
757
+ function walkRedirect(redirect: Redirect, ctx: Ctx, frame: Frame): void {
758
+ walkWord(redirect.target, ctx, frame);
759
+ walkWord(redirect.body, ctx, frame);
760
+ }
761
+
762
+ function walkParts(parts: readonly WordPart[] | undefined, ctx: Ctx, frame: Frame): void {
763
+ if (parts === undefined) return;
764
+ for (const part of parts) {
765
+ switch (part.type) {
766
+ case "DoubleQuoted":
767
+ case "LocaleString":
768
+ walkParts(part.parts, ctx, frame);
769
+ break;
770
+ case "CommandExpansion":
771
+ case "ProcessSubstitution":
772
+ if (part.script !== undefined) {
773
+ walkScript(part.script, ctx, { background: false, stdinIsPipe: false, redirects: [] });
774
+ }
775
+ break;
776
+ case "ParameterExpansion":
777
+ walkWord(part.operand, ctx, frame);
778
+ walkParts(part.indexParts, ctx, frame);
779
+ if (part.slice !== undefined) {
780
+ walkWord(part.slice.offset, ctx, frame);
781
+ walkWord(part.slice.length, ctx, frame);
782
+ }
783
+ if (part.replace !== undefined) {
784
+ walkWord(part.replace.pattern, ctx, frame);
785
+ walkWord(part.replace.replacement, ctx, frame);
786
+ }
787
+ break;
788
+ case "ArithmeticExpansion":
789
+ walkArith(part.expression, ctx, frame);
790
+ break;
791
+ case "BraceExpansion":
792
+ case "ExtendedGlob":
793
+ walkParts(part.parts, ctx, frame);
794
+ break;
795
+ default:
796
+ break;
797
+ }
798
+ }
799
+ }
800
+
801
+ function walkArith(expr: ArithmeticExpression | undefined, ctx: Ctx, frame: Frame): void {
802
+ if (expr === undefined) return;
803
+ switch (expr.type) {
804
+ case "ArithmeticBinary":
805
+ walkArith(expr.left, ctx, frame);
806
+ walkArith(expr.right, ctx, frame);
807
+ return;
808
+ case "ArithmeticUnary":
809
+ walkArith(expr.operand, ctx, frame);
810
+ return;
811
+ case "ArithmeticTernary":
812
+ walkArith(expr.test, ctx, frame);
813
+ walkArith(expr.consequent, ctx, frame);
814
+ walkArith(expr.alternate, ctx, frame);
815
+ return;
816
+ case "ArithmeticGroup":
817
+ walkArith(expr.expression, ctx, frame);
818
+ return;
819
+ case "ArithmeticWord":
820
+ walkParts(expr.parts, ctx, frame);
821
+ return;
822
+ case "ArithmeticCommandExpansion":
823
+ if (expr.script !== undefined) {
824
+ walkScript(expr.script, ctx, { background: false, stdinIsPipe: false, redirects: [] });
825
+ }
826
+ return;
827
+ }
828
+ }
829
+
830
+ function walkTestWords(expr: TestExpression, ctx: Ctx, frame: Frame): void {
831
+ switch (expr.type) {
832
+ case "TestUnary":
833
+ walkWord(expr.operand, ctx, frame);
834
+ return;
835
+ case "TestBinary":
836
+ walkWord(expr.left, ctx, frame);
837
+ walkWord(expr.right, ctx, frame);
838
+ return;
839
+ case "TestLogical":
840
+ walkTestWords(expr.left, ctx, frame);
841
+ walkTestWords(expr.right, ctx, frame);
842
+ return;
843
+ case "TestNot":
844
+ walkTestWords(expr.operand, ctx, frame);
845
+ return;
846
+ case "TestGroup":
847
+ walkTestWords(expr.expression, ctx, frame);
848
+ return;
849
+ }
850
+ }