@sema-agent/core 7.17.4 → 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.
Files changed (40) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/dist/core/memory-engine/delegation-settlement.d.ts +27 -9
  3. package/dist/core/memory-engine/delegation-settlement.js +155 -46
  4. package/dist/core/memory-engine/engine.js +12 -6
  5. package/dist/core/remote-env.d.ts +23 -1
  6. package/dist/core/remote-env.js +4 -1
  7. package/dist/core/runner/prepare-hands-readface.js +10 -8
  8. package/dist/core/tool-catalog-entries.js +1 -1
  9. package/dist/tools/fs/bash-readonly-classifier.d.ts +46 -5
  10. package/dist/tools/fs/bash-readonly-classifier.js +572 -80
  11. package/dist/tools/fs/fs-bash.d.ts +28 -0
  12. package/dist/tools/fs/fs-bash.js +135 -27
  13. package/dist/tools/fs/index.d.ts +13 -0
  14. package/dist/tools/fs/index.js +1 -0
  15. package/package.json +3 -2
  16. package/wire-changes/core@7.10.0.json +739 -0
  17. package/wire-changes/core@7.11.0.json +685 -0
  18. package/wire-changes/core@7.11.1.json +112 -0
  19. package/wire-changes/core@7.11.2.json +73 -0
  20. package/wire-changes/core@7.12.0.json +356 -0
  21. package/wire-changes/core@7.13.0.json +199 -0
  22. package/wire-changes/core@7.14.0.json +578 -0
  23. package/wire-changes/core@7.15.0.json +174 -0
  24. package/wire-changes/core@7.16.0.json +132 -0
  25. package/wire-changes/core@7.17.0.json +1621 -0
  26. package/wire-changes/core@7.17.1.json +139 -0
  27. package/wire-changes/core@7.17.2.json +65 -0
  28. package/wire-changes/core@7.17.3.json +44 -0
  29. package/wire-changes/core@7.17.4.json +52 -0
  30. package/wire-changes/core@7.17.5.json +67 -0
  31. package/wire-changes/core@7.5.0.json +1063 -0
  32. package/wire-changes/core@7.5.1.json +252 -0
  33. package/wire-changes/core@7.5.2.json +418 -0
  34. package/wire-changes/core@7.6.0.json +1243 -0
  35. package/wire-changes/core@7.6.1.json +1130 -0
  36. package/wire-changes/core@7.7.0.json +509 -0
  37. package/wire-changes/core@7.8.0.json +1293 -0
  38. package/wire-changes/core@7.9.0.json +691 -0
  39. package/wire-changes/core@7.9.1.json +518 -0
  40. package/wire-changes/core@7.9.2.json +46 -0
@@ -109,7 +109,7 @@ export function splitShellCompoundSegments(source, options) {
109
109
  }
110
110
  const scanned = options?.trailingTerminator !== "keep" ? source.replace(/;\s*$/, "") : source;
111
111
  const segments = [];
112
- const pipeFed = [false];
112
+ const connectors = ["start"];
113
113
  const mask = quoteMask(scanned);
114
114
  const quoted = (i) => mask.balanced && mask.quoted[i] === true;
115
115
  let cur = "";
@@ -122,7 +122,7 @@ export function splitShellCompoundSegments(source, options) {
122
122
  if (scanned[i + 1] === "&") {
123
123
  segments.push(cur);
124
124
  cur = "";
125
- pipeFed.push(false);
125
+ connectors.push("&&");
126
126
  i++;
127
127
  }
128
128
  else
@@ -132,25 +132,25 @@ export function splitShellCompoundSegments(source, options) {
132
132
  if (scanned[i + 1] === "|") {
133
133
  segments.push(cur);
134
134
  cur = "";
135
- pipeFed.push(false);
135
+ connectors.push("||");
136
136
  i++;
137
137
  }
138
138
  else {
139
139
  segments.push(cur);
140
140
  cur = "";
141
- pipeFed.push(true);
141
+ connectors.push("|");
142
142
  }
143
143
  }
144
144
  else if (c === ";") {
145
145
  segments.push(cur);
146
146
  cur = "";
147
- pipeFed.push(false);
147
+ connectors.push(";");
148
148
  }
149
149
  else
150
150
  cur += c;
151
151
  }
