@sema-agent/core 7.17.5 → 7.17.6

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.
@@ -214,6 +214,10 @@ function isPathShapedToken(tok) {
214
214
  return tok.includes("/") || tok.startsWith("~") || tok === "." || tok === "..";
215
215
  }
216
216
  export function resolveOperandLexically(base, operand, homeDir) {
217
+ return resolveOperandSpellings(base, operand, homeDir)?.folded;
218
+ }
219
+ const PARENT_SEGMENT_RE = /(?:^|[\\/])\.\.(?:[\\/]|$)/;
220
+ export function resolveOperandSpellings(base, operand, homeDir) {
217
221
  let raw = operand;
218
222
  if (raw === "~" || raw.startsWith("~/")) {
219
223
  if (homeDir === undefined || !isAbsolutePathForm(homeDir))
@@ -227,12 +231,16 @@ export function resolveOperandLexically(base, operand, homeDir) {
227
231
  if (!win32NamespaceScreen(raw, family).ok)
228
232
  return undefined;
229
233
  if (isAbsoluteForFamily(family, raw))
230
- return normalizeAbsPathLexically(nativeUncSpellingOf(family, raw));
234
+ return spellingsOf(nativeUncSpellingOf(family, raw));
231
235
  if (base === undefined || family === undefined || !isAbsoluteForFamily(family, base))
232
236
  return undefined;
233
237
  if (isShellRootedSpellingUnmapped(family, raw))
234
238
  return undefined;
235
- return normalizeAbsPathLexically(joinForFamily(family, base, raw));
239
+ return spellingsOf(joinForFamily(family, base, raw));
240
+ }
241
+ function spellingsOf(absolute) {
242
+ const folded = normalizeAbsPathLexically(absolute);
243
+ return PARENT_SEGMENT_RE.test(absolute) ? { folded, spelled: absolute } : { folded };
236
244
  }
237
245
  function reheadSegment(toks, head, from = 0) {
238
246
  return { folded: [head, ...toks.folded.slice(from + 1)], raw: [head, ...toks.raw.slice(from + 1)] };
@@ -643,10 +651,25 @@ function unwrapLauncher(folded, allow) {
643
651
  const head = wordBasename;
644
652
  if (SHELL_CONTROL_PREFIXES.has(head) || SHELL_BLOCK_WORDS.has(head))
645
653
  return { kind: "refused", reason: `"${folded[0]}" is given shell control syntax ("${head}") where the program it runs would be ${NOT_AUTO_ALLOWED}` };
646
- return allow.has(head) || head === "cd" ? { kind: "reader", index: i, head, argvFromStdin } : { kind: "unlisted", head, argvFromStdin };
654
+ return allow.has(head) || head === "cd" ? { kind: "reader", index: i, head, argvFromStdin } : { kind: "unlisted", head, index: i, argvFromStdin };
647
655
  }
648
656
  return { kind: "unlisted", argvFromStdin, runsDefaultProgram: lastReentry === folded.length - 1 };
649
657
  }
658
+ const LAUNCHER_OPTION_GRAMMAR = new Map([
659
+ ["time", {
660
+ plain: new Set(["-p", "-a", "-v", "-q", "--portability", "--append", "--verbose", "--quiet", "--help", "--version"]),
661
+ valued: new Set(["-f", "-o", "--format", "--output"]),
662
+ }],
663
+ ]);
664
+ function launcherOptionSpan(launcher, folded, i) {
665
+ const row = launcher === undefined ? undefined : LAUNCHER_OPTION_GRAMMAR.get(launcher);
666
+ const tok = folded[i];
667
+ if (row === undefined || tok === undefined)
668
+ return 0;
669
+ if (row.plain.has(tok))
670
+ return 1;
671
+ return row.valued.has(tok) && folded[i + 1] !== undefined ? 2 : 0;
672
+ }
650
673
  const ARGV_REENTRY_DEFAULT_PROGRAM = "echo";
651
674
  function argvReentryUnjudged(unwrapped) {
652
675
  if (!unwrapped.argvFromStdin)
@@ -846,6 +869,305 @@ const SED_READONLY_FLAGS = new Set([
846
869
  ]);
847
870
  const SED_READONLY_CLUSTER_LETTERS = new Set(["n", "E", "r", "z"]);
848
871
  const SED_READONLY_SCRIPT_PIECE = /^(?:\d+|\d+,\d+)?p$/;
872
+ const SED_FILE_OR_EXEC_COMMANDS = new Set(["r", "R", "w", "W", "e"]);
873
+ const SED_PLAIN_COMMANDS = new Set(["=", "d", "D", "F", "g", "G", "h", "H", "n", "N", "p", "P", "x", "z"]);
874
+ const SED_NUMERIC_COMMANDS = new Set(["q", "Q", "l", "L"]);
875
+ const SED_LABEL_COMMANDS = new Set(["b", "t", "T", ":", "v"]);
876
+ const SED_TEXT_COMMANDS = new Set(["a", "i", "c"]);
877
+ const SED_SUBSTITUTE_FLAGS = /[gpiImM0-9]/;
878
+ const SED_SCRIPT_LINE_LIMIT = 1024;
879
+ function utf8Length(text) {
880
+ let bytes = 0;
881
+ for (const ch of text) {
882
+ const code = ch.codePointAt(0);
883
+ bytes += code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4;
884
+ }
885
+ return bytes;
886
+ }
887
+ function readSedScript(script) {
888
+ if (script.split("\n").some((line) => utf8Length(line) >= SED_SCRIPT_LINE_LIMIT))
889
+ return "unseen";
890
+ let i = 0;
891
+ let depth = 0;
892
+ const len = script.length;
893
+ const isBlank = (ch) => ch === " " || ch === "\t" || ch === "\v" || ch === "\f";
894
+ const skipBlank = () => {
895
+ while (i < len && isBlank(script[i]))
896
+ i++;
897
+ };
898
+ const digits = () => {
899
+ while (i < len && script[i] >= "0" && script[i] <= "9")
900
+ i++;
901
+ };
902
+ const consumeDelimited = (delim, regex) => {
903
+ while (i < len) {
904
+ const ch = script[i];
905
+ if (ch === "\\") {
906
+ const escaped = script[i + 1];
907
+ if (escaped === undefined || escaped === "\n" || escaped === "\r")
908
+ return false;
909
+ i += 2;
910
+ continue;
911
+ }
912
+ if (ch === "\n" || ch === "\r")
913
+ return false;
914
+ if (ch === delim) {
915
+ i++;
916
+ return true;
917
+ }
918
+ if (regex && ch === "[") {
919
+ i++;
920
+ if (script[i] === "^")
921
+ i++;
922
+ if (script[i] === "]")
923
+ i++;
924
+ while (i < len && script[i] !== "]" && script[i] !== "\n" && script[i] !== "\r") {
925
+ const next = script[i + 1];
926
+ if (script[i] === "[" && (next === ":" || next === "." || next === "=")) {
927
+ const at = script.indexOf(`${next}]`, i + 2);
928
+ if (at === -1 || script.slice(i, at).includes("\n"))
929
+ return false;
930
+ i = at + 2;
931
+ continue;
932
+ }
933
+ i++;
934
+ }
935
+ if (i >= len || script[i] !== "]")
936
+ return false;
937
+ i++;
938
+ continue;
939
+ }
940
+ i++;
941
+ }
942
+ return false;
943
+ };
944
+ const readAddress = () => {
945
+ const ch = script[i];
946
+ if (ch === undefined)
947
+ return undefined;
948
+ if (ch === "$") {
949
+ i++;
950
+ return true;
951
+ }
952
+ if (ch >= "0" && ch <= "9") {
953
+ digits();
954
+ if (script[i] === "~") {
955
+ i++;
956
+ digits();
957
+ }
958
+ return true;
959
+ }
960
+ if (ch === "/" || ch === "\\") {
961
+ const delim = ch === "/" ? "/" : script[i + 1];
962
+ i += ch === "/" ? 1 : 2;
963
+ if (delim === undefined || !consumeDelimited(delim, true))
964
+ return false;
965
+ while (i < len && (script[i] === "I" || script[i] === "M"))
966
+ i++;
967
+ return true;
968
+ }
969
+ return undefined;
970
+ };
971
+ while (i < len) {
972
+ const ch = script[i];
973
+ if (isBlank(ch) || ch === "\n" || ch === "\r" || ch === ";") {
974
+ i++;
975
+ continue;
976
+ }
977
+ if (ch === "#") {
978
+ while (i < len && script[i] !== "\n")
979
+ i++;
980
+ continue;
981
+ }
982
+ if (ch === "}") {
983
+ if (depth === 0)
984
+ return "unparsed";
985
+ depth--;
986
+ i++;
987
+ continue;
988
+ }
989
+ const first = readAddress();
990
+ if (first === false)
991
+ return "unparsed";
992
+ if (first === true) {
993
+ skipBlank();
994
+ if (script[i] === ",") {
995
+ i++;
996
+ skipBlank();
997
+ if (script[i] === "+" || script[i] === "~") {
998
+ i++;
999
+ if (!(script[i] >= "0" && script[i] <= "9"))
1000
+ return "unparsed";
1001
+ digits();
1002
+ }
1003
+ else if (readAddress() !== true)
1004
+ return "unparsed";
1005
+ }
1006
+ skipBlank();
1007
+ }
1008
+ while (script[i] === "!") {
1009
+ i++;
1010
+ skipBlank();
1011
+ }
1012
+ const cmd = script[i];
1013
+ if (cmd === undefined)
1014
+ return "unparsed";
1015
+ i++;
1016
+ if (SED_FILE_OR_EXEC_COMMANDS.has(cmd))
1017
+ return "names";
1018
+ if (cmd === "{") {
1019
+ depth++;
1020
+ continue;
1021
+ }
1022
+ if (cmd === "s" || cmd === "y") {
1023
+ const delim = script[i];
1024
+ if (delim === undefined || delim === "\\" || delim === "\n")
1025
+ return "unparsed";
1026
+ i++;
1027
+ if (!consumeDelimited(delim, cmd === "s"))
1028
+ return "unparsed";
1029
+ if (!consumeDelimited(delim, false))
1030
+ return "unparsed";
1031
+ if (cmd === "y")
1032
+ continue;
1033
+ while (i < len) {
1034
+ const flag = script[i];
1035
+ if (flag === "w" || flag === "W" || flag === "e")
1036
+ return "names";
1037
+ if (SED_SUBSTITUTE_FLAGS.test(flag) || isBlank(flag)) {
1038
+ i++;
1039
+ continue;
1040
+ }
1041
+ if (flag === ";" || flag === "\n" || flag === "\r" || flag === "}" || flag === "#")
1042
+ break;
1043
+ return "unparsed";
1044
+ }
1045
+ continue;
1046
+ }
1047
+ if (SED_TEXT_COMMANDS.has(cmd)) {
1048
+ while (i < len && script[i] !== "\n")
1049
+ i++;
1050
+ continue;
1051
+ }
1052
+ if (SED_LABEL_COMMANDS.has(cmd)) {
1053
+ skipBlank();
1054
+ while (i < len && script[i] !== "\n" && script[i] !== ";" && script[i] !== "}" && !isBlank(script[i]))
1055
+ i++;
1056
+ continue;
1057
+ }
1058
+ if (SED_NUMERIC_COMMANDS.has(cmd)) {
1059
+ skipBlank();
1060
+ digits();
1061
+ continue;
1062
+ }
1063
+ if (!SED_PLAIN_COMMANDS.has(cmd))
1064
+ return "unparsed";
1065
+ }
1066
+ return depth === 0 ? "clean" : "unparsed";
1067
+ }
1068
+ const SED_OPTION_DIALECTS = [
1069
+ { separated: "efl", suffix: "iI", stopsAtFirstOperand: false },
1070
+ { separated: "ef", suffix: "iI", stopsAtFirstOperand: true },
1071
+ ];
1072
+ function sedSeparatedSuffixTaken(tok) {
1073
+ return tok !== undefined && (tok === "" || tok.startsWith("."));
1074
+ }
1075
+ const SED_LONG_VALUE_OPTIONS = ["expression", "line-length"];
1076
+ const SED_LONG_PLAIN_OPTIONS = [
1077
+ "debug", "follow-symlinks", "help", "in-place", "null-data", "posix", "quiet", "regexp-extended",
1078
+ "sandbox", "separate", "silent", "unbuffered", "version", "zero-terminated",
1079
+ ];
1080
+ function readSedInvocation(args, dialect) {
1081
+ const scripts = [];
1082
+ let scriptSupplied = false;
1083
+ let endOfOptions = false;
1084
+ let firstOperand;
1085
+ for (let k = 0; k < args.length; k++) {
1086
+ const tok = args[k];
1087
+ if (!endOfOptions && tok === "--") {
1088
+ endOfOptions = true;
1089
+ continue;
1090
+ }
1091
+ if (endOfOptions || !tok.startsWith("-") || tok === "-") {
1092
+ if (firstOperand === undefined) {
1093
+ firstOperand = tok;
1094
+ if (dialect.stopsAtFirstOperand)
1095
+ endOfOptions = true;
1096
+ }
1097
+ continue;
1098
+ }
1099
+ if (tok.startsWith("--")) {
1100
+ const long = longOptionNameOf(tok);
1101
+ if (long === undefined)
1102
+ return "unparsed";
1103
+ const eq = tok.indexOf("=");
1104
+ const attached = eq >= 0 ? tok.slice(eq + 1) : undefined;
1105
+ if (isLongOptionAbbrevOf(long, "file"))
1106
+ return "unseen";
1107
+ if (isLongOptionAbbrevOf(long, "expression")) {
1108
+ const value = attached ?? args[++k];
1109
+ if (value === undefined)
1110
+ return "unparsed";
1111
+ scripts.push(value);
1112
+ scriptSupplied = true;
1113
+ continue;
1114
+ }
1115
+ if (SED_LONG_VALUE_OPTIONS.some((full) => isLongOptionAbbrevOf(long, full))) {
1116
+ if (attached === undefined)
1117
+ k++;
1118
+ continue;
1119
+ }
1120
+ if (SED_LONG_PLAIN_OPTIONS.some((full) => isLongOptionAbbrevOf(long, full)))
1121
+ continue;
1122
+ return "unparsed";
1123
+ }
1124
+ for (let c = 1; c < tok.length; c++) {
1125
+ const letter = tok[c];
1126
+ if (dialect.suffix.includes(letter)) {
1127
+ if (c + 1 === tok.length && sedSeparatedSuffixTaken(args[k + 1]))
1128
+ k++;
1129
+ break;
1130
+ }
1131
+ if (dialect.separated.includes(letter)) {
1132
+ const rest = tok.slice(c + 1);
1133
+ const value = rest !== "" ? rest : args[++k];
1134
+ if (letter === "f")
1135
+ return "unseen";
1136
+ if (letter === "e") {
1137
+ if (value === undefined)
1138
+ return "unparsed";
1139
+ scripts.push(value);
1140
+ scriptSupplied = true;
1141
+ }
1142
+ break;
1143
+ }
1144
+ if (!SED_SHORT_PLAIN_LETTERS.includes(letter))
1145
+ return "unparsed";
1146
+ }
1147
+ }
1148
+ const script = scriptSupplied ? scripts.join("\n") : firstOperand;
1149
+ if (script === undefined)
1150
+ return "clean";
1151
+ return readSedScript(script);
1152
+ }
1153
+ const SED_SHORT_PLAIN_LETTERS = "nsuzEraHl";
1154
+ const SCRIPT_SLOT_VERBS = new Map([
1155
+ ["sed", (args) => {
1156
+ const readings = SED_OPTION_DIALECTS.map((dialect) => readSedInvocation(args, dialect));
1157
+ if (readings.includes("names") || readings.includes("unseen"))
1158
+ return true;
1159
+ return readings.every((reading) => reading === "unparsed");
1160
+ }],
1161
+ ]);
1162
+ function unjudgedProgramTextRefusal(written, clause) {
1163
+ return `command "${written}" ${clause}, which this walk does not judge ${NOT_AUTO_ALLOWED}`;
1164
+ }
1165
+ function scriptSlotRefusal(written, name, args) {
1166
+ const recognizer = SCRIPT_SLOT_VERBS.get(name);
1167
+ if (recognizer === undefined || !recognizer(args))
1168
+ return undefined;
1169
+ return unjudgedProgramTextRefusal(written, "names files or runs commands inside its script text");
1170
+ }
849
1171
  function guardedVerbFormReason(name, toks, raw) {
850
1172
  if (name === "find") {
851
1173
  for (const t of toks) {
@@ -1130,12 +1452,12 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
1130
1452
  for (let k = 0; k < args.length; k++) {
1131
1453
  if (!argGlobs(k) || expandingArgs.has(k))
1132
1454
  continue;
1133
- const resolved = resolveOperandLexically(boundary.cwd ?? boundary.roots[0], args[k], boundary.homeDir);
1134
- findings.push({ kind: "undecided", path: resolved ?? args[k] });
1455
+ const r = resolveOperandSpellings(boundary.cwd ?? boundary.roots[0], args[k], boundary.homeDir);
1456
+ findings.push({ kind: "undecided", path: r?.folded ?? args[k], ...(r?.spelled !== undefined ? { spelled: r.spelled } : {}) });
1135
1457
  }
1136
1458
  for (const { text: candidate, globbed } of candidates) {
1137
- const resolved = resolveOperandLexically(boundary.cwd ?? boundary.roots[0], candidate, boundary.homeDir);
1138
- if (resolved === undefined) {
1459
+ const spellings = resolveOperandSpellings(boundary.cwd ?? boundary.roots[0], candidate, boundary.homeDir);
1460
+ if (spellings === undefined) {
1139
1461
  findings.push({
1140
1462
  kind: "unresolvable",
1141
1463
  operand: candidate,
@@ -1143,19 +1465,20 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
1143
1465
  });
1144
1466
  continue;
1145
1467
  }
1468
+ const resolved = spellings.folded;
1146
1469
  if (withinAnyRoot(boundary.roots, resolved)) {
1147
1470
  if (!globbed)
1148
- findings.push({ kind: "inside", path: resolved });
1471
+ findings.push({ kind: "inside", path: resolved, ...(spellings.spelled !== undefined ? { spelled: spellings.spelled } : {}) });
1149
1472
  }
1150
1473
  else
1151
- findings.push({ kind: "outside", command: name, path: resolved });
1474
+ findings.push({ kind: "outside", command: name, path: resolved, ...(spellings.spelled !== undefined ? { spelled: spellings.spelled } : {}) });
1152
1475
  }
1153
1476
  for (const { text: operand, globbed } of bareWordOperands) {
1154
- const resolved = resolveOperandLexically(boundary.cwd ?? boundary.roots[0], operand, boundary.homeDir);
1155
- if (resolved === undefined)
1477
+ const spellings = resolveOperandSpellings(boundary.cwd ?? boundary.roots[0], operand, boundary.homeDir);
1478
+ if (spellings === undefined)
1156
1479
  continue;
1157
- if (!globbed && withinAnyRoot(boundary.roots, resolved))
1158
- findings.push({ kind: "inside", path: resolved });
1480
+ if (!globbed && withinAnyRoot(boundary.roots, spellings.folded))
1481
+ findings.push({ kind: "inside", path: spellings.folded, ...(spellings.spelled !== undefined ? { spelled: spellings.spelled } : {}) });
1159
1482
  }
1160
1483
  if (boundary.denyMatch !== undefined && segmentSelectsRecursiveRead(name, args)) {
1161
1484
  const roots = positionalOperands.length > 0 ? positionalOperands : stdinOperand ? [] : ["."];
@@ -1236,11 +1559,17 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
1236
1559
  const toks = tokenizeSegment(segments[si]);
1237
1560
  const folded = toks.folded;
1238
1561
  let from = 0;
1239
- while (from < folded.length && isBareControlWord(toks, from, SHELL_CONTROL_PREFIXES))
1562
+ while (from < folded.length && isBareControlWord(toks, from, SHELL_CONTROL_PREFIXES)) {
1563
+ const controlWord = programBasename(folded[from] ?? "");
1240
1564
  from++;
1565
+ for (let span = launcherOptionSpan(controlWord, folded, from); span > 0; span = launcherOptionSpan(controlWord, folded, from))
1566
+ from += span;
1567
+ }
1241
1568
  const written = folded[from];
1242
1569
  if (written === undefined)
1243
1570
  return { reason: "empty command" };
1571
+ if (written === "coproc")
1572
+ return { reason: "`coproc` backgrounds a command — a backgrounded process outlives the command", refusedWhole: true };
1244
1573
  if (isBareControlWord(toks, from, SHELL_BLOCK_WORDS)) {
1245
1574
  unlistedSegments.add(si);
1246
1575
  nameRefusal ??= `command "${written}" is not in the read-only allowlist`;
@@ -1253,6 +1582,18 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
1253
1582
  const name = pathPrefixed ? parsed.name.slice(parsed.name.lastIndexOf("/") + 1) : parsed.name;
1254
1583
  if (pathPrefixed)
1255
1584
  nameRefusal ??= "the command must be a bare name resolved via PATH (no path prefix)";
1585
+ if (name.startsWith("-") && name !== "-") {
1586
+ return { reason: `"${folded[0]}" is given an option where the program it runs would be — which program runs is not readable without its option table ${NOT_AUTO_ALLOWED}`, refusedWhole: true };
1587
+ }
1588
+ const launcherUnwrap = COMMAND_LAUNCHERS.has(name) ? unwrapLauncher(folded.slice(from), allow) : undefined;
1589
+ if (launcherUnwrap?.kind === "refused")
1590
+ return { reason: launcherUnwrap.reason, refusedWhole: true };
1591
+ const runs = launcherUnwrap === undefined ? { head: name, index: 0 } : { head: launcherUnwrap.head, index: launcherUnwrap.index };
1592
+ if (runs?.head !== undefined && runs.index !== undefined) {
1593
+ const slotRefusal = scriptSlotRefusal(runs.head, runs.head, folded.slice(from + runs.index + 1));
1594
+ if (slotRefusal !== undefined)
1595
+ return { reason: slotRefusal, refusedWhole: true };
1596
+ }
1256
1597
  if (allow.has(name)) {
1257
1598
  if (pathPrefixed || from > 0)
1258
1599
  reheaded.set(si, { from, head: name });
@@ -1262,9 +1603,9 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
1262
1603
  }
1263
1604
  nameRefusal ??= `command "${parsed.name}" is not in the read-only allowlist`;
1264
1605
  if (SHELL_REENTRY_PROGRAM_RE.test(name))
1265
- return { reason: `command "${parsed.name}" runs its argument as shell text, which this walk does not judge ${NOT_AUTO_ALLOWED}`, refusedWhole: true };
1606
+ return { reason: unjudgedProgramTextRefusal(parsed.name, "runs its argument as shell text"), refusedWhole: true };
1266
1607
  if (COMMAND_LAUNCHERS.has(name)) {
1267
- const unwrapped = unwrapLauncher(folded.slice(from), allow);
1608
+ const unwrapped = launcherUnwrap ?? unwrapLauncher(folded.slice(from), allow);
1268
1609
  if (unwrapped.kind === "refused")
1269
1610
  return { reason: unwrapped.reason, refusedWhole: true };
1270
1611
  if (unwrapped.kind === "unlisted" && unwrapped.head !== undefined && SHELL_REENTRY_PROGRAM_RE.test(unwrapped.head)) {
@@ -1438,13 +1779,22 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
1438
1779
  }
1439
1780
  const BASE_CANDIDATE_MAX = 256;
1440
1781
  function evaluateReadBoundary(foldedSegments, boundary, reach) {
1441
- const seen = { outside: new Set(), inside: new Set(), undecided: new Set(), recursive: new Set(), unresolved: new Set() };
1782
+ const seen = { outside: new Set(), inside: new Set(), undecided: new Set(), recursive: new Set(), unresolved: new Set(), checkedSpelled: new Set(), undecidedSpelled: new Set(), outsideSpelled: new Set() };
1442
1783
  const outside = [];
1443
1784
  const inside = [];
1444
1785
  let unresolvable;
1445
1786
  const unresolved = [];
1446
1787
  let baseLost = false;
1447
1788
  const undecided = [];
1789
+ const checkedSpellings = [];
1790
+ const undecidedSpellings = [];
1791
+ const outOfRootSpellings = [];
1792
+ const recordSpelling = (into, seenHere, finding) => {
1793
+ if (finding.spelled === undefined || seenHere.has(finding.spelled))
1794
+ return;
1795
+ seenHere.add(finding.spelled);
1796
+ into.push(finding.spelled);
1797
+ };
1448
1798
  const recursive = [];
1449
1799
  let candidates = [{ base: boundary.cwd ?? boundary.roots[0], skippedCd: -1 }];
1450
1800
  let andRunStart = 0;
@@ -1498,6 +1848,7 @@ function evaluateReadBoundary(foldedSegments, boundary, reach) {
1498
1848
  seen.inside.add(finding.path);
1499
1849
  inside.push(finding.path);
1500
1850
  }
1851
+ recordSpelling(checkedSpellings, seen.checkedSpelled, finding);
1501
1852
  continue;
1502
1853
  }
1503
1854
  if (finding.kind === "undecided") {
@@ -1505,6 +1856,7 @@ function evaluateReadBoundary(foldedSegments, boundary, reach) {
1505
1856
  seen.undecided.add(finding.path);
1506
1857
  undecided.push(finding.path);
1507
1858
  }
1859
+ recordSpelling(undecidedSpellings, seen.undecidedSpelled, finding);
1508
1860
  continue;
1509
1861
  }
1510
1862
  if (finding.kind === "recursive") {
@@ -1525,6 +1877,7 @@ function evaluateReadBoundary(foldedSegments, boundary, reach) {
1525
1877
  seen.outside.add(finding.path);
1526
1878
  outside.push(finding);
1527
1879
  }
1880
+ recordSpelling(outOfRootSpellings, seen.outsideSpelled, finding);
1528
1881
  }
1529
1882
  }
1530
1883
  if (!baseLost && toks.folded[0] === "cd") {
@@ -1563,25 +1916,32 @@ function evaluateReadBoundary(foldedSegments, boundary, reach) {
1563
1916
  const undecidedField = {
1564
1917
  ...(undecidedAll.length > 0 ? { undecidedPaths: undecidedAll } : {}),
1565
1918
  ...(recursive.length > 0 ? { recursiveReadPaths: recursive } : {}),
1919
+ ...(undecidedSpellings.length > 0 ? { undecidedSpellings } : {}),
1566
1920
  };
1921
+ const checkedField = {
1922
+ ...(inside.length > 0 ? { checkedPaths: inside } : {}),
1923
+ ...(checkedSpellings.length > 0 ? { checkedSpellings } : {}),
1924
+ };
1925
+ const outsideField = outOfRootSpellings.length > 0 ? { outOfRootSpellings } : {};
1567
1926
  if (unresolvable !== undefined) {
1568
1927
  return {
1569
1928
  reason: unresolvable,
1570
1929
  unresolvedOperands: unresolved,
1571
- ...(outside.length > 0 ? { outOfRootRead: true, outOfRootPaths: outside.map((o) => o.path) } : {}),
1572
- ...(inside.length > 0 ? { checkedPaths: inside } : {}),
1930
+ ...(outside.length > 0 ? { outOfRootRead: true, outOfRootPaths: outside.map((o) => o.path), ...outsideField } : {}),
1931
+ ...checkedField,
1573
1932
  ...undecidedField,
1574
1933
  };
1575
1934
  }
1576
1935
  if (outside.length === 0)
1577
- return inside.length > 0 ? { checkedPaths: inside, ...undecidedField } : { ...undecidedField };
1936
+ return { ...checkedField, ...undecidedField };
1578
1937
  const paths = outside.map((o) => `"${o.path}"`).join(", ");
1579
1938
  const allowed = boundary.roots.length > 0 ? boundary.roots.join(", ") : "(none)";
1580
1939
  return {
1581
1940
  reason: `"${outside[0].command}" reads ${paths}, outside the allowed directories for this session: ${allowed} ${NOT_AUTO_ALLOWED}`,
1582
1941
  outOfRootRead: true,
1583
1942
  outOfRootPaths: outside.map((o) => o.path),
1584
- ...(inside.length > 0 ? { checkedPaths: inside } : {}),
1943
+ ...outsideField,
1944
+ ...checkedField,
1585
1945
  ...undecidedField,
1586
1946
  };
1587
1947
  }
@@ -1589,6 +1949,19 @@ export function classifySimpleCommandReadBoundary(command, boundary) {
1589
1949
  const toks = tokenizeSegment(command);
1590
1950
  if (toks.folded.length === 0)
1591
1951
  return {};
1952
+ const head = programBasename(toks.folded[0]);
1953
+ let past = 0;
1954
+ for (let span = launcherOptionSpan(head, toks.folded, past + 1); span > 0; span = launcherOptionSpan(head, toks.folded, past + 1))
1955
+ past += span;
1956
+ const chain = past === 0 ? toks.folded : [toks.folded[0], ...toks.folded.slice(past + 1)];
1957
+ const runs = COMMAND_LAUNCHERS.has(head) ? unwrapLauncher(chain, new Set()) : { kind: "reader", head, index: 0 };
1958
+ if (runs.kind === "refused")
1959
+ return { reason: runs.reason, refusedWhole: true };
1960
+ if (runs.head !== undefined && runs.index !== undefined) {
1961
+ const slotRefusal = scriptSlotRefusal(runs.head, runs.head, toks.folded.slice(runs.index + past + 1));
1962
+ if (slotRefusal !== undefined)
1963
+ return { reason: slotRefusal, refusedWhole: true };
1964
+ }
1592
1965
  return evaluateReadBoundary([toks], boundary);
1593
1966
  }
1594
1967
  const NOT_GATED = { gated: false, paths: [] };