@sema-agent/core 7.17.7 → 7.18.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 (163) hide show
  1. package/CHANGELOG.md +140 -13
  2. package/dist/agents/send-message-tool.js +1 -0
  3. package/dist/agents/subagent.d.ts +1 -0
  4. package/dist/agents/subagent.js +5 -0
  5. package/dist/agents/teacher.js +1 -0
  6. package/dist/agents/verify.js +3 -0
  7. package/dist/brain/route-adjudicator.d.ts +18 -0
  8. package/dist/brain/route-adjudicator.js +16 -0
  9. package/dist/core/agent-definition.d.ts +10 -0
  10. package/dist/core/artifact-host.d.ts +41 -4
  11. package/dist/core/artifact-host.js +18 -2
  12. package/dist/core/auto-mode.d.ts +141 -45
  13. package/dist/core/auto-mode.js +109 -3
  14. package/dist/core/checkpoint-store.d.ts +16 -2
  15. package/dist/core/engine-notice.d.ts +47 -1
  16. package/dist/core/engine-notice.js +7 -0
  17. package/dist/core/gate-lanes.js +2 -1
  18. package/dist/core/gate-outcome.d.ts +17 -2
  19. package/dist/core/gate-outcome.js +16 -0
  20. package/dist/core/governance-codes.d.ts +1 -1
  21. package/dist/core/governance-codes.js +8 -0
  22. package/dist/core/hook-seats.d.ts +66 -0
  23. package/dist/core/hook-seats.js +37 -0
  24. package/dist/core/runner/advertised-writable-dirs.d.ts +8 -3
  25. package/dist/core/runner/assemble-result.d.ts +2 -1
  26. package/dist/core/runner/assemble-result.js +1 -1
  27. package/dist/core/runner/classifier-ladder.d.ts +89 -0
  28. package/dist/core/runner/classifier-ladder.js +65 -0
  29. package/dist/core/runner/contracts.d.ts +69 -13
  30. package/dist/core/runner/decide-continuation.d.ts +1 -3
  31. package/dist/core/runner/decide-continuation.js +1 -7
  32. package/dist/core/runner/deferral-census.d.ts +52 -0
  33. package/dist/core/runner/deferral-census.js +44 -0
  34. package/dist/core/runner/derived-route-fallback.d.ts +8 -4
  35. package/dist/core/runner/derived-route-fallback.js +8 -3
  36. package/dist/core/runner/gate-exit.d.ts +12 -0
  37. package/dist/core/runner/gate-exit.js +6 -3
  38. package/dist/core/runner/prepare-artifact.d.ts +10 -2
  39. package/dist/core/runner/prepare-artifact.js +5 -4
  40. package/dist/core/runner/prepare-caps-and-workflow.d.ts +4 -2
  41. package/dist/core/runner/prepare-caps-and-workflow.js +30 -31
  42. package/dist/core/runner/prepare-config-doors.d.ts +1 -1
  43. package/dist/core/runner/prepare-config-doors.js +5 -3
  44. package/dist/core/runner/prepare-defer-classify.d.ts +13 -19
  45. package/dist/core/runner/prepare-defer-classify.js +8 -3
  46. package/dist/core/runner/prepare-gate-stations.d.ts +26 -0
  47. package/dist/core/runner/prepare-gate-stations.js +1 -1
  48. package/dist/core/runner/prepare-hands-readface.d.ts +24 -7
  49. package/dist/core/runner/prepare-hands-readface.js +60 -24
  50. package/dist/core/runner/prepare-inherited-gate.d.ts +17 -4
  51. package/dist/core/runner/prepare-inherited-gate.js +10 -2
  52. package/dist/core/runner/prepare-lsp.d.ts +2 -2
  53. package/dist/core/runner/prepare-lsp.js +1 -1
  54. package/dist/core/runner/prepare-policy-chain.js +2 -0
  55. package/dist/core/runner/prepare-project-context.js +4 -2
  56. package/dist/core/runner/prepare-prompt-inputs.d.ts +4 -2
  57. package/dist/core/runner/prepare-prompt-inputs.js +4 -1
  58. package/dist/core/runner/prepare-run-refs.d.ts +9 -1
  59. package/dist/core/runner/prepare-run-refs.js +3 -2
  60. package/dist/core/runner/prepare-suspend-saga.d.ts +5 -0
  61. package/dist/core/runner/prepare-suspend-saga.js +5 -2
  62. package/dist/core/runner/prepare-task.js +18 -13
  63. package/dist/core/runner/prepare-tool-disclosure-mount.d.ts +5 -0
  64. package/dist/core/runner/prepare-tool-disclosure-mount.js +10 -2
  65. package/dist/core/runner/prepare-turn-wiring.d.ts +8 -4
  66. package/dist/core/runner/prepare-turn-wiring.js +3 -2
  67. package/dist/core/runner/prepare-wiring-manifest.d.ts +5 -5
  68. package/dist/core/runner/prepare-wiring-manifest.js +5 -2
  69. package/dist/core/runner/resume-checkpoint-screen.js +7 -0
  70. package/dist/core/runner/run-harness-handlers.js +4 -1
  71. package/dist/core/runner/run-identity-wiring.js +6 -2
  72. package/dist/core/runner/run-leg.d.ts +2 -0
  73. package/dist/core/runner/run-leg.js +35 -41
  74. package/dist/core/runner/run-notification-lane.js +55 -27
  75. package/dist/core/runner/run-settle-and-teardown.js +1 -0
  76. package/dist/core/runner/run-terminal-adoption.js +16 -13
  77. package/dist/core/runner/run-turn-boundary.js +3 -1
  78. package/dist/core/runner/runtask.d.ts +6 -5
  79. package/dist/core/runner/runtask.js +26 -14
  80. package/dist/core/runner/skill-base-dir.d.ts +54 -0
  81. package/dist/core/runner/skill-base-dir.js +42 -0
  82. package/dist/core/runner/synthetic-tools.d.ts +16 -19
  83. package/dist/core/runner/synthetic-tools.js +18 -10
  84. package/dist/core/runner/tool-defer-gate.d.ts +22 -9
  85. package/dist/core/runner/tool-defer-gate.js +15 -5
  86. package/dist/core/runner/tool-disclosure.d.ts +5 -0
  87. package/dist/core/runner/tool-disclosure.js +2 -0
  88. package/dist/core/runner/tool-progress-lane.d.ts +42 -0
  89. package/dist/core/runner/tool-progress-lane.js +61 -0
  90. package/dist/core/runner-deps.d.ts +48 -7
  91. package/dist/core/swappable-deps.d.ts +9 -7
  92. package/dist/core/swappable-deps.js +7 -3
  93. package/dist/core/task-event.d.ts +96 -29
  94. package/dist/core/task-notification.d.ts +142 -16
  95. package/dist/core/task-notification.js +106 -0
  96. package/dist/core/task-result.d.ts +9 -1
  97. package/dist/core/task-spec.d.ts +20 -3
  98. package/dist/core/tighten-task-spec.js +7 -5
  99. package/dist/core/tool-model-gate.d.ts +1 -1
  100. package/dist/core/tool-search-setting.d.ts +78 -0
  101. package/dist/core/tool-search-setting.js +90 -0
  102. package/dist/core/tool-spec.d.ts +8 -0
  103. package/dist/core/types.d.ts +1 -1
  104. package/dist/core/types.js +1 -1
  105. package/dist/core/wiring-manifest.d.ts +46 -0
  106. package/dist/core/wiring-manifest.js +9 -1
  107. package/dist/engine/execution-env/node-execution-env.js +10 -4
  108. package/dist/engine/harness/agent-harness.d.ts +42 -0
  109. package/dist/engine/harness/agent-harness.js +61 -29
  110. package/dist/engine/loop/agent-loop.d.ts +68 -3
  111. package/dist/engine/loop/agent-loop.js +109 -16
  112. package/dist/index.d.ts +10 -5
  113. package/dist/index.js +9 -5
  114. package/dist/internal/harness.d.ts +1 -1
  115. package/dist/orchestration/run-workflow-tool.d.ts +3 -0
  116. package/dist/orchestration/run-workflow-tool.js +6 -1
  117. package/dist/orchestration/workflow-governance.d.ts +7 -1
  118. package/dist/orchestration/workflow-governance.js +9 -5
  119. package/dist/orchestration/workflow-primitives.d.ts +3 -0
  120. package/dist/orchestration/workflow-primitives.js +6 -1
  121. package/dist/orchestration/workflow-types.d.ts +48 -13
  122. package/dist/orchestration/workflow-types.js +1 -1
  123. package/dist/orchestration/workflow.js +45 -17
  124. package/dist/prompt-assembly/epoch.js +2 -0
  125. package/dist/prompt-assembly/packs/sema-default.js +2 -2
  126. package/dist/prompt-assembly/section-usage.d.ts +26 -0
  127. package/dist/prompt-assembly/section-usage.js +10 -0
  128. package/dist/prompt-assembly/types.d.ts +8 -6
  129. package/dist/prompts/default.d.ts +13 -0
  130. package/dist/prompts/default.js +16 -8
  131. package/dist/tools/artifact/artifact-text.d.ts +10 -1
  132. package/dist/tools/artifact/artifact-text.js +6 -3
  133. package/dist/tools/artifact/artifact-tool.d.ts +12 -2
  134. package/dist/tools/artifact/artifact-tool.js +203 -70
  135. package/dist/tools/artifact/conformance.d.ts +56 -0
  136. package/dist/tools/artifact/conformance.js +349 -0
  137. package/dist/tools/artifact/local-stub.js +1 -1
  138. package/dist/tools/artifact/reject-breaker.d.ts +28 -0
  139. package/dist/tools/artifact/reject-breaker.js +42 -0
  140. package/dist/tools/fs/bash-lexer.d.ts +132 -0
  141. package/dist/tools/fs/bash-lexer.js +563 -0
  142. package/dist/tools/fs/bash-program-position.d.ts +142 -0
  143. package/dist/tools/fs/bash-program-position.js +125 -0
  144. package/dist/tools/fs/bash-readonly-classifier.d.ts +54 -16
  145. package/dist/tools/fs/bash-readonly-classifier.js +121 -552
  146. package/dist/tools/fs/containment-face.d.ts +89 -0
  147. package/dist/tools/fs/containment-face.js +81 -0
  148. package/dist/tools/fs/fs-bash.d.ts +46 -1
  149. package/dist/tools/fs/fs-bash.js +158 -22
  150. package/dist/tools/fs/fs-read.d.ts +1 -1
  151. package/dist/tools/fs/fs-search-tools.d.ts +3 -3
  152. package/dist/tools/fs/fs-write.d.ts +4 -3
  153. package/dist/tools/fs/fs-write.js +9 -9
  154. package/dist/tools/fs/index.d.ts +24 -5
  155. package/dist/tools/fs/index.js +18 -6
  156. package/dist/tools/fs/repo-map.d.ts +2 -2
  157. package/dist/tools/fs/safety.d.ts +3 -3
  158. package/dist/tools/fs/safety.js +4 -4
  159. package/package.json +1 -1
  160. package/test/export-surface.snapshot.json +85 -9
  161. package/wire-changes/core@7.17.7.json +49 -0
  162. package/dist/tools/fs/read-face.d.ts +0 -68
  163. package/dist/tools/fs/read-face.js +0 -47