152
152
  segments.push(cur);
153
- return { segments, pipeFed };
153
+ return { segments, pipeFed: connectors.map((k) => k === "|"), connectors };
154
154
  }
155
155
  const EXEMPT_REDIRECTION = /^(?:2>\/dev\/null|[12]?>&[12])$/;
156
156
  function stripExemptRedirections(command) {
@@ -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)] };
@@ -259,7 +267,8 @@ function tokensMoveBase(toks, allow) {
259
267
  const unwrapped = unwrapLauncher(toks.folded.slice(from), allow);
260
268
  return unwrapped.kind === "refused" || (unwrapped.kind === "reader" && unwrapped.head === "cd");
261
269
  }
262
- const SHELL_REENTRY_PROGRAM_RE = /^(?:(?:ba|z|da|k|fi)?sh|eval|source|\.)$/;
270
+ const SHELL_REENTRY_PROGRAM_RE = /^(?:(?:ba|z|da|k|fi)?sh|eval|source|\.|parallel)$/;
271
+ const ARGV_REENTRY_PROGRAM_RE = /^xargs$/;
263
272
  export function refusedCommandMayRead(command, allow, boundary) {
264
273
  return !refusedCommandExempt(command, allow, boundary, 0);
265
274
  }
@@ -579,6 +588,8 @@ function refusedCommandExempt(command, allow, boundary, depth, baseMovedOutside
579
588
  const unwrapped = unwrapLauncher(toks.slice(i).map(fold), allow);
580
589
  if (unwrapped.kind === "refused")
581
590
  return false;
591
+ if (argvReentryUnjudged(unwrapped))
592
+ return false;
582
593
  if (unwrapped.kind === "unlisted") {
583
594
  if (!unlistedSegmentJudgedInside(toks, i))
584
595
  return false;
@@ -621,18 +632,50 @@ function refusedCommandExempt(command, allow, boundary, depth, baseMovedOutside
621
632
  return true;
622
633
  }
623
634
  function unwrapLauncher(folded, allow) {
635
+ let argvFromStdin = ARGV_REENTRY_PROGRAM_RE.test(programBasename(folded[0] ?? ""));
636
+ let lastReentry = argvFromStdin ? 0 : -1;
624
637
  for (let i = 1; i < folded.length; i++) {
625
638
  const t = folded[i];
626
- if (ASSIGNMENT_WORD.test(t) || /^\d+[smhd]?$/.test(t) || COMMAND_LAUNCHERS.has(t))
639
+ if (ASSIGNMENT_WORD.test(t) || /^\d+[smhd]?$/.test(t))
640
+ continue;
641
+ const wordBasename = programBasename(t);
642
+ if (ARGV_REENTRY_PROGRAM_RE.test(wordBasename)) {
643
+ argvFromStdin = true;
644
+ lastReentry = i;
645
+ continue;
646
+ }
647
+ if (COMMAND_LAUNCHERS.has(wordBasename))
627
648
  continue;
628
649
  if (t.startsWith("-"))
629
650
  return { kind: "refused", reason: `"${folded[0]}" is given an option before the program it runs — which program runs is not readable without the launcher's option table ${NOT_AUTO_ALLOWED}` };
630
- const head = t.includes("/") ? t.slice(t.lastIndexOf("/") + 1) : t;
651
+ const head = wordBasename;
631
652
  if (SHELL_CONTROL_PREFIXES.has(head) || SHELL_BLOCK_WORDS.has(head))
632
653
  return { kind: "refused", reason: `"${folded[0]}" is given shell control syntax ("${head}") where the program it runs would be ${NOT_AUTO_ALLOWED}` };
633
- return allow.has(head) || head === "cd" ? { kind: "reader", index: i, head } : { kind: "unlisted", head };
654
+ return allow.has(head) || head === "cd" ? { kind: "reader", index: i, head, argvFromStdin } : { kind: "unlisted", head, index: i, argvFromStdin };
634
655
  }
635
- return { kind: "unlisted" };
656
+ return { kind: "unlisted", argvFromStdin, runsDefaultProgram: lastReentry === folded.length - 1 };
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
+ }
673
+ const ARGV_REENTRY_DEFAULT_PROGRAM = "echo";
674
+ function argvReentryUnjudged(unwrapped) {
675
+ if (!unwrapped.argvFromStdin)
676
+ return false;
677
+ const runs = unwrapped.head ?? (unwrapped.runsDefaultProgram === true ? ARGV_REENTRY_DEFAULT_PROGRAM : undefined);
678
+ return runs === undefined || !NO_PATH_OPERAND_COMMANDS.has(runs);
636
679
  }
637
680
  function tokenizeSegment(segment) {
638
681
  const raw = splitWordsQuoteAware(segment);
@@ -826,6 +869,305 @@ const SED_READONLY_FLAGS = new Set([
826
869
  ]);
827
870
  const SED_READONLY_CLUSTER_LETTERS = new Set(["n", "E", "r", "z"]);
828
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
+ }
829
1171
  function guardedVerbFormReason(name, toks, raw) {
830
1172
  if (name === "find") {
831
1173
  for (const t of toks) {
@@ -1110,12 +1452,12 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
1110
1452
  for (let k = 0; k < args.length; k++) {
1111
1453
  if (!argGlobs(k) || expandingArgs.has(k))
1112
1454
  continue;
1113
- const resolved = resolveOperandLexically(boundary.cwd ?? boundary.roots[0], args[k], boundary.homeDir);
1114
- 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 } : {}) });
1115
1457
  }
1116
1458
  for (const { text: candidate, globbed } of candidates) {
1117
- const resolved = resolveOperandLexically(boundary.cwd ?? boundary.roots[0], candidate, boundary.homeDir);
1118
- if (resolved === undefined) {
1459
+ const spellings = resolveOperandSpellings(boundary.cwd ?? boundary.roots[0], candidate, boundary.homeDir);
1460
+ if (spellings === undefined) {
1119
1461
  findings.push({
1120
1462
  kind: "unresolvable",
1121
1463
  operand: candidate,
@@ -1123,19 +1465,20 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
1123
1465
  });
1124
1466
  continue;
1125
1467
  }
1468
+ const resolved = spellings.folded;
1126
1469
  if (withinAnyRoot(boundary.roots, resolved)) {
1127
1470
  if (!globbed)
1128
- findings.push({ kind: "inside", path: resolved });
1471
+ findings.push({ kind: "inside", path: resolved, ...(spellings.spelled !== undefined ? { spelled: spellings.spelled } : {}) });
1129
1472
  }
1130
1473
  else
1131
- findings.push({ kind: "outside", command: name, path: resolved });
1474
+ findings.push({ kind: "outside", command: name, path: resolved, ...(spellings.spelled !== undefined ? { spelled: spellings.spelled } : {}) });
1132
1475
  }
1133
1476
  for (const { text: operand, globbed } of bareWordOperands) {
1134
- const resolved = resolveOperandLexically(boundary.cwd ?? boundary.roots[0], operand, boundary.homeDir);
1135
- if (resolved === undefined)
1477
+ const spellings = resolveOperandSpellings(boundary.cwd ?? boundary.roots[0], operand, boundary.homeDir);
1478
+ if (spellings === undefined)
1136
1479
  continue;
1137
- if (!globbed && withinAnyRoot(boundary.roots, resolved))
1138
- 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 } : {}) });
1139
1482
  }
