@sema-agent/core 7.47.0 → 7.48.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 (44) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/core/allow-entry.js +1 -1
  3. package/dist/core/checkpoint-store.d.ts +1 -1
  4. package/dist/core/mcp-server-spec.d.ts +1 -1
  5. package/dist/core/runner/park-commit.d.ts +1 -1
  6. package/dist/core/safety-axis-vocab.d.ts +1 -1
  7. package/dist/core/safety-merge-corpus.d.ts +1 -1
  8. package/dist/core/session-policy-store.d.ts +6 -3
  9. package/dist/core/session-policy-store.js +28 -10
  10. package/dist/core/shell-wrapper-table.d.ts +2 -2
  11. package/dist/core/shell-wrapper-table.js +1 -1
  12. package/dist/core/tool-name-aliases.d.ts +9 -8
  13. package/dist/core/tool-name-aliases.js +2 -0
  14. package/dist/core/tool-policy.js +4 -5
  15. package/dist/orchestration/workflow.js +2 -2
  16. package/dist/prompt-assembly/types.d.ts +1 -1
  17. package/dist/shell/lex.js +30 -4
  18. package/dist/tools/fs/bash-program-position.d.ts +7 -0
  19. package/dist/tools/fs/bash-program-position.js +5 -2
  20. package/dist/tools/fs/index.d.ts +3 -2
  21. package/dist/tools/fs/index.js +2 -2
  22. package/dist/tools/fs/program-read-model.d.ts +60 -5
  23. package/dist/tools/fs/program-read-model.js +100 -36
  24. package/dist/tools/fs/program-read-model.rows.js +13 -5
  25. package/dist/tools/fs/read-deny-home.d.ts +20 -1
  26. package/dist/tools/fs/read-deny-home.js +28 -7
  27. package/dist/tools/fs/read-deny.d.ts +13 -6
  28. package/dist/tools/fs/read-deny.js +12 -3
  29. package/dist/tools/fs/removal-targets.js +12 -3
  30. package/dist/tools/fs/shell-read-argv.d.ts +4 -7
  31. package/dist/tools/fs/shell-read-argv.js +5 -7
  32. package/dist/tools/fs/shell-read-bindings.d.ts +34 -9
  33. package/dist/tools/fs/shell-read-bindings.js +166 -60
  34. package/dist/tools/fs/shell-read-establish.js +3 -4
  35. package/dist/tools/fs/shell-read-plan.d.ts +1 -14
  36. package/dist/tools/fs/shell-read-plan.js +102 -101
  37. package/dist/tools/fs/shell-read-start.d.ts +57 -0
  38. package/dist/tools/fs/shell-read-start.js +92 -0
  39. package/dist/tools/fs/shell-read-word.d.ts +35 -6
  40. package/dist/tools/fs/shell-read-word.js +85 -9
  41. package/dist/tools/fs/shell-reentry-text.d.ts +23 -21
  42. package/dist/tools/fs/shell-reentry-text.js +31 -26
  43. package/package.json +1 -1
  44. package/wire-changes/core@7.48.0.json +252 -0
@@ -272,6 +272,23 @@ export interface LauncherSpec {
272
272
  readonly requiredOperandSpelling?: RegExp;
273
273
  /** A lone `-` in its option section is its own switch (`env -`, the obsolete spelling of `env -i`). */
274
274
  readonly bareDash?: true;
275
+ /** It runs its shell as ANOTHER user ({@link TargetUser}): the user an option of `userWith` names (`sudo -u USER`), or —
276
+ * a {@link shellText} `user` launcher — its user operand; root where none is named. The shell's `HOME` is that user's
277
+ * home (`target`), or the caller's or that user's as the launcher's policy decides (`policy`: `sudo -s`); a switch of
278
+ * `loginWith` (`sudo -i`, `su -l`, su's lone `-` operand) starts it as a LOGIN shell — its `HOME` that user's, and it
279
+ * stands in that home. A `names-program` switch (`su -m`) keeps the caller's environment, `HOME` with it. */
280
+ readonly asUser?: {
281
+ readonly userWith?: readonly string[];
282
+ readonly home: "target" | "policy";
283
+ readonly loginWith: readonly string[];
284
+ };
285
+ }
286
+ /** The user a launcher runs its shell as ({@link LauncherSpec.asUser}): the name, where a word spells it literally; the
287
+ * shell's `HOME` (`caller` — kept; `target` — that user's; `either` — one of the two); whether it is a login shell. */
288
+ export interface TargetUser {
289
+ readonly name?: string;
290
+ readonly home: "caller" | "target" | "either";
291
+ readonly login: boolean;
275
292
  }
276
293
  /** How a program receives the PROGRAM it runs (an interpreter): an option value, an operand (`operands.positional`),
277
294
  * or standard input when it names none. */