@@ -1,5 +1,7 @@
1
1
  import { isAbsoluteForFamily, isAbsolutePathForm, isBlockedDevicePath, isShellRootedSpellingUnmapped, joinForFamily, nativeUncSpellingOf, normalizeAbsPathLexically, win32NamespaceScreen, pathFamilyOf, withinAnyRoot } from "./safety.js";
2
- export const NOT_AUTO_ALLOWED = "— not auto-allowed";
2
+ import { lexShellText, literalTildeSpelling, quoteMask, substitutionsInHeredocBody, SUBSTITUTION_PLACEHOLDER, wordsOf, } from "./bash-lexer.js";
3
+ import { ASSIGNMENT_WORD, COMMAND_LAUNCHERS, NOT_AUTO_ALLOWED, SHELL_BLOCK_WORDS, argvReentryUnjudged, isBareControlWord, programBasename, programPositionRefusalSentence, readProgramPosition, } from "./bash-program-position.js";
4
+ export { NOT_AUTO_ALLOWED } from "./bash-program-position.js";
3
5
  export const BASH_READONLY_DEFAULT_ALLOW = [
4
6
  "ls", "cat", "head", "tail", "wc", "pwd", "echo", "whoami", "uname",
5
7
  "grep", "cut", "tr", "basename", "dirname", "stat", "du", "df", "which",
@@ -8,24 +10,6 @@ export const BASH_READONLY_DEFAULT_ALLOW = [
8
10
  ];
9
11
  export const BASH_CLASSIFY_DEFAULT_ALLOW = [...BASH_READONLY_DEFAULT_ALLOW, "find", "sed", "cd"];
10
12
  const SHELL_OPERATORS = /[;&|<>$()`\n\r\\]/;
11
- function quoteMask(s) {
12
- const quoted = new Array(s.length).fill(false);
13
- let open;
14
- for (let i = 0; i < s.length; i++) {
15
- const ch = s[i];
16
- if (open === undefined && (ch === '"' || ch === "'")) {
17
- open = ch;
18
- quoted[i] = true;
19
- continue;
20
- }
21
- if (open !== undefined) {
22
- quoted[i] = true;
23
- if (open === ch)
24
- open = undefined;
25
- }
26
- }
27
- return { quoted, balanced: open === undefined };
28
- }
29
13
  function hasBareShellOperator(s, quotedOperatorsAreText) {
30
14
  if (!quotedOperatorsAreText)
31
15
  return SHELL_OPERATORS.test(s);
@@ -108,48 +92,11 @@ export function splitShellCompoundSegments(source, options) {
108
92
  return { reject: "redirection, command/variable substitution, subshells, escapes, and line breaks are not allowed" };
109
93
  }
110
94
  const scanned = options?.trailingTerminator !== "keep" ? source.replace(/;\s*$/, "") : source;
111
- const segments = [];
112
- const connectors = ["start"];
113
- const mask = quoteMask(scanned);
114
- const quoted = (i) => mask.balanced && mask.quoted[i] === true;
115
- let cur = "";
116
- for (let i = 0; i < scanned.length; i++) {
117
- const c = scanned[i];
118
- if (quoted(i)) {
119
- cur += c;
120
- }
121
- else if (c === "&") {
122
- if (scanned[i + 1] === "&") {
123
- segments.push(cur);
124
- cur = "";
125
- connectors.push("&&");
126
- i++;
127
- }
128
- else
129
- return { reject: "`&` backgrounding is not allowed — a backgrounded process outlives the command" };
130
- }
131
- else if (c === "|") {
132
- if (scanned[i + 1] === "|") {
133
- segments.push(cur);
134
- cur = "";
135
- connectors.push("||");
136
- i++;
137
- }
138
- else {
139
- segments.push(cur);
140
- cur = "";
141
- connectors.push("|");
142
- }
143
- }
144
- else if (c === ";") {
145
- segments.push(cur);
146
- cur = "";
147
- connectors.push(";");
148
- }
149
- else
150
- cur += c;
151
- }
152
- segments.push(cur);
95
+ const lexed = lexShellText(scanned, { mode: "strict" });
96
+ if ("reject" in lexed)
97
+ return lexed;
98
+ const segments = lexed.segments.map((s) => s.text);
99
+ const connectors = lexed.segments.map((s) => s.connector);
153
100
  return { segments, pipeFed: connectors.map((k) => k === "|"), connectors };
154
101
  }
155
102
  const EXEMPT_REDIRECTION = /^(?:2>\/dev\/null|[12]?>&[12])$/;
@@ -181,24 +128,6 @@ function stripExemptRedirections(command) {
181
128
  flush();
182
129
  return out;
183
130
  }
184
- function foldQuoteRemovalToken(tok) {
185
- if (!tok.includes('"') && !tok.includes("'"))
186
- return tok;
187
- let out = "";
188
- let open;
189
- for (const ch of tok) {
190
- if (open === undefined && (ch === '"' || ch === "'")) {
191
- open = ch;
192
- continue;
193
- }
194
- if (open === ch) {
195
- open = undefined;
196
- continue;
197
- }
198
- out += ch;
199
- }
200
- return out;
201
- }
202
131
  const POLL_LOOP_CAUSE_MAX_PATHS = 3;
203
132
  export function formatOutOfRootReadApprovalOption(directory) {
204
133
  const trimmed = directory.replace(/[/\\]+$/, "");
@@ -245,36 +174,25 @@ function spellingsOf(absolute) {
245
174
  function reheadSegment(toks, head, from = 0) {
246
175
  return { folded: [head, ...toks.folded.slice(from + 1)], raw: [head, ...toks.raw.slice(from + 1)] };
247
176
  }
248
- const SHELL_CONTROL_PREFIXES = new Set(["!", "if", "then", "elif", "else", "while", "until", "do", "time"]);
249
- const SHELL_BLOCK_WORDS = new Set(["for", "select", "case", "esac", "fi", "done", "function", "coproc", "[[", "]]", "in"]);
250
- function isBareControlWord(toks, i, words) {
251
- const word = toks.folded[i];
252
- return word !== undefined && toks.raw[i] === word && words.has(word);
253
- }
254
- function tokensMoveBase(toks, allow) {
255
- let from = 0;
256
- while (from < toks.folded.length && isBareControlWord(toks, from, SHELL_CONTROL_PREFIXES)) {
257
- const controlWord = programBasename(toks.folded[from] ?? "");
258
- from++;
259
- for (let span = launcherOptionSpan(controlWord, toks.folded, from); span > 0; span = launcherOptionSpan(controlWord, toks.folded, from))
260
- from += span;
261
- }
262
- const head = toks.folded[from];
263
- if (head === undefined)
177
+ function tokensMoveBase(toks) {
178
+ const pos = readProgramPosition(toks);
179
+ if (pos.written === undefined)
264
180
  return false;
265
- if (toks.raw[from] !== head)
266
- return true;
267
- if (head.startsWith("-") && head !== "-")
268
- return true;
269
- if (head === "cd")
181
+ const written = toks.folded[pos.written];
182
+ if (toks.raw[pos.written] !== written)
270
183
  return true;
271
- if (!COMMAND_LAUNCHERS.has(head))
184
+ if (pos.assignments.length > 0)
272
185
  return false;
273
- const unwrapped = unwrapLauncher(toks.folded.slice(from), allow);
274
- return unwrapped.kind === "refused" || (unwrapped.kind === "reader" && unwrapped.head === "cd");
186
+ if (pos.launchers.length === 0) {
187
+ if (written.startsWith("-") && written !== "-")
188
+ return true;
189
+ return written === "cd";
190
+ }
191
+ if (!COMMAND_LAUNCHERS.has(written))
192
+ return false;
193
+ return pos.refusal !== undefined || pos.program?.basename === "cd";
275
194
  }
276
195
  const SHELL_REENTRY_PROGRAM_RE = /^(?:(?:ba|z|da|k|fi|a|mk|ya|pdk|c|tc)?sh|eval|source|\.|parallel)$/;
277
- const ARGV_REENTRY_PROGRAM_RE = /^xargs$/;
278
196
  export function refusedCommandMayRead(command, allow, boundary) {
279
197
  return !refusedCommandExempt(command, allow, boundary, 0);
280
198
  }
@@ -282,249 +200,49 @@ const STDIN_ONLY_READER_FLAGS = new Map([
282
200
  ["cat", /^-[AbeEnstTuv]+$/],
283
201
  ["wc", /^-[clmwL]+$/],
284
202
  ]);
285
- const SUBSTITUTION_PLACEHOLDER = "\u0000subst";
286
- function withoutHeredocBodies(text) {
287
- const lines = text.split("\n");
288
- const out = [];
289
- const expandedBodies = [];
290
- let terminator;
291
- let expanded = false;
292
- let body = [];
293
- for (const line of lines) {
294
- if (terminator !== undefined) {
295
- if (line.trim() === terminator) {
296
- if (expanded)
297
- expandedBodies.push(body.join("\n"));
298
- terminator = undefined;
299
- body = [];
300
- }
301
- else
302
- body.push(line);
303
- continue;
304
- }
305
- out.push(line);
306
- const header = heredocHeaderOf(line);
307
- if (header === null)
308
- return undefined;
309
- if (header !== undefined) {
310
- terminator = header.word;
311
- expanded = !header.quoted;
312
- }
313
- }
314
- return terminator === undefined ? { outer: out.join("\n"), expandedBodies } : undefined;
315
- }
316
- function heredocHeaderOf(line) {
317
- let inSingle = false;
318
- let inDouble = false;
319
- for (let i = 0; i < line.length; i++) {
320
- const ch = line[i];
321
- if (ch === "\\" && !inSingle) {
322
- i++;
323
- continue;
324
- }
325
- if (ch === "'" && !inDouble) {
326
- inSingle = !inSingle;
327
- continue;
328
- }
329
- if (ch === '"' && !inSingle) {
330
- inDouble = !inDouble;
331
- continue;
332
- }
333
- if (inSingle || inDouble)
334
- continue;
335
- if (ch === "#")
336
- return undefined;
337
- if (ch === "<" && line[i + 1] === "<") {
338
- if (line[i + 2] === "<") {
339
- i += 2;
340
- continue;
341
- }
342
- const m = /^<<-?\s*([^\s;|&<>()]+)/.exec(line.slice(i));
343
- if (m === null)
344
- return null;
345
- const word = m[1];
346
- if (/[$`]/.test(word))
347
- return null;
348
- const delimiter = word.replace(/\\(.)/g, "$1").replace(/["']/g, "");
349
- if (delimiter === "")
350
- return null;
351
- return { word: delimiter, quoted: /["'\\]/.test(word) };
352
- }
353
- }
354
- return undefined;
355
- }
356
- function splitSubstitutions(text, quotesAreLiteral = false) {
357
- let outer = "";
358
- const bodies = [];
359
- let i = 0;
360
- let inSingle = false;
361
- let inDouble = false;
362
- while (i < text.length) {
363
- const ch = text[i];
364
- if (ch === "\\" && !inSingle) {
365
- outer += text.slice(i, i + 2);
366
- i += 2;
367
- continue;
368
- }
369
- if (ch === "'" && !inDouble && !quotesAreLiteral) {
370
- inSingle = !inSingle;
371
- outer += ch;
372
- i++;
373
- continue;
374
- }
375
- if (ch === '"' && !inSingle && !quotesAreLiteral) {
376
- inDouble = !inDouble;
377
- outer += ch;
378
- i++;
379
- continue;
380
- }
381
- if (inSingle) {
382
- outer += ch;
383
- i++;
384
- continue;
385
- }
386
- if (ch === "`") {
387
- const close = text.indexOf("`", i + 1);
388
- if (close < 0)
389
- return undefined;
390
- bodies.push(text.slice(i + 1, close));
391
- outer += SUBSTITUTION_PLACEHOLDER;
392
- i = close + 1;
393
- continue;
394
- }
395
- if ((ch === "$" || ch === "<" || ch === ">") && text[i + 1] === "(") {
396
- let depth = 0;
397
- let j = i + 1;
398
- let quoted;
399
- for (; j < text.length; j++) {
400
- const c = text[j];
401
- if (quoted !== undefined) {
402
- if (c === "\\" && quoted === '"')
403
- j++;
404
- else if (c === quoted)
405
- quoted = undefined;
406
- continue;
407
- }
408
- if (c === "'" || c === '"') {
409
- quoted = c;
410
- continue;
411
- }
412
- if (c === "(")
413
- depth++;
414
- else if (c === ")") {
415
- depth--;
416
- if (depth === 0)
417
- break;
418
- }
419
- }
420
- if (j >= text.length)
421
- return undefined;
422
- bodies.push(text.slice(i + 2, j));
423
- outer += SUBSTITUTION_PLACEHOLDER;
424
- i = j + 1;
425
- continue;
426
- }
427
- outer += ch;
428
- i++;
429
- }
430
- if (inSingle)
431
- return undefined;
432
- return { outer, bodies };
203
+ function whitelistSegment(segment) {
204
+ const tokens = [...segment.tokens];
205
+ for (let k = 0; k + 2 < tokens.length; k++) {
206
+ const [a, b, c] = [tokens[k], tokens[k + 1], tokens[k + 2]];
207
+ if (a.kind === "word" && b.kind === "group" && b.op === "(" && c.kind === "group" && c.op === ")" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(a.folded))
208
+ tokens.splice(k, 3);
209
+ }
210
+ const words = tokens.filter((t) => t.kind === "word" && !(t.raw === t.folded && (t.folded === "{" || t.folded === "}")));
211
+ let from = 0;
212
+ while (from < words.length && SHELL_BLOCK_WORDS.has(words[from].folded))
213
+ from++;
214
+ return {
215
+ words: { folded: words.slice(from).map((w) => w.folded), raw: words.slice(from).map((w) => w.raw) },
216
+ redirects: tokens.filter((t) => t.kind === "redirect"),
217
+ };
433
218
  }
434
219
  function refusedCommandExempt(command, allow, boundary, depth, baseMovedOutside = false) {
435
220
  if (depth > 4)
436
221
  return false;
437
- const stripped = withoutHeredocBodies(command);
438
- if (stripped === undefined)
439
- return false;
440
- if (!whitelistReadsTheSameWords(command, stripped.outer))
441
- return false;
442
- const split = splitSubstitutions(stripped.outer);
443
- if (split === undefined)
222
+ const lexed = lexShellText(command, { mode: "lenient" });
223
+ if ("reject" in lexed)
444
224
  return false;
445
- const bodies = [...split.bodies];
446
- for (const expandedBody of stripped.expandedBodies) {
447
- const inner = splitSubstitutions(expandedBody, true);
448
- if (inner === undefined)
449
- return false;
450
- bodies.push(...inner.bodies);
451
- }
452
- const fold = (w) => w.replace(/\\(.)/g, "$1").replace(/["']/g, "");
453
- const programOf = (w) => {
454
- const inner = fold(w);
455
- return inner.includes("/") ? inner.slice(inner.lastIndexOf("/") + 1) : inner;
456
- };
457
- const segments = split.outer.split(/\n|;|&&|\|\||\||(?<![>&\d])&(?![>&])/).map((segment) => {
458
- const toks = [];
459
- for (const word of segment.trim().split(/\s+/)) {
460
- let rest = word;
461
- while (rest !== "") {
462
- const m = /^(\d*(?:<<<|<<|<|>>|>)(?:&\d*)?|&>>?)(.*)$/.exec(rest);
463
- if (m !== null) {
464
- toks.push(m[1].replace(/^\d+/, ""));
465
- rest = m[2];
466
- continue;
467
- }
468
- const at = rest.search(/\d*(?:<<<|<<|<|>>|>)|&>/);
469
- if (at <= 0) {
470
- toks.push(rest);
471
- break;
472
- }
473
- toks.push(rest.slice(0, at));
474
- rest = rest.slice(at);
475
- }
476
- }
477
- const commentAt = toks.findIndex((t) => t.startsWith("#"));
478
- return (commentAt >= 0 ? toks.slice(0, commentAt) : toks)
479
- .map((t) => (/^[A-Za-z_][A-Za-z0-9_]*\(\)$/.test(t) ? "{" : t.replace(/^[({]+|[)}]+$/g, "")))
480
- .filter((t) => t !== "");
481
- });
482
- const headIndex = (toks) => {
483
- const folded = toks.map(fold);
484
- let i = 0;
485
- while (i < toks.length) {
486
- const w = folded[i];
487
- if (ASSIGNMENT_WORD.test(w) || SHELL_CONTROL_PREFIXES.has(w) || SHELL_BLOCK_WORDS.has(w) || /^[{}]$/.test(w)) {
488
- i++;
489
- const controlWord = programBasename(w);
490
- for (let span = launcherOptionSpan(controlWord, folded, i); span > 0; span = launcherOptionSpan(controlWord, folded, i))
491
- i += span;
492
- continue;
493
- }
494
- if (/^(?:<<<|<<|<|>>?|&>>?)$/.test(w)) {
495
- i += 2;
496
- continue;
497
- }
498
- if (/^(?:>>?|<)&\d*$/.test(w)) {
499
- i++;
225
+ const bodies = [...lexed.substitutions];
226
+ const segments = [];
227
+ for (const segment of lexed.segments) {
228
+ for (const t of segment.tokens) {
229
+ if (t.kind !== "heredoc" || t.quoted)
500
230
  continue;
501
- }
502
- break;
231
+ const inner = substitutionsInHeredocBody(t.body);
232
+ if (inner === undefined)
233
+ return false;
234
+ for (const body of inner)
235
+ bodies.push(body);
503
236
  }
504
- return i;
505
- };
506
- const runProgramOf = (toks, i) => {
507
- let head = programOf(toks[i] ?? "");
508
- let guard = 0;
509
- while (COMMAND_LAUNCHERS.has(head) && guard++ < 8) {
510
- let j = i + 1;
511
- while (j < toks.length && (ASSIGNMENT_WORD.test(fold(toks[j])) || /^\d+[smhd]?$/.test(fold(toks[j]))))
512
- j++;
513
- const next = toks[j];
514
- if (next === undefined || fold(next).startsWith("-"))
515
- return undefined;
516
- i = j;
517
- head = programOf(next);
518
- }
519
- return head;
520
- };
237
+ segments.push(whitelistSegment(segment));
238
+ }
521
239
  const baseMoved = baseMovedOutside ||
522
- segments.some((toks) => {
523
- const i = headIndex(toks);
524
- if (i >= toks.length)
525
- return false;
526
- const head = runProgramOf(toks, i);
527
- return head === undefined || head === "cd" || UNMODELLED_BASE_MOVERS.has(head);
240
+ segments.some(({ words }) => {
241
+ const pos = readProgramPosition(words);
242
+ if (pos.refusal !== undefined)
243
+ return true;
244
+ const runs = pos.program?.basename;
245
+ return runs === "cd" || (runs !== undefined && UNMODELLED_BASE_MOVERS.has(runs));
528
246
  });
529
247
  for (const body of bodies) {
530
248
  const walked = classifyCompoundReadonlyDetailed(body, allow, boundary);
@@ -535,33 +253,22 @@ function refusedCommandExempt(command, allow, boundary, depth, baseMovedOutside
535
253
  if (!refusedCommandExempt(body, allow, boundary, depth + 1, baseMoved))
536
254
  return false;
537
255
  }
538
- const stdinFileJudgedInside = (raw) => {
539
- if (raw === undefined || raw === "" || raw.includes(SUBSTITUTION_PLACEHOLDER) || UNREADABLE_WHITELIST_WORD.test(raw))
256
+ const stdinFileJudgedInside = (word) => {
257
+ if (word === undefined || word === "" || word.includes(SUBSTITUTION_PLACEHOLDER) || UNREADABLE_WHITELIST_WORD.test(word))
540
258
  return false;
541
259
  if (boundary === undefined || baseMoved)
542
260
  return false;
543
- const resolved = resolveOperandLexically(boundary.cwd ?? boundary.roots[0], fold(raw), boundary.homeDir);
261
+ const resolved = resolveOperandLexically(boundary.cwd ?? boundary.roots[0], word, boundary.homeDir);
544
262
  if (resolved === undefined || boundary.denyMatch?.(resolved) != null)
545
263
  return false;
546
264
  return withinAnyRoot(boundary.roots, resolved);
547
265
  };
548
- const unlistedSegmentJudgedInside = (toks, from) => {
266
+ const unlistedSegmentJudgedInside = (words, from) => {
549
267
  if (boundary === undefined)
550
268
  return true;
551
269
  let endOfOptions = false;
552
- for (let j = from; j < toks.length; j++) {
553
- const t = toks[j];
554
- if (t === "<<<" || t === "<<" || t === "<") {
555
- j++;
556
- continue;
557
- }
558
- if (/^(?:>>?|&>>?)$/.test(t)) {
559
- j++;
560
- continue;
561
- }
562
- if (/^(?:>>?|<)&\d*$/.test(t))
563
- continue;
564
- const word = fold(t);
270
+ for (let j = from; j < words.folded.length; j++) {
271
+ const word = words.folded[j];
565
272
  if (j > from && !endOfOptions && word === "--") {
566
273
  endOfOptions = true;
567
274
  continue;
@@ -580,47 +287,49 @@ function refusedCommandExempt(command, allow, boundary, depth, baseMovedOutside
580
287
  }
581
288
  return true;
582
289
  };
583
- for (const toks of segments) {
584
- for (let j = 0; j < toks.length; j++)
585
- if (toks[j] === "<" && !stdinFileJudgedInside(toks[j + 1]))
290
+ for (const { words, redirects } of segments) {
291
+ for (const r of redirects)
292
+ if (r.op === "<" && !stdinFileJudgedInside(r.target?.folded))
586
293
  return false;
587
- let i = headIndex(toks);
588
- if (i >= toks.length)
589
- continue;
590
- const headWord = fold(toks[i]);
591
- if (headWord.includes(SUBSTITUTION_PLACEHOLDER) || /[$`]/.test(headWord))
294
+ const pos = readProgramPosition(words);
295
+ if (pos.refusal !== undefined)
592
296
  return false;
593
- if (headWord.startsWith("-") && headWord !== "-")
297
+ for (const k of words.folded.keys()) {
298
+ const word = words.folded[k];
299
+ if (ASSIGNMENT_WORD.test(word) && /\$\(|`/.test(word) && (pos.assignments.includes(k) || pos.launchers.some((l) => l.index < k)))
300
+ return false;
301
+ }
302
+ const program = pos.program;
303
+ const listed = (name) => allow.has(name) || name === "cd";
304
+ if (program === undefined) {
305
+ if (pos.launchers.length === 0)
306
+ continue;
307
+ if (argvReentryUnjudged(pos, NO_PATH_OPERAND_COMMANDS))
308
+ return false;
309
+ if (!unlistedSegmentJudgedInside(words, pos.launchers[0].index))
310
+ return false;
311
+ continue;
312
+ }
313
+ if (program.name.includes(SUBSTITUTION_PLACEHOLDER) || /[$`]/.test(program.name))
594
314
  return false;
595
- let head = programOf(headWord);
596
- const launcherUnwrap = COMMAND_LAUNCHERS.has(head) ? unwrapLauncher(toks.slice(i).map(fold), allow) : undefined;
597
- if (launcherUnwrap?.kind === "refused")
315
+ if (argumentAsProgramRefusal(program.basename, program.basename, words.folded.slice(program.index + 1)) !== undefined)
598
316
  return false;
599
- const runs = launcherUnwrap === undefined ? { head, index: 0 } : { head: launcherUnwrap.head, index: launcherUnwrap.index };
600
- if (runs.head !== undefined && runs.index !== undefined &&
601
- argumentAsProgramRefusal(runs.head, runs.head, toks.slice(i + runs.index + 1).map(fold)) !== undefined)
317
+ if (SHELL_REENTRY_PROGRAM_RE.test(program.basename))
602
318
  return false;
603
- if (launcherUnwrap !== undefined) {
604
- const wrapped = runProgramOf(toks, i);
605
- if (wrapped === undefined || /[$`]/.test(wrapped) || SHELL_REENTRY_PROGRAM_RE.test(wrapped))
319
+ if (pos.launchers.length > 0) {
320
+ if (argvReentryUnjudged(pos, NO_PATH_OPERAND_COMMANDS))
606
321
  return false;
607
- const unwrapped = launcherUnwrap;
608
- if (argvReentryUnjudged(unwrapped))
609
- return false;
610
- if (unwrapped.kind === "unlisted") {
611
- if (!unlistedSegmentJudgedInside(toks, i))
322
+ if (!listed(program.basename)) {
323
+ if (!unlistedSegmentJudgedInside(words, pos.launchers[0].index))
612
324
  return false;
613
325
  continue;
614
326
  }
615
- head = unwrapped.head;
616
- i += unwrapped.index;
617
327
  }
618
- if (SHELL_REENTRY_PROGRAM_RE.test(head))
619
- return false;
328
+ const head = program.basename;
620
329
  if (head === "cd" || UNMODELLED_BASE_MOVERS.has(head))
621
330
  continue;
622
331
  if (!allow.has(head)) {
623
- if (!unlistedSegmentJudgedInside(toks, i))
332
+ if (!unlistedSegmentJudgedInside(words, program.index))
624
333
  return false;
625
334
  continue;
626
335
  }
@@ -629,110 +338,18 @@ function refusedCommandExempt(command, allow, boundary, depth, baseMovedOutside
629
338
  const flags = STDIN_ONLY_READER_FLAGS.get(head);
630
339
  if (flags === undefined)
631
340
  return false;
632
- for (let j = i + 1; j < toks.length; j++) {
633
- const t = toks[j];
634
- if (t === "<<<" || t === "<<" || t === "<") {
635
- j++;
636
- continue;
637
- }
638
- if (/^(?:>>?|&>>?)$/.test(t)) {
639
- j++;
640
- continue;
641
- }
642
- if (/^(?:>>?|<)&\d*$/.test(t))
643
- continue;
644
- if (flags.test(fold(t)))
645
- continue;
646
- return false;
341
+ for (let j = program.index + 1; j < words.folded.length; j++) {
342
+ if (!flags.test(words.folded[j]))
343
+ return false;
647
344
  }
648
345
  }
649
346
  return true;
650
347
  }
651
- function unwrapLauncher(folded, allow) {
652
- let argvFromStdin = ARGV_REENTRY_PROGRAM_RE.test(programBasename(folded[0] ?? ""));
653
- let lastReentry = argvFromStdin ? 0 : -1;
654
- for (let i = 1; i < folded.length; i++) {
655
- const t = folded[i];
656
- if (ASSIGNMENT_WORD.test(t) || /^\d+[smhd]?$/.test(t))
657
- continue;
658
- const wordBasename = programBasename(t);
659
- if (ARGV_REENTRY_PROGRAM_RE.test(wordBasename)) {
660
- argvFromStdin = true;
661
- lastReentry = i;
662
- continue;
663
- }
664
- if (COMMAND_LAUNCHERS.has(wordBasename))
665
- continue;
666
- if (t.startsWith("-"))
667
- 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}` };
668
- const head = wordBasename;
669
- if (SHELL_CONTROL_PREFIXES.has(head) || SHELL_BLOCK_WORDS.has(head))
670
- return { kind: "refused", reason: `"${folded[0]}" is given shell control syntax ("${head}") where the program it runs would be ${NOT_AUTO_ALLOWED}` };
671
- return allow.has(head) || head === "cd" ? { kind: "reader", index: i, head, argvFromStdin } : { kind: "unlisted", head, index: i, argvFromStdin };
672
- }
673
- return { kind: "unlisted", argvFromStdin, runsDefaultProgram: lastReentry === folded.length - 1 };
674
- }
675
- const LAUNCHER_OPTION_GRAMMAR = new Map([
676
- ["time", {
677
- plain: new Set(["-p", "-a", "-v", "-q", "--portability", "--append", "--verbose", "--quiet", "--help", "--version"]),
678
- valued: new Set(["-f", "-o", "--format", "--output"]),
679
- }],
680
- ]);
681
- function launcherOptionSpan(launcher, folded, i) {
682
- const row = launcher === undefined ? undefined : LAUNCHER_OPTION_GRAMMAR.get(launcher);
683
- const tok = folded[i];
684
- if (row === undefined || tok === undefined)
685
- return 0;
686
- if (row.plain.has(tok))
687
- return 1;
688
- return row.valued.has(tok) && folded[i + 1] !== undefined ? 2 : 0;
689
- }
690
- const ARGV_REENTRY_DEFAULT_PROGRAM = "echo";
691
- function argvReentryUnjudged(unwrapped) {
692
- if (!unwrapped.argvFromStdin)
693
- return false;
694
- const runs = unwrapped.head ?? (unwrapped.runsDefaultProgram === true ? ARGV_REENTRY_DEFAULT_PROGRAM : undefined);
695
- return runs === undefined || !NO_PATH_OPERAND_COMMANDS.has(runs);
696
- }
697
348
  function tokenizeSegment(segment) {
698
- const raw = splitWordsQuoteAware(segment);
699
- return { folded: raw.map((r) => { const f = foldQuoteRemovalToken(r); return tildeIsLiteral(r) ? literalTildeSpelling(f) : f; }), raw };
700
- }
701
- function literalTildeSpelling(word) {
702
- return word.startsWith("~") ? `./${word}` : word;
703
- }
704
- function tildeIsLiteral(raw) {
705
- if (!raw.startsWith("~"))
706
- return true;
707
- for (let i = 1; i < raw.length; i++) {
708
- const ch = raw[i];
709
- if (ch === '"' || ch === "'")
710
- return true;
711
- if (ch === "/")
712
- return false;
713
- }
714
- return false;
715
- }
716
- function splitWordsQuoteAware(segment) {
717
- const s = segment.trim();
718
- const mask = quoteMask(s);
719
- if (!mask.balanced)
720
- return s.split(/\s+/).filter((t) => t.length > 0);
721
- const out = [];
722
- let cur = "";
723
- for (let i = 0; i < s.length; i++) {
724
- const ch = s[i];
725
- if (!mask.quoted[i] && /\s/.test(ch)) {
726
- if (cur.length > 0)
727
- out.push(cur);
728
- cur = "";
729
- continue;
730
- }
731
- cur += ch;
732
- }
733
- if (cur.length > 0)
734
- out.push(cur);
735
- return out;
349
+ const lexed = lexShellText(segment, { mode: "strict", connectors: "text" });
350
+ if ("reject" in lexed)
351
+ return { folded: [], raw: [] };
352
+ return wordsOf(lexed.segments[0]);
736
353
  }
737
354
  function hasUnquotedExpansionMetachar(rawToken) {
738
355
  let open;
@@ -1304,20 +921,6 @@ const INTERPRETER_INLINE_PROGRAMS = new Map([
1304
921
  function interpreterKey(name) {
1305
922
  return name.replace(/\d+(?:\.\d+)*$/, "");
1306
923
  }
1307
- function withoutRedirectionWords(args) {
1308
- const out = [];
1309
- for (let i = 0; i < args.length; i++) {
1310
- const t = args[i];
1311
- if (/^(?:<<<|<<|<|>>?|&>>?)$/.test(t)) {
1312
- i++;
1313
- continue;
1314
- }
1315
- if (/^(?:>>?|<)&\d*$/.test(t))
1316
- continue;
1317
- out.push(t);
1318
- }
1319
- return out;
1320
- }
1321
924
  function inlineProgramClause(model, args) {
1322
925
  let endOfOptions = false;
1323
926
  let banner = false;
@@ -1441,7 +1044,7 @@ function argumentAsProgramRefusal(written, name, args) {
1441
1044
  const interpreter = INTERPRETER_INLINE_PROGRAMS.get(interpreterKey(name));
1442
1045
  if (interpreter === undefined)
1443
1046
  return undefined;
1444
- const clause = inlineProgramClause(interpreter, withoutRedirectionWords(args));
1047
+ const clause = inlineProgramClause(interpreter, args);
1445
1048
  return clause === undefined ? undefined : unjudgedProgramTextRefusal(written, clause);
1446
1049
  }
1447
1050
  function guardedVerbFormReason(name, toks, raw) {
@@ -1834,13 +1437,8 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
1834
1437
  for (let si = 0; si < segments.length; si++) {
1835
1438
  const toks = tokenizeSegment(segments[si]);
1836
1439
  const folded = toks.folded;
1837
- let from = 0;
1838
- while (from < folded.length && isBareControlWord(toks, from, SHELL_CONTROL_PREFIXES)) {
1839
- const controlWord = programBasename(folded[from] ?? "");
1840
- from++;
1841
- for (let span = launcherOptionSpan(controlWord, folded, from); span > 0; span = launcherOptionSpan(controlWord, folded, from))
1842
- from += span;
1843
- }
1440
+ const pos = readProgramPosition(toks);
1441
+ const from = pos.written ?? folded.length;
1844
1442
  const written = folded[from];
1845
1443
  if (written === undefined)
1846
1444
  return { reason: "empty command" };
@@ -1858,15 +1456,11 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
1858
1456
  const name = pathPrefixed ? parsed.name.slice(parsed.name.lastIndexOf("/") + 1) : parsed.name;
1859
1457
  if (pathPrefixed)
1860
1458
  nameRefusal ??= "the command must be a bare name resolved via PATH (no path prefix)";
1861
- if (name.startsWith("-") && name !== "-") {
1862
- 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 };
1863
- }
1864
- const launcherUnwrap = COMMAND_LAUNCHERS.has(name) ? unwrapLauncher(folded.slice(from), allow) : undefined;
1865
- if (launcherUnwrap?.kind === "refused")
1866
- return { reason: launcherUnwrap.reason, refusedWhole: true };
1867
- const runs = launcherUnwrap === undefined ? { head: name, index: 0 } : { head: launcherUnwrap.head, index: launcherUnwrap.index };
1868
- if (runs?.head !== undefined && runs.index !== undefined) {
1869
- const familyRefusal = argumentAsProgramRefusal(runs.head, runs.head, folded.slice(from + runs.index + 1));
1459
+ if (pos.refusal !== undefined)
1460
+ return { reason: programPositionRefusalSentence(pos.refusal), refusedWhole: true };
1461
+ const program = pos.program;
1462
+ if (program !== undefined) {
1463
+ const familyRefusal = argumentAsProgramRefusal(program.basename, program.basename, folded.slice(program.index + 1));
1870
1464
  if (familyRefusal !== undefined)
1871
1465
  return { reason: familyRefusal, refusedWhole: true };
1872
1466
  }
@@ -1880,20 +1474,18 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
1880
1474
  nameRefusal ??= `command "${parsed.name}" is not in the read-only allowlist`;
1881
1475
  if (SHELL_REENTRY_PROGRAM_RE.test(name))
1882
1476
  return { reason: unjudgedProgramTextRefusal(parsed.name, "runs its argument as shell text"), refusedWhole: true };
1883
- if (COMMAND_LAUNCHERS.has(name)) {
1884
- const unwrapped = launcherUnwrap ?? unwrapLauncher(folded.slice(from), allow);
1885
- if (unwrapped.kind === "refused")
1886
- return { reason: unwrapped.reason, refusedWhole: true };
1887
- if (unwrapped.kind === "unlisted" && unwrapped.head !== undefined && SHELL_REENTRY_PROGRAM_RE.test(unwrapped.head)) {
1888
- return { reason: `"${written}" runs "${unwrapped.head}", which runs its argument as shell text this walk does not judge ${NOT_AUTO_ALLOWED}`, refusedWhole: true };
1477
+ if (pos.launchers.length > 0) {
1478
+ const listed = program !== undefined && (allow.has(program.basename) || program.basename === "cd");
1479
+ if (program !== undefined && !listed && SHELL_REENTRY_PROGRAM_RE.test(program.basename)) {
1480
+ return { reason: `"${written}" runs "${program.basename}", which runs its argument as shell text this walk does not judge ${NOT_AUTO_ALLOWED}`, refusedWhole: true };
1889
1481
  }
1890
- if (argvReentryUnjudged(unwrapped)) {
1482
+ if (argvReentryUnjudged(pos, NO_PATH_OPERAND_COMMANDS)) {
1891
1483
  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 };
1892
1484
  }
1893
- if (unwrapped.kind === "unlisted")
1485
+ if (!listed || program === undefined)
1894
1486
  unlistedSegments.add(si);
1895
1487
  else
1896
- reheaded.set(si, { from: from + unwrapped.index, head: unwrapped.head });
1488
+ reheaded.set(si, { from: program.index, head: program.basename });
1897
1489
  continue;
1898
1490
  }
1899
1491
  unlistedSegments.add(si);
@@ -2225,44 +1817,21 @@ export function classifySimpleCommandReadBoundary(command, boundary) {
2225
1817
  const toks = tokenizeSegment(command);
2226
1818
  if (toks.folded.length === 0)
2227
1819
  return {};
2228
- const head = programBasename(toks.folded[0]);
2229
- let past = 0;
2230
- for (let span = launcherOptionSpan(head, toks.folded, past + 1); span > 0; span = launcherOptionSpan(head, toks.folded, past + 1))
2231
- past += span;
2232
- const chain = past === 0 ? toks.folded : [toks.folded[0], ...toks.folded.slice(past + 1)];
2233
- const runs = COMMAND_LAUNCHERS.has(head) ? unwrapLauncher(chain, new Set()) : { kind: "reader", head, index: 0 };
2234
- if (runs.kind === "refused")
2235
- return { reason: runs.reason, refusedWhole: true };
2236
- if (runs.head !== undefined && runs.index !== undefined) {
2237
- const familyRefusal = argumentAsProgramRefusal(runs.head, runs.head, toks.folded.slice(runs.index + past + 1));
1820
+ const pos = readProgramPosition(toks);
1821
+ if (pos.refusal !== undefined)
1822
+ return { reason: programPositionRefusalSentence(pos.refusal), refusedWhole: true };
1823
+ if (pos.program !== undefined) {
1824
+ const familyRefusal = argumentAsProgramRefusal(pos.program.basename, pos.program.basename, toks.folded.slice(pos.program.index + 1));
2238
1825
  if (familyRefusal !== undefined)
2239
1826
  return { reason: familyRefusal, refusedWhole: true };
2240
1827
  }
2241
1828
  return evaluateReadBoundary([toks], boundary);
2242
1829
  }
2243
1830
  const NOT_GATED = { gated: false, paths: [] };
2244
- const ASSIGNMENT_WORD = /^[A-Za-z_][A-Za-z0-9_]*=/;
2245
1831
  const UNREADABLE_WHITELIST_WORD = /[$`()*?[\]{}~]/;
2246
- function whitelistReadsTheSameWords(command, outsideHeredocBodies) {
2247
- if (/\\\r?\n/.test(command))
2248
- return false;
2249
- if (/\\[<>&|;()]/.test(outsideHeredocBodies))
2250
- return false;
2251
- return !(outsideHeredocBodies.includes("#") && /["']/.test(outsideHeredocBodies));
2252
- }
2253
1832
  const UNMODELLED_BASE_MOVERS = new Set(["pushd", "popd"]);
2254
- const COMMAND_LAUNCHERS = new Set([
2255
- "env", "command", "builtin", "exec", "noglob", "xargs", "busybox",
2256
- "sudo", "doas", "pkexec", "su", "runuser", "chroot", "setpriv",
2257
- "nohup", "nice", "ionice", "chrt", "taskset", "stdbuf", "timeout", "time", "watch",
2258
- "setsid", "flock", "unshare", "nsenter", "script", "numactl", "prlimit", "systemd-run",
2259
- "strace", "ltrace", "valgrind", "firejail", "bwrap",
2260
- ]);
2261
1833
  export const COMMAND_LAUNCHERS_FOR_TEST = COMMAND_LAUNCHERS;
2262
1834
  const LAUNCHER_SCAN_HEAD = "cat";
2263
- function programBasename(word) {
2264
- return word.includes("/") ? (word.split("/").pop() ?? word) : word;
2265
- }
2266
1835
  export function classifyOutOfRootReadGate(command, allow, boundary) {
2267
1836
  const containmentOnly = {
2268
1837
  roots: boundary.roots,
@@ -2417,7 +1986,7 @@ export function classifyBoundedReadonlyPollLoopDetailed(command, allow, boundary
2417
1986
  if (readSegments.length === 0) {
2418
1987
  return { reason: "the loop body has no read command — a sleep-only loop observes nothing and is not auto-allowed" };
2419
1988
  }
2420
- const bodyHasCd = readSegments.some((seg) => tokensMoveBase(tokenizeSegment(seg), allow));
1989
+ const bodyHasCd = readSegments.some((seg) => tokensMoveBase(tokenizeSegment(seg)));
2421
1990
  const modelled = bodyHasCd ? Array.from({ length: beats }, () => readSegments.join("; ")).join("; ") : readSegments.join("; ");
2422
1991
  const verdict = classifyCompoundReadonlyDetailed(modelled, allow, boundary, { iterated: bodyHasCd });
2423
1992
  if (verdict.reason !== undefined)