1140
1483
  if (boundary.denyMatch !== undefined && segmentSelectsRecursiveRead(name, args)) {
1141
1484
  const roots = positionalOperands.length > 0 ? positionalOperands : stdinOperand ? [] : ["."];
@@ -1208,7 +1551,7 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
1208
1551
  const split = segmentCompoundForReadonly(command, boundary?.homeDir);
1209
1552
  if ("reject" in split)
1210
1553
  return { reason: split.reject, refusedWhole: true };
1211
- const { segments, pipeFed } = split;
1554
+ const { segments, pipeFed, connectors } = split;
1212
1555
  let nameRefusal;
1213
1556
  const unlistedSegments = new Set();
1214
1557
  const reheaded = new Map();
@@ -1216,11 +1559,17 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
1216
1559
  const toks = tokenizeSegment(segments[si]);
1217
1560
  const folded = toks.folded;
1218
1561
  let from = 0;
1219
- 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] ?? "");
1220
1564
  from++;
1565
+ for (let span = launcherOptionSpan(controlWord, folded, from); span > 0; span = launcherOptionSpan(controlWord, folded, from))
1566
+ from += span;
1567
+ }
1221
1568
  const written = folded[from];
1222
1569
  if (written === undefined)
1223
1570
  return { reason: "empty command" };