@@ -563,6 +580,20 @@ export declare function attachedStdinFile(row: ProgramRow | undefined, tok: stri
563
580
  /** The row read under each implementation's grammar alone (one view per grammar), or the row itself when it declares
564
581
  * at most one. A reader that needs to know what ONE implementation does with an argv reads these views. */
565
582
  export declare function implementationsOf(row: ProgramRow): readonly ProgramRow[];
583
+ /** What a scan over an argv's option words found: the specs selected, the values they took (argv index; the value
584
+ * inside the option word when it is attached), and the operands (argv indices). */
585
+ interface OptionScan {
586
+ readonly specs: readonly OptionSpec[];
587
+ /** Each selected option's spelling as its row declares it (`-x`, `--exec`), in step with `specs`. */
588
+ readonly names: readonly string[];
589
+ readonly values: readonly {
590
+ readonly at: number;
591
+ readonly value?: string;
592
+ readonly spec: OptionSpec;
593
+ readonly name: string;
594
+ }[];
595
+ readonly operands: readonly number[];
596
+ }
566
597
  /**
567
598
  * Every option spec an argv selects. A short cluster is read as getopt reads it, scanning left to right: a declared
568
599
  * letter is selected; a value-taking letter owns the rest of the token (and the next word, when it ends the token); a
@@ -574,6 +605,14 @@ export declare function implementationsOf(row: ProgramRow): readonly ProgramRow[
574
605
  * option, and a word past `--`, included. The reading a check that only ever refuses takes: it errs to more options.
575
606
  */
576
607
  export declare function selectedOptions(row: ProgramRow | undefined, args: readonly string[], under?: "one-implementation"): OptionSpec[];
608
+ /** The options ONE implementation's reading of an argv selects ({@link selectedOptions}), named by the spelling its row
609
+ * declares them under (`-x`, `--exec`), and the values they take. An option is told apart by its spelling, never by its
610
+ * spec: spellings share specs (every switch of a row is one). */
611
+ export declare function selectedSpellings(row: ProgramRow, args: readonly string[]): Pick<OptionScan, "names" | "values">;
612
+ /** Does ONE implementation of a row (`view`, {@link implementationsOf}) reject these option words — a spelling the row
613
+ * declares (another implementation's) that this one does not? getopt calls it an invalid option: the program runs
614
+ * nothing. */
615
+ export declare function implementationRejects(row: ProgramRow, view: ProgramRow, words: readonly string[]): boolean;
577
616
  /** A path role whose effect writes. */
578
617
  export type WritingRole = Extract<ArgRole, {
579
618
  kind: "path";
@@ -656,22 +695,38 @@ export declare function moduleTailOf(row: ProgramRow | undefined, args: readonly
656
695
  * value exists only attached (spelled bare, it takes nothing), 1 for one that takes the next word; `undefined` for any
657
696
  * other spelling and for an option the walk does not step over. */
658
697
  export declare function launcherOptionArity(row: ProgramRow, tok: string): 0 | 1 | undefined;
659
- /** The PATH role of the value a launcher option spelled EXACTLY takes (`xargs -a FILE`, `strace -o FILE`), or `undefined`
660
- * when its value names no path. Such a value is a word the launcher opens or writes, not an operand of its own. */
698
+ /** The PATH role of the value a launcher option word takes (`xargs -a FILE`, `strace -fo FILE`, `strace -oFILE`), or
699
+ * `undefined` when its value names no path ({@link readLauncherOption}). Such a value is a word the launcher opens or
700
+ * writes, not an operand of its own. */
661
701
  export declare function launcherOptionPathRole(row: ProgramRow, tok: string): Extract<ArgRole, {
662
702
  kind: "path";
663
703
  }> | undefined;
664
- /** How many tokens one launcher option occupies, or `undefined` where the row does not explain it: an arity-1 option
665
- * separated (2), a value fused to its long name or short letter (1); a switch, an attached-only option, or a cluster of
666
- * switches (1); a lone `-` where the launcher reads it as its own switch (1). */
704
+ /** How many words one launcher option occupies ({@link readLauncherOption}), or `undefined` where the row does not
705
+ * explain it. */
667
706
  export declare function launcherOptionStep(row: ProgramRow, tok: string): 1 | 2 | undefined;
668
707
  /** Does a launcher that runs a shell ({@link LauncherSpec.shellText}) run one over these words — the words after it —
669
708
  * rather than the program they name? Not where an implementation selects one of its {@link LauncherSpec.programWith}
670
709
  * options (as that implementation reads them: past `--` a word is an operand). */
671
710
  export declare function launcherRunsShell(row: ProgramRow, args: readonly string[]): boolean;
711
+ /** The user a launcher runs its shell as ({@link LauncherSpec.asUser}), read off its own words (`words`, as the program
712
+ * receives them) by its row's walk: the value of its last `userWith` option, else its user operand (`operand`; a lone `-`
713
+ * in front of it starts a login shell, `dash`), else root — a name only where it is spelled as one (`root`, `#0`, a login
714
+ * name); `undefined` for a launcher that runs no other user. */
715
+ export declare function targetUserOf(row: ProgramRow, words: readonly string[], operand?: {
716
+ readonly name?: string;
717
+ readonly dash: boolean;
718
+ }): TargetUser | undefined;
672
719
  /** The spellings of a launcher's command-line options (`env -S` / `--split-string`): the value is a command line the
673
720
  * launcher splits and runs. */
674
721
  export declare function commandLineOptionSpellings(row: ProgramRow | undefined): readonly string[];
722
+ /** The command line ONE launcher option word carries, read as getopt reads the word ({@link readLauncherOption}): a
723
+ * command-line spelling ({@link commandLineOptionSpellings}) exactly — `"next"`, the next word is the line — or with its
724
+ * value after `=` (long) or after the letter (short, `attached`); in a short cluster every letter before the
725
+ * command-line letter is a switch the walk steps (`env -vS 'S'`, `env -iS'S'`). `undefined` where the word carries
726
+ * none. */
727
+ export declare function commandLineOf(row: ProgramRow, tok: string): {
728
+ readonly attached: string;
729
+ } | "next" | undefined;
675
730
  /** One implementation's reading of where the program text is: the text (every text option's value joined by newlines,
676
731
  * or the first operand), `from-file` (an option names a FILE holding it), `unparsed` (an option the grammar does not
677
732
  * declare, or a text option with nothing left to take), or `none` (no program text at all). */
@@ -682,6 +682,16 @@ export function implementationsOf(row) {
682
682
  export function selectedOptions(row, args, under) {
683
683
  return row === undefined ? [] : [...scanOptions(row, args, under === "one-implementation").specs];
684
684
  }
685
+ export function selectedSpellings(row, args) {
686
+ const scan = scanOptions(row, args, true);
687
+ return { names: scan.names, values: scan.values };
688
+ }
689
+ export function implementationRejects(row, view, words) {
690
+ if (view === row)
691
+ return false;
692
+ const declares = (n) => (n.startsWith("--") ? mergedTable(view, "long").has(n.slice(2)) : mergedTable(view, n.length === 2 ? "short" : "dashWords").has(n.slice(1)));
693
+ return selectedSpellings(row, words).names.some((n) => !declares(n));
694
+ }
685
695
  function scanOptions(row, args, one) {
686
696
  const stopsAtOperand = one && row.grammars[0]?.afterFirstOperand === "operands";
687
697
  const shorts = mergedTable(row, "short");
@@ -689,8 +699,10 @@ function scanOptions(row, args, one) {
689
699
  const dashWords = mergedTable(row, "dashWords");
690
700
  const obsolete = row.grammars.flatMap((g) => (g.obsoleteFirst === undefined ? [] : [g.obsoleteFirst]));
691
701
  const specs = [];
702
+ const names = [];
692
703
  const values = [];
693
704
  const operands = [];
705
+ const select = (spec, name) => { specs.push(spec); names.push(name); };
694
706
  let endOfOptions = false;
695
707
  for (let k = 0; k < args.length; k++) {
696
708
  const t = args[k];
@@ -704,7 +716,7 @@ function scanOptions(row, args, one) {
704
716
  for (const ch of t.replace(/^.\d+/, "")) {
705
717
  const spec = o.letters[ch];
706
718
  if (spec !== undefined)
707
- specs.push(spec);
719
+ select(spec, `-${ch}`);
708
720
  }
709
721
  continue;
710
722
  }
@@ -719,13 +731,13 @@ function scanOptions(row, args, one) {
719
731
  endOfOptions = true;
720
732
  continue;
721
733
  }
722
- const take = (spec, attached) => {
734
+ const take = (spec, name, attached) => {
723
735
  if (attached !== undefined) {
724
- values.push({ at: k, value: attached, spec });
736
+ values.push({ at: k, value: attached, spec, name });
725
737
  return;
726
738
  }
727
739
  if (one && k + 1 < args.length) {
728
- values.push({ at: k + 1, spec });
740
+ values.push({ at: k + 1, spec, name });
729
741
  k++;
730
742
  }
731
743
  };
@@ -735,22 +747,23 @@ function scanOptions(row, args, one) {
735
747
  continue;
736
748
  const eq = t.indexOf("=");
737
749
  const exact = longs.get(name);
738
- const hits = exact !== undefined ? [exact] : [...longs].filter(([full]) => isLongOptionAbbrevOf(name, full)).map(([, m]) => m);
739
- for (const m of hits)
740
- specs.push(m.spec);
741
- if (hits.length === 1 && (hits[0].arity === 1 || (eq >= 0 && hits[0].arity === "attached")))
742
- take(hits[0].spec, eq >= 0 ? t.slice(eq + 1) : undefined);
743
- else if (one && eq < 0 && hits.length > 1 && hits.every((m) => m.arity === 1))
750
+ const hits = exact !== undefined ? [[name, exact]] : [...longs].filter(([full]) => isLongOptionAbbrevOf(name, full));
751
+ for (const [full, m] of hits)
752
+ select(m.spec, `--${full}`);
753
+ const [full, only] = hits[0] ?? [];
754
+ if (hits.length === 1 && (only.arity === 1 || (eq >= 0 && only.arity === "attached")))
755
+ take(only.spec, `--${full}`, eq >= 0 ? t.slice(eq + 1) : undefined);
756
+ else if (one && eq < 0 && hits.length > 1 && hits.every(([, m]) => m.arity === 1))
744
757
  k++;
745
758
  continue;
746
759
  }
747
760
  const word = /^-([A-Za-z_][\w-]+)$/.exec(t)?.[1];
748
761
  const dash = word === undefined ? undefined : dashWords.get(word);
749
762
  if (dash !== undefined) {
750
- specs.push(dash.spec);
763
+ select(dash.spec, t);
751
764
  if (one) {
752
765
  if (dash.arity === 1)
753
- take(dash.spec, undefined);
766
+ take(dash.spec, t, undefined);
754
767
  continue;
755
768
  }
756
769
  }
@@ -761,18 +774,18 @@ function scanOptions(row, args, one) {
761
774
  continue;
762
775
  break;
763
776
  }
764
- specs.push(m.spec);
777
+ select(m.spec, `-${t[i]}`);
765
778
  if (m.arity === 0)
766
779
  continue;
767
780
  const rest = t.slice(i + 1);
768
781
  if (rest !== "")
769
- values.push({ at: k, value: rest, spec: m.spec });
782
+ values.push({ at: k, value: rest, spec: m.spec, name: `-${t[i]}` });
770
783
  else if (m.arity === 1 || m.arity === "suffix")
771
- take(m.spec, undefined);
784
+ take(m.spec, `-${t[i]}`, undefined);
772
785
  break;
773
786
  }
774
787
  }
775
- return { specs, values, operands };
788
+ return { specs, names, values, operands };
776
789
  }
777
790
  export function writesPath(role) {
778
791
  return role?.kind === "path" && WRITING_EFFECTS.has(role.effect);
@@ -1023,7 +1036,14 @@ function steppableLauncherOption(row, tok) {
1023
1036
  const m = tok.startsWith("--") ? (tok.includes("=") ? undefined : mergedTable(row, "long").get(tok.slice(2))) : tok.length === 2 && tok.startsWith("-") ? mergedTable(row, "short").get(tok[1]) : undefined;
1024
1037
  if (m === undefined || m.spec.value?.kind === "shell-text" || (m.spec.value?.kind === "path" && m.spec.value.base !== undefined))
1025
1038
  return undefined;
1026
- return m.arity === "disputed" ? undefined : m;
1039
+ return m.arity !== "disputed" || takesNoWordBare(row, tok) ? m : undefined;
1040
+ }
1041
+ function takesNoWordBare(row, spelling) {
1042
+ const key = spelling.startsWith("--") ? spelling.slice(2) : spelling.slice(1);
1043
+ return row.grammars.every((g) => {
1044
+ const s = (spelling.startsWith("--") ? g.long : g.short)[key];
1045
+ return s === undefined || s.arity === 0 || s.arity === "attached";
1046
+ });
1027
1047
  }
1028
1048
  export function launcherOptionArity(row, tok) {
1029
1049
  if (row.grammars.length === 0)
@@ -1033,34 +1053,59 @@ export function launcherOptionArity(row, tok) {
1033
1053
  const m = steppableLauncherOption(row, tok);
1034
1054
  return m === undefined ? undefined : m.arity === 1 ? 1 : 0;
1035
1055
  }
1056
+ function readLauncherOption(row, tok) {
1057
+ if (tok === "-")
1058
+ return row.launcher?.bareDash === true ? { step: 1 } : undefined;
1059
+ if (tok === "--")
1060
+ return launcherOptionArity(row, tok) === 0 ? { step: 1 } : undefined;
1061
+ if (tok.startsWith("--")) {
1062
+ const eq = tok.indexOf("=");
1063
+ const m = steppableLauncherOption(row, eq > 2 ? tok.slice(0, eq) : tok);
1064
+ if (m === undefined)
1065
+ return undefined;
1066
+ if (eq > 2)
1067
+ return m.arity === 0 || m.arity === "disputed" ? undefined : { step: 1, valued: m };
1068
+ return m.arity === 1 ? { step: 2, valued: m } : { step: 1 };
1069
+ }
1070
+ if (!/^-[^-]/.test(tok))
1071
+ return undefined;
1072
+ for (let i = 1; i < tok.length; i++) {
1073
+ const m = steppableLauncherOption(row, `-${tok[i]}`);
1074
+ if (m === undefined)
1075
+ return undefined;
1076
+ if (m.arity === 0)
1077
+ continue;
1078
+ if (i + 1 < tok.length)
1079
+ return m.arity === "disputed" ? undefined : { step: 1, valued: m };
1080
+ return m.arity === 1 ? { step: 2, valued: m } : { step: 1 };
1081
+ }
1082
+ return { step: 1 };
1083
+ }
1036
1084
  export function launcherOptionPathRole(row, tok) {
1037
- const role = steppableLauncherOption(row, tok)?.spec.value;
1085
+ const role = readLauncherOption(row, tok)?.valued?.spec.value;
1038
1086
  return role?.kind === "path" ? role : undefined;
1039
1087
  }
1040
1088
  export function launcherOptionStep(row, tok) {
1041
- if (tok === "-" && row.launcher?.bareDash === true)
1042
- return 1;
1043
- const exact = launcherOptionArity(row, tok);
1044
- if (exact !== undefined)
1045
- return exact === 1 ? 2 : 1;
1046
- const eq = tok.startsWith("--") ? tok.indexOf("=") : -1;
1047
- if (eq > 2 && steppableLauncherOption(row, tok.slice(0, eq))?.arity !== undefined && steppableLauncherOption(row, tok.slice(0, eq)).arity !== 0)
1048
- return 1;
1049
- if (/^-[A-Za-z]./.test(tok) && !tok.startsWith("--")) {
1050
- const first = steppableLauncherOption(row, tok.slice(0, 2));
1051
- if (first !== undefined && first.arity !== 0)
1052
- return 1;
1053
- }
1054
- if (/^-[A-Za-z]+$/.test(tok) && [...tok.slice(1)].every((ch) => launcherOptionArity(row, `-${ch}`) === 0 && steppableLauncherOption(row, `-${ch}`)?.arity === 0))
1055
- return 1;
1056
- return undefined;
1089
+ return readLauncherOption(row, tok)?.step;
1057
1090
  }
1058
1091
  export function launcherRunsShell(row, args) {
1059
1092
  const l = row.launcher;
1060
1093
  if (l?.shellText === undefined)
1061
1094
  return false;
1062
- const specs = new Set((l.programWith ?? []).map((s) => (s.startsWith("--") ? mergedTable(row, "long").get(s.slice(2)) : mergedTable(row, "short").get(s.slice(1)))?.spec));
1063
- return !implementationsOf(row).some((view) => selectedOptions(view, args, "one-implementation").some((o) => specs.has(o)));
1095
+ const programWith = l.programWith ?? [];
1096
+ return !implementationsOf(row).some((view) => selectedSpellings(view, args).names.some((n) => programWith.includes(n)));
1097
+ }
1098
+ export function targetUserOf(row, words, operand) {
1099
+ const as = row.launcher?.asUser;
1100
+ if (as === undefined)
1101
+ return undefined;
1102
+ const own = selectedSpellings(row, words);
1103
+ const user = own.values.filter((v) => as.userWith?.includes(v.name) === true).at(-1);
1104
+ const named = user !== undefined ? user.value ?? words[user.at] : operand === undefined ? "root" : operand.name;
1105
+ const login = operand?.dash === true || own.names.some((n) => as.loginWith.includes(n));
1106
+ const keepsCaller = selectedOptions(row, words).some((o) => o.mode === "names-program");
1107
+ const name = named === "#0" ? "root" : named !== undefined && /^[A-Za-z_][A-Za-z0-9_.-]*$/.test(named) ? named : undefined;
1108
+ return { ...(name !== undefined ? { name } : {}), home: login ? "target" : as.home === "policy" ? "either" : keepsCaller ? "caller" : "target", login };
1064
1109
  }
1065
1110
  export function commandLineOptionSpellings(row) {
1066
1111
  if (row === undefined)
@@ -1070,6 +1115,25 @@ export function commandLineOptionSpellings(row) {
1070
1115
  ...[...mergedTable(row, "long")].filter(([, m]) => m.spec.value?.kind === "shell-text").map(([n]) => `--${n}`),
1071
1116
  ];
1072
1117
  }
1118
+ export function commandLineOf(row, tok) {
1119
+ const lines = new Set(commandLineOptionSpellings(row));
1120
+ if (tok.startsWith("--")) {
1121
+ const eq = tok.indexOf("=");
1122
+ if (!lines.has(eq > 2 ? tok.slice(0, eq) : tok))
1123
+ return undefined;
1124
+ return eq > 2 ? { attached: tok.slice(eq + 1) } : "next";
1125
+ }
1126
+ if (!/^-[^-]/.test(tok))
1127
+ return undefined;
1128
+ for (let i = 1; i < tok.length; i++) {
1129
+ const letter = `-${tok[i]}`;
1130
+ if (lines.has(letter))
1131
+ return i + 1 < tok.length ? { attached: tok.slice(i + 1) } : "next";
1132
+ if (steppableLauncherOption(row, letter)?.arity !== 0)
1133
+ return undefined;
1134
+ }
1135
+ return undefined;
1136
+ }
1073
1137
  export function readProgramTextLanguage(row) {
1074
1138
  const first = row?.operands?.positional?.[0];
1075
1139
  return first?.kind === "program-text" && READ_LANGUAGES.has(first.language) ? first.language : undefined;
@@ -391,7 +391,7 @@ const launchers = [
391
391
  optional: { "-i": LABEL, "-l": COUNT, "--replace": LABEL, "--eof": LABEL, "--max-lines": COUNT },
392
392
  }, {}), { reads: { "-a": val(READ, { delivers: "operands" }), "--arg-file": val(READ, { delivers: "operands" }) } }, { lacks: ["-d", "-i", "-l", "-a", "--arg-file", "--null", "--no-run-if-empty", "--verbose", "--exit", "--interactive", "--open-tty", "--show-limits", "--help", "--version", "--max-args", "--max-procs", "--delimiter", "--max-chars", "--process-slot-var", "--replace", "--eof", "--max-lines"] }), launcher: {}, reentry: { kind: "argv", defaultProgram: "echo" }, witness: BINARY },
393
393
  launcher("busybox"),
394
- launcher("sudo", { assignmentOperands: true, shellCommand: "escaped" }, launcherGrammar({
394
+ launcher("sudo", { assignmentOperands: true, shellCommand: "escaped", asUser: { userWith: ["-u", "--user"], home: "policy", loginWith: ["-i", "--login"] } }, launcherGrammar({
395
395
  switches: ["-A", "-b", "-E", "-H", "-K", "-k", "-l", "-n", "-P", "-S", "-V", "-v", "--askpass", "--background", "--preserve-env", "--set-home", "--remove-timestamp", "--reset-timestamp", "--list", "--non-interactive", "--preserve-groups", "--stdin", "--version", "--validate", "--bell", "--help"],
396
396
  shells: ["-i", "-s", "--login", "--shell"],
397
397
  values: {
@@ -399,10 +399,10 @@ const launchers = [
399
399
  "--user": LABEL, "--group": LABEL, "--other-user": LABEL, "--close-from": COUNT, "--chdir": WORKDIR, "--host": LABEL, "--prompt": LABEL, "--role": LABEL, "--chroot": ROOTDIR, "--type": LABEL, "--command-timeout": COUNT, "--auth-type": LABEL,
400
400
  },
401
401
  }, { stepsDashDash: true }), { witness: PRIVILEGED }),
402
- launcher("doas", {}, launcherGrammar({ switches: ["-L", "-n"], shells: ["-s"], values: { "-a": LABEL, "-u": LABEL, "-C": READ } }, { stepsDashDash: true }), { witness: PRIVILEGED }),
402
+ launcher("doas", { asUser: { userWith: ["-u"], home: "target", loginWith: [] } }, launcherGrammar({ switches: ["-L", "-n"], shells: ["-s"], values: { "-a": LABEL, "-u": LABEL, "-C": READ } }, { stepsDashDash: true }), { witness: PRIVILEGED }),
403
403
  launcher("pkexec", { whenNoProgram: "shell" }, launcherGrammar({ switches: ["--disable-internal-agent", "--keep-cwd", "--version", "--help"], values: { "--user": LABEL } }), { witness: PRIVILEGED }),
404
- { key: "su", grammars: implementations(SU, { afterFirstOperand: "options" }, { lacks: SU_LINUX_ONLY, afterFirstOperand: "operands" }), launcher: { shellText: "user" }, witness: PRIVILEGED },
405
- { key: "runuser", grammars: [launcherGrammar({ ...SU_OPTIONS, values: { ...SU_OPTIONS.values, "-u": LABEL, "--user": LABEL } }, { afterFirstOperand: "options", stepsDashDash: true })], launcher: { shellText: "user", programWith: ["-u", "--user"] }, witness: PRIVILEGED },
404
+ { key: "su", grammars: implementations(SU, { afterFirstOperand: "options" }, { lacks: SU_LINUX_ONLY, afterFirstOperand: "operands" }), launcher: { shellText: "user", asUser: { home: "target", loginWith: ["-l", "--login"] } }, witness: PRIVILEGED },
405
+ { key: "runuser", grammars: [launcherGrammar({ ...SU_OPTIONS, values: { ...SU_OPTIONS.values, "-u": LABEL, "--user": LABEL } }, { afterFirstOperand: "options", stepsDashDash: true })], launcher: { shellText: "user", programWith: ["-u", "--user"], asUser: { home: "target", loginWith: ["-l", "--login"] } }, witness: PRIVILEGED },
406
406
  launcher("chroot", { requiredOperands: 1, whenNoProgram: "shell" }, undefined, { witness: PRIVILEGED }),
407
407
  launcher("setpriv", {}, undefined, { witness: PRIVILEGED }),
408
408
  launcher("nohup", {}, grammar({}, BANNERS)),
@@ -420,7 +420,15 @@ const launchers = [
420
420
  { key: "stdbuf", grammars: implementations(grammar({ i: val(LABEL), o: val(LABEL), e: val(LABEL) }, { input: val(LABEL), output: val(LABEL), error: val(LABEL), ...BANNERS }), {}, { lacks: ["--input", "--output", "--error", "--help", "--version"] }), launcher: {}, witness: BINARY },
421
421
  launcher("timeout", { requiredOperands: 1, requiredOperandSpelling: DURATION_SPELLING }, grammar({ v: SWITCH, k: val(COUNT), s: val(LABEL) }, { foreground: SWITCH, "preserve-status": SWITCH, verbose: SWITCH, "kill-after": val(COUNT), signal: val(LABEL), ...BANNERS })),
422
422
  { key: "time", grammars: implementations(grammar({ p: SWITCH, a: SWITCH, v: SWITCH, q: SWITCH, f: val(LABEL), o: val(WRITE) }, { portability: SWITCH, append: SWITCH, verbose: SWITCH, quiet: SWITCH, help: SWITCH, version: SWITCH, format: val(LABEL), output: val(WRITE) }, { stepsDashDash: true }), {}, { lacks: ["-v", "-q", "-f", "--portability", "--append", "--verbose", "--quiet", "--help", "--version", "--format", "--output"] }), launcher: {}, witness: BINARY },
423
- launcher("watch", { shellText: "joined" }, undefined, { witness: SHELL_TEXT_LAUNCHER }),
423
+ { key: "watch", grammars: [
424
+ launcherGrammar({
425
+ switches: ["-b", "-C", "-c", "-e", "-f", "-g", "-h", "-p", "-r", "-t", "-w", "-v", "-x", "--beep", "--color", "--no-color", "--errexit", "--follow", "--chgexit", "--exec", "--precise", "--no-rerun", "--no-title", "--no-wrap", "--help", "--version"],
426
+ banners: ["-h", "-v"],
427
+ values: { "-n": COUNT, "--interval": COUNT, "-q": COUNT, "--equexit": COUNT, "-s": WRITE, "--shotsdir": WRITE },
428
+ optional: { "-d": LABEL, "--differences": LABEL },
429
+ }, { afterFirstOperand: "operands", stepsDashDash: true }),
430
+ launcherGrammar({ switches: ["-d", "-t"], values: { "-n": COUNT } }, { afterFirstOperand: "operands", stepsDashDash: true }),
431
+ ], launcher: { shellText: "joined", programWith: ["-x", "--exec"] }, witness: SHELL_TEXT_LAUNCHER },
424
432
  launcher("setsid", {}, launcherGrammar({ switches: ["-c", "-f", "-w", "--ctty", "--fork", "--wait", "--help", "--version"] })),
425
433
  launcher("flock", { requiredOperands: 1 }, launcherGrammar({
426
434
  switches: ["-s", "-x", "-u", "-n", "-o", "-F", "--shared", "--exclusive", "--unlock", "--nonblock", "--nb", "--close", "--no-fork", "--verbose", "--help", "--version"],
@@ -38,6 +38,23 @@ export declare function readDenyHomeLinksOf(env: ExecutionEnv, entries: readonly
38
38
  readonly linked: Map<string, string[]>;
39
39
  readonly whole: boolean;
40
40
  }>;
41
+ /**
42
+ * Where the ROOTED rows of a table (`/app/config-lkg.json`, read-deny.ts) lead — the same `linked` answer
43
+ * {@link readDenyHomeLinksOf} gives for home-anchored rows, by the same machine ({@link linkedLocationsOf}): the row's
44
+ * LITERAL HEAD is spelled under the root and resolved the way the read faces mint their keys, and a location that differs
45
+ * from the spelling is where one of the head's segments is a link (`/app` a link to `/data/app`) — the row is placed
46
+ * there too, so a read of the link's target meets it. The laws are `readDenyHomeLinksOf`'s: a head that resolves to its own
47
+ * spelling adds nothing, a head with nothing below it adds nothing, a head the environment does not answer adds
48
+ * nothing and the answer is not WHOLE, a row whose head is empty (`/*.json`) has no location to resolve. A root is no
49
+ * home, so the answer does not depend on the homes: it is read whether or not a home answered.
50
+ */
51
+ export declare function readDenyRootLinksOf(env: ExecutionEnv, entries: readonly {
52
+ readonly pattern: string;
53
+ readonly caseSensitive?: boolean;
54
+ }[], signal?: AbortSignal, deadline?: number): Promise<{
55
+ readonly linked: Map<string, string[]>;
56
+ readonly whole: boolean;
57
+ }>;
41
58
  /**
42
59
  * Where the rows that open with the configuration directory's name (`~/.sema/…`, {@link agentConfigDirTail}) lead when
43
60
  * the host declares the directory's root (`RunnerDeps.agentConfigDir`) — the same `linked` answer
@@ -138,7 +155,9 @@ export declare function carryHomeViews<T extends object>(judge: T, source: HomeV
138
155
  * with no answer, or a declared home that did not answer, places nothing: the rows are held anywhere (`compile([])`)
139
156
  * rather than at a spelling that may miss the home's canonical one;
140
157
  * · each AUXILIARY home (see the module head) read once under {@link AUXILIARY_HOME_PROBE_MS} — its spellings, its
141
- * chain, its rows' heads — at the same time; whatever of it did not answer costs that home alone and never `whole`.
158
+ * chain, its rows' heads — at the same time; whatever of it did not answer costs that home alone and never `whole`;
159
+ * · the ROOTED rows' heads ({@link readDenyRootLinksOf}), read whether or not a home answered: a head that did not answer
160
+ * leaves the placement not WHOLE, and a placement that held the rows anywhere still places them.
142
161
  * `compile` is the mount's own compile; the rows' patterns are read off its compile at no home. */
143
162
  export declare function placeReadDenyAtHome(env: ExecutionEnv, compile: (homes: readonly string[], linked?: ReadonlyMap<string, readonly string[]>) => ReadDenyMatcher, homes?: readonly string[], signal?: AbortSignal, configRoot?: string): Promise<{
144
163
  readonly matcher: PlacedReadDenyMatcher;
@@ -3,7 +3,7 @@ import { canonicalizeTarget, isAbsolutePathForm, joinForFamily, normalizeAbsPath
3
3
  import { DEFAULT_GATE_AWAIT_TIMEOUT_MS, withGateTimeout } from "../../core/gate-bounded-await.js";
4
4
  import { thrownText } from "../../core/thrown-value.js";
5
5
  import { agentConfigDirTail } from "../../core/agent-config-dir.js";
6
- import { readDenyPatternRun, readDenyRunHead } from "./read-deny.js";
6
+ import { isRootedReadDenyPattern, readDenyPatternRun, readDenyRunHead } from "./read-deny.js";
7
7
  export const AUXILIARY_HOME_PROBE_MS = 2_000;
8
8
  const said = new Set();
9
9
  function sayOnce(kind, line) {
@@ -111,7 +111,7 @@ export async function readDenyHomeLinksOf(env, entries, homes, signal, deadline)
111
111
  const { homeAnchored, run } = readDenyPatternRun(pattern);
112
112
  if (!homeAnchored)
113
113
  continue;
114
- const head = run.split("/").filter(Boolean).slice(0, readDenyRunHead(run)).join("/");
114
+ const head = literalHeadOf(run);
115
115
  if (head.length === 0)
116
116
  continue;
117
117
  const spelled = homes.map((home) => normalizeAbsPathLexically(joinForFamily(pathFamilyOf({ root: home }), home, head)));
@@ -119,6 +119,23 @@ export async function readDenyHomeLinksOf(env, entries, homes, signal, deadline)
119
119
  }
120
120
  return await linkedLocationsOf(env, rows, signal, deadline);
121
121
  }
122
+ const ROOT = "/";
123
+ export async function readDenyRootLinksOf(env, entries, signal, deadline) {
124
+ const rows = [];
125
+ for (const { pattern, caseSensitive } of entries) {
126
+ if (!isRootedReadDenyPattern(pattern))
127
+ continue;
128
+ const head = literalHeadOf(pattern);
129
+ if (head.length === 0)
130
+ continue;
131
+ const spelled = [normalizeAbsPathLexically(joinForFamily(pathFamilyOf({ root: ROOT }), ROOT, head))];
132
+ rows.push({ pattern, caseSensitive: caseSensitive === true, spelled, anchored: spelled });
133
+ }
134
+ return await linkedLocationsOf(env, rows, signal, deadline);
135
+ }
136
+ function literalHeadOf(run) {
137
+ return run.split("/").filter(Boolean).slice(0, readDenyRunHead(run)).join("/");
138
+ }
122
139
  export async function readDenyConfigRootLinksOf(env, entries, roots, signal, deadline) {
123
140
  return await linkedLocationsOf(env, configRootRowsOf(entries, roots), signal, deadline);
124
141
  }
@@ -129,7 +146,7 @@ function configRootRowsOf(entries, roots) {
129
146
  const tail = homeAnchored ? agentConfigDirTail(run, caseSensitive === true) : undefined;
130
147
  if (tail === undefined)
131
148
  continue;
132
- const head = tail.split("/").filter(Boolean).slice(0, readDenyRunHead(tail)).join("/");
149
+ const head = literalHeadOf(tail);
133
150
  const spelled = roots.map((root) => normalizeAbsPathLexically(head.length === 0 ? root : joinForFamily(pathFamilyOf({ root }), root, head)));
134
151
  rows.push({ pattern, caseSensitive: caseSensitive === true, spelled, anchored: [] });
135
152
  }
@@ -363,8 +380,12 @@ export async function placeReadDenyAtHome(env, compile, homes, signal, configRoo
363
380
  const entries = compile([]).entries;
364
381
  const declared = declaredHomeOf(env);
365
382
  const relocated = configRoot === undefined ? undefined : readAuxiliaryHome(env, configRoot, { entries, configRoot: true }, signal);
383
+ const rooted = readDenyRootLinksOf(env, entries, signal);
366
384
  const heldAnywhere = async (whole) => {
367
- const linked = (await relocated)?.linked ?? new Map();
385
+ const linked = new Map();
386
+ for (const map of [(await relocated)?.linked, (await rooted).linked])
387
+ for (const [pattern, locations] of map ?? [])
388
+ linked.set(pattern, locations);
368
389
  return placementOf(carryHomeViews(linked.size === 0 ? compile([]) : compile([], linked), homeViewSourceOver(env, relocated === undefined ? [] : [relocated])), [], linked, whole);
369
390
  };
370
391
  const listed = homes === undefined ? declaredReadDenyHomesAnswered(env) : { homes, whole: true };
@@ -379,15 +400,15 @@ export async function placeReadDenyAtHome(env, compile, homes, signal, configRoo
379
400
  declaredAt = [...new Set([...own, ...ownSpellings.spellings])];
380
401
  }
381
402
  const auxiliary = readAuxiliaryHomes(env, listed.homes, { entries }, signal);
382
- const [heads, readings, root] = await Promise.all([readDenyHomeLinksOf(env, entries, declaredAt, signal), Promise.all(auxiliary), relocated]);
403
+ const [heads, readings, root, rootedHeads] = await Promise.all([readDenyHomeLinksOf(env, entries, declaredAt, signal), Promise.all(auxiliary), relocated, rooted]);
383
404
  const at = [...new Set([...declaredAt, ...readings.flatMap((r) => r.place)])];
384
405
  const linked = new Map();
385
- for (const map of [heads.linked, ...readings.map((r) => r.linked), ...(root === undefined ? [] : [root.linked])]) {
406
+ for (const map of [heads.linked, ...readings.map((r) => r.linked), ...(root === undefined ? [] : [root.linked]), rootedHeads.linked]) {
386
407
  for (const [pattern, locations] of map)
387
408
  linked.set(pattern, [...new Set([...(linked.get(pattern) ?? []), ...locations])]);
388
409
  }
389
410
  const matcher = linked.size === 0 ? compile(at) : compile(at, linked);
390
- return placementOf(carryHomeViews(matcher, homeViewSourceOver(env, relocated === undefined ? auxiliary : [...auxiliary, relocated])), at, linked, heads.whole);
411
+ return placementOf(carryHomeViews(matcher, homeViewSourceOver(env, relocated === undefined ? auxiliary : [...auxiliary, relocated])), at, linked, heads.whole && rootedHeads.whole);
391
412
  }
392
413
  export function readDenyPlacedLater(unplaced, place) {
393
414
  let current = unplaced;
@@ -5,7 +5,10 @@
5
5
  * unless the pattern opens with the HOME word ({@link READ_DENY_HOME_WORD}): `~/<run>` matches only where
6
6
  * the run starts right below the execution environment's home directory (`~/.sema/settings.json` is the
7
7
  * home's file, never a project's `.sema/settings.json`). Only a FIRST segment that is exactly `~` is the
8
- * word; `~user` and a `~` further along are literal segments.
8
+ * word; `~user` and a `~` further along are literal segments. A pattern that opens with `/` (and is not home-anchored)
9
+ * is ROOTED, the same kind of anchor with the filesystem root as its place: `/app/config-lkg.json` names the file
10
+ * at exactly that location, never a project's `app/config-lkg.json` — the run must start at the first segment of an
11
+ * absolute path (a path that is not absolute is not placed, and the row does not judge it).
9
12
  * String shorthand ≡ `{ pattern }`. Matching is ASCII-case-insensitive by default (a case-insensitive
10
13
  * filesystem serves `.SSH` and `.ssh` as the same directory); `caseSensitive: true` opts a single
11
14
  * entry out. Unicode normalization (NFC/NFD) and volume-level case semantics are NOT modeled (v1
@@ -384,8 +387,9 @@ export interface CompiledSegmentPattern {
384
387
  /** The segment lists of the directories the run is ANCHORED at (folded as the pattern folds): the run must start
385
388
  * right after one of them. Absent = the run matches anywhere. */
386
389
  anchors?: readonly (readonly string[])[];
387
- /** Judged in a ROOTED view only: an extra location of a home-anchored row (where its literal head leads). A view that
388
- * is not rooted cannot be placed and is covered by the row's own run, held anywhere. */
390
+ /** Judged in a ROOTED view only: an extra location of a home-anchored row (where its literal head leads), or a row that is
391
+ * itself rooted (anchored at the filesystem root, {@link ReadDenyEntry}). A view that is not rooted cannot be placed: a
392
+ * home-anchored row's own run is held anywhere in it (the row cannot be placed, so it holds wider); these two judge it not. */
389
393
  rootedOnly?: true;
390
394
  }
391
395
  /**
@@ -408,6 +412,9 @@ export declare function compileSegmentPattern(raw: string, fold: "none" | "ascii
408
412
  export declare function matchSegmentPatterns(path: string, compiled: readonly CompiledSegmentPattern[], opts?: {
409
413
  readonly aliasResolved?: boolean;
410
414
  }): string | null;
415
+ /** Is a canonical pattern ROOTED — placed at the filesystem root ({@link ReadDenyEntry})? Only {@link canonicalReadDenyPattern}
416
+ * puts a leading `/` on a canonical pattern, so the text is the whole answer. */
417
+ export declare function isRootedReadDenyPattern(canonical: string): boolean;
411
418
  /**
412
419
  * the PURE validation/canonicality predicate over a PERSISTED deny entry (checkpoint v9
413
420
  * face section), for the resume pre-CAS ladder: it must refuse BEFORE the approval CAS everything
@@ -437,9 +444,9 @@ export declare function checkedReadDenyHomes(homes: unknown, seat?: string): rea
437
444
  * spellings only — any other shape refuses loudly, naming `readDenyHomes`. EMPTY = the environment declares no
438
445
  * home: a home-anchored entry then matches its run anywhere — a row that cannot be placed holds wider, never narrower.
439
446
  *
440
- * `linked` — where a home-anchored row's LITERAL HEAD ({@link readDenyRunHead}) really leads, keyed by the row's
441
- * pattern: the canonical locations a mount resolved (`readDenyHomeLinksOf`, read-deny-home.ts) where one of the
442
- * head's segments is a link (`~/.sema` linked into a dotfiles directory). The row is placed at each location too —
447
+ * `linked` — where a home-anchored or a rooted row's LITERAL HEAD ({@link readDenyRunHead}) really leads, keyed by the row's
448
+ * pattern: the canonical locations a mount resolved (`readDenyHomeLinksOf` / `readDenyRootLinksOf`, read-deny-home.ts) where
449
+ * one of the head's segments is a link (`~/.sema` linked into a dotfiles directory; `/app` a link to `/data/app`). The row is placed at each location too —
443
450
  * the same row, the same name in every refusal — with the rest of its run matched below it. The read faces judge
444
451
  * canonical keys, so every spelling that reaches the linked file meets the row there. Absolute locations only.
445
452
  */
@@ -418,7 +418,14 @@ function normalizeEntry(entry, layer) {
418
418
  throw new Error(`readDenyPatterns (${layer}): pattern ${JSON.stringify(shape.pattern)} contains no path segments${homeAnchored ? " below the home word" : ""} and would ${homeAnchored ? "name the whole home directory" : "deny nothing"}. ` +
419
419
  `Patterns are "/"-separated runs of path SEGMENTS (e.g. ".ssh", ".config/gcloud", "id_rsa*", "~/.sema/settings.json"); remove the entry or spell the segments.`);
420
420
  }
421
- return { pattern: segments.join("/"), caseSensitive: shape.caseSensitive === true };
421
+ return { pattern: canonicalReadDenyPattern(shape.pattern), caseSensitive: shape.caseSensitive === true };
422
+ }
423
+ function canonicalReadDenyPattern(pattern) {
424
+ const joined = pattern.split("/").filter(Boolean).join("/");
425
+ return pattern.startsWith("/") && !readDenyPatternRun(joined).homeAnchored ? `/${joined}` : joined;
426
+ }
427
+ export function isRootedReadDenyPattern(canonical) {
428
+ return canonical.startsWith("/");
422
429
  }
423
430
  export function persistedReadDenyEntryProblem(e) {
424
431
  if (typeof e !== "object" || e === null)
@@ -434,7 +441,7 @@ export function persistedReadDenyEntryProblem(e) {
434
441
  const segments = pattern.split("/").filter(Boolean);
435
442
  if (readDenyPatternRun(segments.join("/")).run.length === 0)
436
443
  return "pattern has no path segments";
437
- if (segments.join("/") !== pattern)
444
+ if (canonicalReadDenyPattern(pattern) !== pattern)
438
445
  return "pattern is not in canonical a/b form";
439
446
  return null;
440
447
  }
@@ -473,6 +480,8 @@ export function compileReadDeny(additions = [], layer = "additions", builtin, ru
473
480
  const c = compileSegmentPattern(run, fold);
474
481
  if (c === null)
475
482
  throw new Error(`readDenyPatterns: pattern ${JSON.stringify(e.pattern)} compiled to zero segments.`);
483
+ if (isRootedReadDenyPattern(e.pattern))
484
+ return { ...c, raw: e.pattern, anchors: [[]], rootedOnly: true };
476
485
  if (!homeAnchored || homeSegments.length === 0)
477
486
  return { ...c, raw: e.pattern };
478
487
  return { ...c, raw: e.pattern, anchors: homeSegments.map((segs) => (fold === "ascii" ? segs.map(asciiLower) : segs)) };
@@ -483,7 +492,7 @@ export function compileReadDeny(additions = [], layer = "additions", builtin, ru
483
492
  normalized.forEach((e, i) => {
484
493
  const locations = linked.get(e.pattern);
485
494
  const { homeAnchored, run } = readDenyPatternRun(e.pattern);
486
- if (locations === undefined || !homeAnchored)
495
+ if (locations === undefined || !(homeAnchored || isRootedReadDenyPattern(e.pattern)))
487
496
  return;
488
497
  const c = compiled[i];
489
498
  const rest = c.segments.slice(readDenyRunHead(run));
@@ -2,8 +2,8 @@ import { planShellReads } from "./shell-read-plan.js";
2
2
  import { literalTextOf } from "../../shell/lex.js";
3
3
  import { createShellReadEstablisher, establishShellReadsLexically } from "./shell-read-establish.js";
4
4
  import { isAbsolutePathForm, isWinFormPath, normalizeAbsPathLexically } from "./safety.js";
5
- import { launchedCommands, launchedReadings, reentryText, spellLaunched } from "./shell-reentry-text.js";
6
- import { readProgramPosition } from "./bash-program-position.js";
5
+ import { launchedCommands, launchedReadings, reentryText, scriptFileAt, spellLaunched } from "./shell-reentry-text.js";
6
+ import { isShellSyntaxWord, LOOP_LIST_HEADS, readProgramPosition } from "./bash-program-position.js";
7
7
  import { bashClassifyDefaultAllow } from "./bash-readonly-classifier.js";
8
8
  import { programArgs } from "./shell-read-write-targets.js";
9
9
  import { implementationsOf, programRow, runsShellText, selectedOptions, writeReadings } from "./program-read-model.js";
@@ -68,7 +68,7 @@ function bindingsOf(plan) {
68
68
  if (w.assignment !== undefined)
69
69
  bind(w.assignment.name, w.assignment.settlesWholeName ? literalTextOf(w.assignment.value.pieces) : undefined);
70
70
  const [head, name] = segment.argv;
71
- if ((head?.folded === "for" || head?.folded === "select") && name !== undefined && NAME.test(name.folded)) {
71
+ if (isShellSyntaxWord(head, LOOP_LIST_HEADS) && name !== undefined && NAME.test(name.folded)) {
72
72
  const at = segment.argv.findIndex((w, i) => i > 1 && w.folded === "in");
73
73
  const words = at < 0 ? [undefined] : segment.argv.slice(at + 1).map((w) => literalTextOf(w.pieces));
74
74
  for (const v of words.length > 0 ? words : [undefined])
@@ -175,6 +175,15 @@ function runtimeScriptOf(segment) {
175
175
  return literal !== undefined ? literal.slice(from) : `${MARK}${w.raw.slice(from)}`;
176
176
  };
177
177
  const pos = readProgramPosition(segment.argv);
178
+ if (pos.program !== undefined && pos.refusal === undefined) {
179
+ const argv = segment.argv.slice(pos.program.index + 1);
180
+ const file = scriptFileAt(program.basename, argv.map((w) => w.folded));
181
+ const source = file === undefined ? undefined : argv[file];
182
+ if (source !== undefined && source.pieces.length === 1 && source.pieces[0].expansion?.kind === "process")
183
+ return source.raw;
184
+ if (pos.argvFromStdin)
185
+ return segment.text.trim();
186
+ }
178
187
  const named = pos.program !== undefined && pos.refusal === undefined ? [{ shell: program.basename, words: segment.argv.slice(pos.program.index + 1).map((_, k) => spell(pos.program.index + 1 + k)) }] : [];
179
188
  const launched = launchedCommands(segment.argv, pos).flatMap((c) => {
180
189
  const shell = launchedReadings(segment.argv, c).shell;