1571
+ if (written === "coproc")
1572
+ return { reason: "`coproc` backgrounds a command — a backgrounded process outlives the command", refusedWhole: true };
1224
1573
  if (isBareControlWord(toks, from, SHELL_BLOCK_WORDS)) {
1225
1574
  unlistedSegments.add(si);
1226
1575
  nameRefusal ??= `command "${written}" is not in the read-only allowlist`;
@@ -1233,6 +1582,18 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
1233
1582
  const name = pathPrefixed ? parsed.name.slice(parsed.name.lastIndexOf("/") + 1) : parsed.name;
1234
1583
  if (pathPrefixed)
1235
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
+ }
1236
1597
  if (allow.has(name)) {
1237
1598
  if (pathPrefixed || from > 0)
1238
1599
  reheaded.set(si, { from, head: name });
@@ -1242,14 +1603,17 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
1242
1603
  }
1243
1604
  nameRefusal ??= `command "${parsed.name}" is not in the read-only allowlist`;
1244
1605
  if (SHELL_REENTRY_PROGRAM_RE.test(name))
1245
- 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 };
1246
1607
  if (COMMAND_LAUNCHERS.has(name)) {
1247
- const unwrapped = unwrapLauncher(folded.slice(from), allow);
1608
+ const unwrapped = launcherUnwrap ?? unwrapLauncher(folded.slice(from), allow);
1248
1609
  if (unwrapped.kind === "refused")
1249
1610
  return { reason: unwrapped.reason, refusedWhole: true };
1250
1611
  if (unwrapped.kind === "unlisted" && unwrapped.head !== undefined && SHELL_REENTRY_PROGRAM_RE.test(unwrapped.head)) {
1251
1612
  return { reason: `"${written}" runs "${unwrapped.head}", which runs its argument as shell text this walk does not judge ${NOT_AUTO_ALLOWED}`, refusedWhole: true };
1252
1613
  }
1614
+ if (argvReentryUnjudged(unwrapped)) {
1615
+ return { reason: `"${written}" takes the operands of the program it runs from its standard input, which this walk does not judge ${NOT_AUTO_ALLOWED}`, refusedWhole: true };
1616
+ }
1253
1617
  if (unwrapped.kind === "unlisted")
1254
1618
  unlistedSegments.add(si);
1255
1619
  else
@@ -1288,13 +1652,29 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
1288
1652
  };
1289
1653
  const STDIN_FILE_FLOOR = { cat: 1, grep: 2, head: 1, tail: 1, wc: 1, cut: 1, tr: Infinity, diff: 2, cmp: 2, comm: 2, sed: 2 };
1290
1654
  const foldedSegments = [];
1655
+ const foldedReach = [];
1656
+ let crossedAllAnd = true;
1657
+ let crossedAnyOr = false;
1291
1658
  let shapeRefusal = nameRefusal;
1292
1659
  for (let si = 0; si < segments.length; si++) {
1660
+ if (si > 0) {
1661
+ if (connectors[si] !== "&&")
1662
+ crossedAllAnd = false;
1663
+ if (connectors[si] === "||")
1664
+ crossedAnyOr = true;
1665
+ }
1666
+ const moveProvable = pipeFed[si] !== true && pipeFed[si + 1] !== true && reheaded.get(si) === undefined;
1667
+ const pushReach = () => {
1668
+ foldedReach.push({ andJoined: si > 0 && crossedAllAnd, orBroken: !crossedAllAnd && crossedAnyOr, moveProvable });
1669
+ crossedAllAnd = true;
1670
+ crossedAnyOr = false;
1671
+ };
1293
1672
  if (unlistedSegments.has(si)) {
1294
1673
  const unlisted = tokenizeSegment(segments[si]);
1295
1674
  if (unlisted.folded.length === 0)
1296
1675
  continue;
1297
1676
  foldedSegments.push({ folded: [LAUNCHER_SCAN_HEAD, ...unlisted.folded], raw: [LAUNCHER_SCAN_HEAD, ...unlisted.raw] });
1677
+ pushReach();
1298
1678
  continue;
1299
1679
  }
1300
1680
  const rehead = reheaded.get(si);
@@ -1303,6 +1683,7 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
1303
1683
  if (toks.length === 0)
1304
1684
  continue;
1305
1685
  foldedSegments.push(segmentTokens);
1686
+ pushReach();
1306
1687
  const name = toks[0];
1307
1688
  const guardReason = guardedVerbFormReason(name, toks, segmentTokens.raw);
1308
1689
  if (guardReason !== undefined) {
@@ -1383,7 +1764,7 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
1383
1764
  shapeRefusal ??= `multiple directory changes in one command require approval for clarity ${NOT_AUTO_ALLOWED}`;
1384
1765
  }
1385
1766
  if (boundary !== undefined) {
1386
- const walk = evaluateReadBoundary(foldedSegments, boundary);
1767
+ const walk = evaluateReadBoundary(foldedSegments, boundary, foldedReach);
1387
1768
  if (shapeRefusal === undefined)
1388
1769
  return walk;
1389
1770
  const { reason: _walkSentence, ...structure } = walk;
@@ -1396,90 +1777,171 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
1396
1777
  }
1397
1778
  return {};
1398
1779
  }
1399
- function evaluateReadBoundary(foldedSegments, boundary) {
1780
+ const BASE_CANDIDATE_MAX = 256;
1781
+ function evaluateReadBoundary(foldedSegments, boundary, reach) {
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() };
1400
1783
  const outside = [];
1401
1784
  const inside = [];
1402
1785
  let unresolvable;
1403
1786
  const unresolved = [];
1404
1787
  let baseLost = false;
1405
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
+ };
1406
1798
  const recursive = [];
1407
- let currentBase = boundary.cwd ?? boundary.roots[0];
1408
- for (const toks of foldedSegments) {
1799
+ let candidates = [{ base: boundary.cwd ?? boundary.roots[0], skippedCd: -1 }];
1800
+ let andRunStart = 0;
1801
+ const findingsByWords = new Map();
1802
+ const findingsRowFor = (wordsKey) => {
1803
+ const row = findingsByWords.get(wordsKey);
1804
+ if (row !== undefined)
1805
+ return row;
1806
+ const fresh = new Map();
1807
+ findingsByWords.set(wordsKey, fresh);
1808
+ return fresh;
1809
+ };
1810
+ const findingsFor = (row, toks, base) => {
1811
+ const hit = row.get(base);
1812
+ if (hit !== undefined)
1813
+ return hit;
1814
+ const segBoundary = base === (boundary.cwd ?? boundary.roots[0]) ? boundary : { ...boundary, cwd: base };
1815
+ const computed = collectSegmentBoundaryFindings(toks, segBoundary);
1816
+ row.set(base, computed);
1817
+ return computed;
1818
+ };
1819
+ for (let si = 0; si < foldedSegments.length; si++) {
1820
+ const toks = foldedSegments[si];
1409
1821
  if (baseLost)
1410
1822
  break;
1411
- const segBoundary = currentBase === (boundary.cwd ?? boundary.roots[0]) ? boundary : { ...boundary, cwd: currentBase };
1412
- for (const finding of collectSegmentBoundaryFindings(toks, segBoundary)) {
1413
- if (finding.kind === "unresolvable") {
1414
- unresolvable ??= finding.reason;
1415
- if (!unresolved.includes(finding.operand))
1416
- unresolved.push(finding.operand);
1417
- if (toks.folded[0] === "cd")
1418
- baseLost = true;
1419
- continue;
1420
- }
1421
- const denied = boundary.denyMatch?.(finding.path);
1422
- if (denied != null) {
1423
- return {
1424
- reason: `a command operand resolves to "${finding.path}", which matches the sensitive-path read deny list (pattern "${denied}") ${NOT_AUTO_ALLOWED}`,
1425
- readDenied: true,
1426
- };
1427
- }
1428
- if (finding.kind === "inside") {
1429
- if (!inside.includes(finding.path))
1430
- inside.push(finding.path);
1431
- continue;
1432
- }
1433
- if (finding.kind === "undecided") {
1434
- if (!undecided.includes(finding.path))
1435
- undecided.push(finding.path);
1436
- continue;
1437
- }
1438
- if (finding.kind === "recursive") {
1439
- if (!recursive.includes(finding.path))
1440
- recursive.push(finding.path);
1441
- continue;
1442
- }
1443
- if (boundary.face === "open") {
1444
- if (!inside.includes(finding.path))
1445
- inside.push(finding.path);
1446
- continue;
1823
+ if (si > 0 && reach?.[si]?.andJoined !== true)
1824
+ andRunStart = reach?.[si]?.orBroken === true ? si + 1 : si;
1825
+ const reachable = candidates.filter((c) => c.skippedCd < andRunStart);
1826
+ const row = findingsRowFor(JSON.stringify([toks.folded, toks.raw]));
1827
+ for (const { base: currentBase } of reachable) {
1828
+ for (const finding of findingsFor(row, toks, currentBase)) {
1829
+ if (finding.kind === "unresolvable") {
1830
+ unresolvable ??= finding.reason;
1831
+ if (!seen.unresolved.has(finding.operand)) {
1832
+ seen.unresolved.add(finding.operand);
1833
+ unresolved.push(finding.operand);
1834
+ }
1835
+ if (toks.folded[0] === "cd")
1836
+ baseLost = true;
1837
+ continue;
1838
+ }
1839
+ const denied = boundary.denyMatch?.(finding.path);
1840
+ if (denied != null) {
1841
+ return {
1842
+ reason: `a command operand resolves to "${finding.path}", which matches the sensitive-path read deny list (pattern "${denied}") ${NOT_AUTO_ALLOWED}`,
1843
+ readDenied: true,
1844
+ };
1845
+ }
1846
+ if (finding.kind === "inside") {
1847
+ if (!seen.inside.has(finding.path)) {
1848
+ seen.inside.add(finding.path);
1849
+ inside.push(finding.path);
1850
+ }
1851
+ recordSpelling(checkedSpellings, seen.checkedSpelled, finding);
1852
+ continue;
1853
+ }
1854
+ if (finding.kind === "undecided") {
1855
+ if (!seen.undecided.has(finding.path)) {
1856
+ seen.undecided.add(finding.path);
1857
+ undecided.push(finding.path);
1858
+ }
1859
+ recordSpelling(undecidedSpellings, seen.undecidedSpelled, finding);
1860
+ continue;
1861
+ }
1862
+ if (finding.kind === "recursive") {
1863
+ if (!seen.recursive.has(finding.path)) {
1864
+ seen.recursive.add(finding.path);
1865
+ recursive.push(finding.path);
1866
+ }
1867
+ continue;
1868
+ }
1869
+ if (boundary.face === "open") {
1870
+ if (!seen.inside.has(finding.path)) {
1871
+ seen.inside.add(finding.path);
1872
+ inside.push(finding.path);
1873
+ }
1874
+ continue;
1875
+ }
1876
+ if (!seen.outside.has(finding.path)) {
1877
+ seen.outside.add(finding.path);
1878
+ outside.push(finding);
1879
+ }
1880
+ recordSpelling(outOfRootSpellings, seen.outsideSpelled, finding);
1447
1881
  }
1448
- if (!outside.some((o) => o.path === finding.path))
1449
- outside.push(finding);
1450
1882
  }
1451
1883
  if (!baseLost && toks.folded[0] === "cd") {
1452
1884
  const cdArgs = toks.folded.slice(1);
1453
1885
  const cdTargetIdx = cdArgs.findIndex((t) => !t.startsWith("-") || t === "-");
1454
1886
  const cdTarget = cdTargetIdx === -1 ? undefined : cdArgs[cdTargetIdx];
1455
1887
  if (cdTarget !== undefined && cdTarget !== "-") {
1456
- currentBase = resolveOperandLexically(currentBase, cdTarget, boundary.homeDir) ?? currentBase;
1888
+ const provable = reach?.[si]?.moveProvable === true;
1889
+ const branched = [];
1890
+ for (const c of candidates) {
1891
+ if (c.skippedCd < andRunStart)
1892
+ branched.push({ base: resolveOperandLexically(c.base, cdTarget, boundary.homeDir) ?? c.base, skippedCd: c.skippedCd });
1893
+ branched.push({ base: c.base, skippedCd: provable ? si : c.skippedCd });
1894
+ }
1895
+ const byBase = new Map();
1896
+ for (const c of branched) {
1897
+ const prior = byBase.get(c.base);
1898
+ if (prior === undefined || c.skippedCd < prior.skippedCd)
1899
+ byBase.set(c.base, c);
1900
+ }
1901
+ const next = [...byBase.values()];
1902
+ if (next.length > BASE_CANDIDATE_MAX) {
1903
+ unresolvable ??= `too many directory changes to track where "${cdTarget}" leaves the working directory ${NOT_AUTO_ALLOWED}`;
1904
+ if (!seen.unresolved.has(cdTarget)) {
1905
+ seen.unresolved.add(cdTarget);
1906
+ unresolved.push(cdTarget);
1907
+ }
1908
+ baseLost = true;
1909
+ }
1910
+ else
1911
+ candidates = next;
1457
1912
  }
1458
1913
  }
1459
1914
  }
1460
- const undecidedAll = [...undecided, ...recursive.filter((p) => !undecided.includes(p))];
1915
+ const undecidedAll = [...undecided, ...recursive.filter((p) => !seen.undecided.has(p))];
1461
1916
  const undecidedField = {
1462
1917
  ...(undecidedAll.length > 0 ? { undecidedPaths: undecidedAll } : {}),
1463
1918
  ...(recursive.length > 0 ? { recursiveReadPaths: recursive } : {}),
1919
+ ...(undecidedSpellings.length > 0 ? { undecidedSpellings } : {}),
1464
1920
  };
1921
+ const checkedField = {
1922
+ ...(inside.length > 0 ? { checkedPaths: inside } : {}),
1923
+ ...(checkedSpellings.length > 0 ? { checkedSpellings } : {}),
1924
+ };
1925
+ const outsideField = outOfRootSpellings.length > 0 ? { outOfRootSpellings } : {};
1465
1926
  if (unresolvable !== undefined) {
1466
1927
  return {
1467
1928
  reason: unresolvable,
1468
1929
  unresolvedOperands: unresolved,
1469
- ...(outside.length > 0 ? { outOfRootRead: true, outOfRootPaths: outside.map((o) => o.path) } : {}),
1470
- ...(inside.length > 0 ? { checkedPaths: inside } : {}),
1930
+ ...(outside.length > 0 ? { outOfRootRead: true, outOfRootPaths: outside.map((o) => o.path), ...outsideField } : {}),
1931
+ ...checkedField,
1471
1932
  ...undecidedField,
1472
1933
  };
1473
1934
  }
1474
1935
  if (outside.length === 0)
1475
- return inside.length > 0 ? { checkedPaths: inside, ...undecidedField } : { ...undecidedField };
1936
+ return { ...checkedField, ...undecidedField };
1476
1937
  const paths = outside.map((o) => `"${o.path}"`).join(", ");
1477
1938
  const allowed = boundary.roots.length > 0 ? boundary.roots.join(", ") : "(none)";
1478
1939
  return {
1479
1940
  reason: `"${outside[0].command}" reads ${paths}, outside the allowed directories for this session: ${allowed} ${NOT_AUTO_ALLOWED}`,
1480
1941
  outOfRootRead: true,
1481
1942
  outOfRootPaths: outside.map((o) => o.path),
1482
- ...(inside.length > 0 ? { checkedPaths: inside } : {}),
1943
+ ...outsideField,
1944
+ ...checkedField,
1483
1945
  ...undecidedField,
1484
1946
  };
1485
1947
  }
@@ -1487,6 +1949,19 @@ export function classifySimpleCommandReadBoundary(command, boundary) {
1487
1949
  const toks = tokenizeSegment(command);
1488
1950
  if (toks.folded.length === 0)
1489
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
+ }
1490
1965
  return evaluateReadBoundary([toks], boundary);
1491
1966
  }
1492
1967
  const NOT_GATED = { gated: false, paths: [] };
@@ -1504,7 +1979,7 @@ const COMMAND_LAUNCHERS = new Set([
1504
1979
  "env", "command", "builtin", "exec", "noglob", "xargs",
1505
1980
  "sudo", "doas", "pkexec", "su", "runuser", "chroot", "setpriv",
1506
1981
  "nohup", "nice", "ionice", "chrt", "taskset", "stdbuf", "timeout", "time", "watch",
1507
- "setsid", "flock", "unshare", "nsenter", "script", "numactl", "prlimit", "systemd-run", "parallel",
1982
+ "setsid", "flock", "unshare", "nsenter", "script", "numactl", "prlimit", "systemd-run",
1508
1983
  "strace", "ltrace", "valgrind", "firejail", "bwrap",
1509
1984
  ]);
1510
1985
  export const COMMAND_LAUNCHERS_FOR_TEST = COMMAND_LAUNCHERS;
@@ -1524,8 +1999,25 @@ export function classifyOutOfRootReadGate(command, allow, boundary) {
1524
1999
  if ("reject" in split)
1525
2000
  return NOT_GATED;
1526
2001
  const readers = [];
2002
+ const readerReach = [];
2003
+ let crossedAllAnd = true;
2004
+ let crossedAnyOr = false;
1527
2005
  let baseMoved = false;
1528
- for (const segment of split.segments) {
2006
+ for (let si = 0; si < split.segments.length; si++) {
2007
+ const segment = split.segments[si];
2008
+ if (si > 0) {
2009
+ if (split.connectors[si] !== "&&")
2010
+ crossedAllAnd = false;
2011
+ if (split.connectors[si] === "||")
2012
+ crossedAnyOr = true;
2013
+ }
2014
+ const moveProvable = split.pipeFed[si] !== true && split.pipeFed[si + 1] !== true;
2015
+ const pushReader = (st) => {
2016
+ readers.push(st);
2017
+ readerReach.push({ andJoined: si > 0 && crossedAllAnd, orBroken: !crossedAllAnd && crossedAnyOr, moveProvable });
2018
+ crossedAllAnd = true;
2019
+ crossedAnyOr = false;
2020
+ };
1529
2021
  const toks = tokenizeSegment(segment);
1530
2022
  if (toks.folded.length === 0)
1531
2023
  continue;
@@ -1539,16 +2031,16 @@ export function classifyOutOfRootReadGate(command, allow, boundary) {
1539
2031
  if (UNMODELLED_BASE_MOVERS.has(name))
1540
2032
  baseMoved = true;
1541
2033
  if (!allow.has(name) && name !== "cd") {
1542
- readers.push({ folded: [LAUNCHER_SCAN_HEAD, ...toks.folded.slice(i)], raw: [LAUNCHER_SCAN_HEAD, ...toks.raw.slice(i)] });
2034
+ pushReader({ folded: [LAUNCHER_SCAN_HEAD, ...toks.folded.slice(i)], raw: [LAUNCHER_SCAN_HEAD, ...toks.raw.slice(i)] });
1543
2035
  continue;
1544
2036
  }
1545
- readers.push(i === 0 && name === written ? toks : { folded: [name, ...toks.folded.slice(i + 1)], raw: [toks.raw[i] ?? name, ...toks.raw.slice(i + 1)] });
2037
+ pushReader(i === 0 && name === written ? toks : { folded: [name, ...toks.folded.slice(i + 1)], raw: [toks.raw[i] ?? name, ...toks.raw.slice(i + 1)] });
1546
2038
  }
1547
2039
  if (readers.length === 0)
1548
2040
  return NOT_GATED;
1549
2041
  if (baseMoved)
1550
2042
  return { gated: true, paths: [] };
1551
- const verdict = evaluateReadBoundary(readers, containmentOnly);
2043
+ const verdict = evaluateReadBoundary(readers, containmentOnly, readerReach);
1552
2044
  return verdict.outOfRootRead === true ? { gated: true, paths: verdict.outOfRootPaths ?? [] } : NOT_GATED;
1553
2045
  };
1554
2046
  const direct = judge(command);