@sema-agent/core 7.17.7 → 7.19.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 (201) hide show
  1. package/CHANGELOG.md +233 -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/anthropic.js +6 -3
  8. package/dist/brain/open-responses.js +10 -4
  9. package/dist/brain/openai.js +5 -2
  10. package/dist/brain/reasoning.d.ts +13 -13
  11. package/dist/brain/reasoning.js +2 -2
  12. package/dist/brain/route-adjudicator.d.ts +18 -0
  13. package/dist/brain/route-adjudicator.js +16 -0
  14. package/dist/brain/stream-engine.js +2 -1
  15. package/dist/brain/stream-shared.d.ts +3 -0
  16. package/dist/brain/stream-shared.js +1 -0
  17. package/dist/core/agent-definition.d.ts +10 -0
  18. package/dist/core/artifact-host.d.ts +41 -4
  19. package/dist/core/artifact-host.js +18 -2
  20. package/dist/core/ask-origin.d.ts +16 -0
  21. package/dist/core/ask-origin.js +8 -0
  22. package/dist/core/auto-mode-arming.d.ts +6 -0
  23. package/dist/core/auto-mode-arming.js +5 -5
  24. package/dist/core/auto-mode-defaults.d.ts +43 -15
  25. package/dist/core/auto-mode-defaults.js +4 -1
  26. package/dist/core/auto-mode.d.ts +184 -59
  27. package/dist/core/auto-mode.js +188 -13
  28. package/dist/core/checkpoint-store.d.ts +23 -2
  29. package/dist/core/checkpoint-store.js +2 -2
  30. package/dist/core/engine-notice.d.ts +77 -1
  31. package/dist/core/engine-notice.js +7 -0
  32. package/dist/core/gate-fold.js +5 -1
  33. package/dist/core/gate-lanes.js +9 -8
  34. package/dist/core/gate-outcome.d.ts +42 -2
  35. package/dist/core/gate-outcome.js +23 -0
  36. package/dist/core/governance-codes.d.ts +1 -1
  37. package/dist/core/governance-codes.js +14 -0
  38. package/dist/core/hook-seats.d.ts +66 -0
  39. package/dist/core/hook-seats.js +37 -0
  40. package/dist/core/hooks.d.ts +1 -1
  41. package/dist/core/read-root-candidate.d.ts +12 -0
  42. package/dist/core/read-root-candidate.js +24 -0
  43. package/dist/core/roles.d.ts +9 -1
  44. package/dist/core/roles.js +4 -0
  45. package/dist/core/runner/advertised-writable-dirs.d.ts +8 -3
  46. package/dist/core/runner/assemble-result.d.ts +2 -1
  47. package/dist/core/runner/assemble-result.js +1 -1
  48. package/dist/core/runner/classifier-ladder.d.ts +227 -0
  49. package/dist/core/runner/classifier-ladder.js +214 -0
  50. package/dist/core/runner/contracts.d.ts +71 -13
  51. package/dist/core/runner/decide-continuation.d.ts +1 -3
  52. package/dist/core/runner/decide-continuation.js +1 -7
  53. package/dist/core/runner/deferral-census.d.ts +52 -0
  54. package/dist/core/runner/deferral-census.js +44 -0
  55. package/dist/core/runner/denial-limit-arms.js +2 -2
  56. package/dist/core/runner/derived-route-fallback.d.ts +8 -4
  57. package/dist/core/runner/derived-route-fallback.js +8 -3
  58. package/dist/core/runner/gate-exit.d.ts +12 -0
  59. package/dist/core/runner/gate-exit.js +6 -3
  60. package/dist/core/runner/prepare-artifact.d.ts +10 -2
  61. package/dist/core/runner/prepare-artifact.js +5 -4
  62. package/dist/core/runner/prepare-ask-lane.js +1 -0
  63. package/dist/core/runner/prepare-caps-and-workflow.d.ts +4 -2
  64. package/dist/core/runner/prepare-caps-and-workflow.js +43 -55
  65. package/dist/core/runner/prepare-config-doors.d.ts +7 -1
  66. package/dist/core/runner/prepare-config-doors.js +13 -13
  67. package/dist/core/runner/prepare-defer-classify.d.ts +13 -19
  68. package/dist/core/runner/prepare-defer-classify.js +8 -3
  69. package/dist/core/runner/prepare-gate-stations.d.ts +26 -0
  70. package/dist/core/runner/prepare-gate-stations.js +1 -1
  71. package/dist/core/runner/prepare-hands-readface.d.ts +24 -7
  72. package/dist/core/runner/prepare-hands-readface.js +60 -24
  73. package/dist/core/runner/prepare-inherited-gate.d.ts +17 -4
  74. package/dist/core/runner/prepare-inherited-gate.js +10 -2
  75. package/dist/core/runner/prepare-lsp.d.ts +2 -2
  76. package/dist/core/runner/prepare-lsp.js +1 -1
  77. package/dist/core/runner/prepare-policy-chain.js +2 -0
  78. package/dist/core/runner/prepare-project-context.d.ts +5 -1
  79. package/dist/core/runner/prepare-project-context.js +7 -4
  80. package/dist/core/runner/prepare-prompt-inputs.d.ts +4 -2
  81. package/dist/core/runner/prepare-prompt-inputs.js +4 -1
  82. package/dist/core/runner/prepare-run-refs.d.ts +9 -1
  83. package/dist/core/runner/prepare-run-refs.js +3 -2
  84. package/dist/core/runner/prepare-suspend-saga.d.ts +5 -0
  85. package/dist/core/runner/prepare-suspend-saga.js +5 -2
  86. package/dist/core/runner/prepare-task.js +19 -14
  87. package/dist/core/runner/prepare-tool-disclosure-mount.d.ts +5 -0
  88. package/dist/core/runner/prepare-tool-disclosure-mount.js +10 -2
  89. package/dist/core/runner/prepare-turn-wiring.d.ts +8 -4
  90. package/dist/core/runner/prepare-turn-wiring.js +3 -2
  91. package/dist/core/runner/prepare-wiring-manifest.d.ts +5 -5
  92. package/dist/core/runner/prepare-wiring-manifest.js +5 -2
  93. package/dist/core/runner/resume-apply.js +3 -3
  94. package/dist/core/runner/resume-checkpoint-screen.js +7 -0
  95. package/dist/core/runner/run-harness-handlers.js +4 -1
  96. package/dist/core/runner/run-identity-wiring.js +6 -2
  97. package/dist/core/runner/run-leg.d.ts +2 -0
  98. package/dist/core/runner/run-leg.js +45 -45
  99. package/dist/core/runner/run-notification-lane.js +55 -27
  100. package/dist/core/runner/run-settle-and-teardown.js +1 -0
  101. package/dist/core/runner/run-terminal-adoption.js +16 -13
  102. package/dist/core/runner/run-turn-boundary.js +15 -5
  103. package/dist/core/runner/runtask.d.ts +6 -5
  104. package/dist/core/runner/runtask.js +26 -14
  105. package/dist/core/runner/skill-base-dir.d.ts +54 -0
  106. package/dist/core/runner/skill-base-dir.js +42 -0
  107. package/dist/core/runner/synthetic-tools.d.ts +83 -48
  108. package/dist/core/runner/synthetic-tools.js +69 -32
  109. package/dist/core/runner/tool-defer-gate.d.ts +22 -9
  110. package/dist/core/runner/tool-defer-gate.js +15 -5
  111. package/dist/core/runner/tool-disclosure.d.ts +5 -0
  112. package/dist/core/runner/tool-disclosure.js +2 -0
  113. package/dist/core/runner/tool-progress-lane.d.ts +42 -0
  114. package/dist/core/runner/tool-progress-lane.js +61 -0
  115. package/dist/core/runner/turn-attachments.d.ts +43 -12
  116. package/dist/core/runner/turn-attachments.js +58 -14
  117. package/dist/core/runner-deps.d.ts +48 -7
  118. package/dist/core/sensitive-path-policy.d.ts +58 -17
  119. package/dist/core/sensitive-path-policy.js +33 -5
  120. package/dist/core/skill-listing-budget.d.ts +112 -0
  121. package/dist/core/skill-listing-budget.js +58 -0
  122. package/dist/core/swappable-deps.d.ts +9 -7
  123. package/dist/core/swappable-deps.js +7 -3
  124. package/dist/core/task-event.d.ts +96 -29
  125. package/dist/core/task-notification.d.ts +142 -16
  126. package/dist/core/task-notification.js +106 -0
  127. package/dist/core/task-result.d.ts +9 -1
  128. package/dist/core/task-spec.d.ts +46 -3
  129. package/dist/core/tighten-task-spec.js +7 -5
  130. package/dist/core/tool-model-gate.d.ts +1 -1
  131. package/dist/core/tool-policy.d.ts +19 -4
  132. package/dist/core/tool-policy.js +6 -7
  133. package/dist/core/tool-search-setting.d.ts +78 -0
  134. package/dist/core/tool-search-setting.js +90 -0
  135. package/dist/core/tool-spec.d.ts +45 -4
  136. package/dist/core/types.d.ts +2 -2
  137. package/dist/core/types.js +1 -1
  138. package/dist/core/wiring-manifest.d.ts +46 -0
  139. package/dist/core/wiring-manifest.js +9 -1
  140. package/dist/core/write-protect.d.ts +8 -3
  141. package/dist/core/write-protect.js +8 -5
  142. package/dist/engine/execution-env/node-execution-env.js +10 -4
  143. package/dist/engine/harness/agent-harness.d.ts +42 -0
  144. package/dist/engine/harness/agent-harness.js +61 -29
  145. package/dist/engine/loop/agent-loop.d.ts +68 -3
  146. package/dist/engine/loop/agent-loop.js +109 -16
  147. package/dist/index.d.ts +13 -8
  148. package/dist/index.js +11 -7
  149. package/dist/internal/harness.d.ts +1 -1
  150. package/dist/orchestration/run-workflow-tool.d.ts +3 -0
  151. package/dist/orchestration/run-workflow-tool.js +6 -1
  152. package/dist/orchestration/workflow-governance.d.ts +7 -1
  153. package/dist/orchestration/workflow-governance.js +9 -5
  154. package/dist/orchestration/workflow-primitives.d.ts +3 -0
  155. package/dist/orchestration/workflow-primitives.js +6 -1
  156. package/dist/orchestration/workflow-types.d.ts +48 -13
  157. package/dist/orchestration/workflow-types.js +1 -1
  158. package/dist/orchestration/workflow.js +45 -17
  159. package/dist/prompt-assembly/epoch.js +2 -0
  160. package/dist/prompt-assembly/packs/sema-default.js +2 -2
  161. package/dist/prompt-assembly/section-usage.d.ts +26 -0
  162. package/dist/prompt-assembly/section-usage.js +10 -0
  163. package/dist/prompt-assembly/types.d.ts +8 -6
  164. package/dist/prompts/default.d.ts +13 -0
  165. package/dist/prompts/default.js +16 -8
  166. package/dist/tools/artifact/artifact-text.d.ts +10 -1
  167. package/dist/tools/artifact/artifact-text.js +6 -3
  168. package/dist/tools/artifact/artifact-tool.d.ts +12 -2
  169. package/dist/tools/artifact/artifact-tool.js +203 -70
  170. package/dist/tools/artifact/conformance.d.ts +56 -0
  171. package/dist/tools/artifact/conformance.js +349 -0
  172. package/dist/tools/artifact/local-stub.js +1 -1
  173. package/dist/tools/artifact/reject-breaker.d.ts +28 -0
  174. package/dist/tools/artifact/reject-breaker.js +42 -0
  175. package/dist/tools/fs/bash-lexer.d.ts +132 -0
  176. package/dist/tools/fs/bash-lexer.js +563 -0
  177. package/dist/tools/fs/bash-program-position.d.ts +142 -0
  178. package/dist/tools/fs/bash-program-position.js +125 -0
  179. package/dist/tools/fs/bash-readonly-classifier.d.ts +81 -16
  180. package/dist/tools/fs/bash-readonly-classifier.js +180 -558
  181. package/dist/tools/fs/containment-face.d.ts +89 -0
  182. package/dist/tools/fs/containment-face.js +81 -0
  183. package/dist/tools/fs/fs-bash.d.ts +46 -1
  184. package/dist/tools/fs/fs-bash.js +165 -25
  185. package/dist/tools/fs/fs-read.d.ts +1 -1
  186. package/dist/tools/fs/fs-search-tools.d.ts +3 -3
  187. package/dist/tools/fs/fs-write.d.ts +4 -3
  188. package/dist/tools/fs/fs-write.js +9 -9
  189. package/dist/tools/fs/index.d.ts +24 -5
  190. package/dist/tools/fs/index.js +18 -6
  191. package/dist/tools/fs/read-deny.d.ts +4 -3
  192. package/dist/tools/fs/repo-map.d.ts +2 -2
  193. package/dist/tools/fs/safety.d.ts +18 -3
  194. package/dist/tools/fs/safety.js +11 -4
  195. package/package.json +1 -1
  196. package/test/export-surface.snapshot.json +99 -13
  197. package/wire-changes/core@7.17.7.json +49 -0
  198. package/wire-changes/core@7.18.0.json +1273 -0
  199. package/wire-changes/core@7.19.0.json +457 -0
  200. package/dist/tools/fs/read-face.d.ts +0 -68
  201. package/dist/tools/fs/read-face.js +0 -47
@@ -1,5 +1,7 @@
1
- import { isAbsoluteForFamily, isAbsolutePathForm, isBlockedDevicePath, isShellRootedSpellingUnmapped, joinForFamily, nativeUncSpellingOf, normalizeAbsPathLexically, win32NamespaceScreen, pathFamilyOf, withinAnyRoot } from "./safety.js";
2
- export const NOT_AUTO_ALLOWED = "— not auto-allowed";
1
+ import { hasWin32AliasedComponent, isAbsoluteForFamily, isAbsolutePathForm, isBlockedDevicePath, isShellRootedSpellingUnmapped, joinForFamily, nativeUncSpellingOf, normalizeAbsPathLexically, win32NamespaceScreen, pathFamilyOf, withinAnyRoot } from "./safety.js";
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(/[/\\]+$/, "");
@@ -206,6 +135,49 @@ export function formatOutOfRootReadApprovalOption(directory) {
206
135
  const leaf = cut >= 0 ? trimmed.slice(cut + 1) : trimmed;
207
136
  return `Yes, allow reading from ${leaf || directory}/ from this project`;
208
137
  }
138
+ function parentDirLexically(p) {
139
+ return normalizeAbsPathLexically(`${p}/..`);
140
+ }
141
+ export function deepestCommonDir(a, b) {
142
+ if (a === b)
143
+ return a;
144
+ const shorter = Math.min(a.length, b.length);
145
+ let end = 0;
146
+ let i = 0;
147
+ while (i < shorter && a[i] === b[i]) {
148
+ if (a[i] === "/" || a[i] === "\\")
149
+ end = i;
150
+ i++;
151
+ }
152
+ if (i === shorter && (a[shorter] === "/" || a[shorter] === "\\" || b[shorter] === "/" || b[shorter] === "\\"))
153
+ end = shorter;
154
+ const cut = a.slice(0, end);
155
+ const candidate = cut.includes("/") || cut.includes("\\") ? cut : a.slice(0, end + 1);
156
+ if (candidate.length === 0 || !withinAnyRoot([candidate], a) || !withinAnyRoot([candidate], b))
157
+ return undefined;
158
+ return candidate;
159
+ }
160
+ function outOfRootGrantCandidateOf(paths, boundary) {
161
+ if (paths.length === 0)
162
+ return undefined;
163
+ if (paths.some(hasWin32AliasedComponent))
164
+ return undefined;
165
+ let candidate = parentDirLexically(paths[0]);
166
+ for (const path of paths.slice(1)) {
167
+ if (candidate === undefined)
168
+ return undefined;
169
+ candidate = deepestCommonDir(candidate, parentDirLexically(path));
170
+ }
171
+ if (candidate === undefined)
172
+ return undefined;
173
+ if (parentDirLexically(candidate) === candidate)
174
+ return undefined;
175
+ if (boundary.homeDir !== undefined && withinAnyRoot([candidate], boundary.homeDir))
176
+ return undefined;
177
+ if (boundary.roots.some((root) => withinAnyRoot([candidate], root)))
178
+ return undefined;
179
+ return candidate;
180
+ }
209
181
  const NO_PATH_OPERAND_COMMANDS = new Set([
210
182
  "pwd", "echo", "whoami", "uname", "which", "tr", "basename", "dirname",
211
183
  "cal", "uptime", "id", "groups", "nproc", "locale", "free", "true", "false", "seq", "expr", "type", "sleep",
@@ -245,36 +217,25 @@ function spellingsOf(absolute) {
245
217
  function reheadSegment(toks, head, from = 0) {
246
218
  return { folded: [head, ...toks.folded.slice(from + 1)], raw: [head, ...toks.raw.slice(from + 1)] };
247
219
  }
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)
220
+ function tokensMoveBase(toks) {
221
+ const pos = readProgramPosition(toks);
222
+ if (pos.written === undefined)
264
223
  return false;
265
- if (toks.raw[from] !== head)
266
- return true;
267
- if (head.startsWith("-") && head !== "-")
224
+ const written = toks.folded[pos.written];
225
+ if (toks.raw[pos.written] !== written)
268
226
  return true;
269
- if (head === "cd")
270
- return true;
271
- if (!COMMAND_LAUNCHERS.has(head))
227
+ if (pos.assignments.length > 0)
272
228
  return false;
273
- const unwrapped = unwrapLauncher(toks.folded.slice(from), allow);
274
- return unwrapped.kind === "refused" || (unwrapped.kind === "reader" && unwrapped.head === "cd");
229
+ if (pos.launchers.length === 0) {
230
+ if (written.startsWith("-") && written !== "-")
231
+ return true;
232
+ return written === "cd";
233
+ }
234
+ if (!COMMAND_LAUNCHERS.has(written))
235
+ return false;
236
+ return pos.refusal !== undefined || pos.program?.basename === "cd";
275
237
  }
276
238
  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
239
  export function refusedCommandMayRead(command, allow, boundary) {
279
240
  return !refusedCommandExempt(command, allow, boundary, 0);
280
241
  }
@@ -282,249 +243,49 @@ const STDIN_ONLY_READER_FLAGS = new Map([
282
243
  ["cat", /^-[AbeEnstTuv]+$/],
283
244
  ["wc", /^-[clmwL]+$/],
284
245
  ]);
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 };
246
+ function whitelistSegment(segment) {
247
+ const tokens = [...segment.tokens];
248
+ for (let k = 0; k + 2 < tokens.length; k++) {
249
+ const [a, b, c] = [tokens[k], tokens[k + 1], tokens[k + 2]];
250
+ if (a.kind === "word" && b.kind === "group" && b.op === "(" && c.kind === "group" && c.op === ")" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(a.folded))
251
+ tokens.splice(k, 3);
252
+ }
253
+ const words = tokens.filter((t) => t.kind === "word" && !(t.raw === t.folded && (t.folded === "{" || t.folded === "}")));
254
+ let from = 0;
255
+ while (from < words.length && SHELL_BLOCK_WORDS.has(words[from].folded))
256
+ from++;
257
+ return {
258
+ words: { folded: words.slice(from).map((w) => w.folded), raw: words.slice(from).map((w) => w.raw) },
259
+ redirects: tokens.filter((t) => t.kind === "redirect"),
260
+ };
433
261
  }
434
262
  function refusedCommandExempt(command, allow, boundary, depth, baseMovedOutside = false) {
435
263
  if (depth > 4)
436
264
  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)
265
+ const lexed = lexShellText(command, { mode: "lenient" });
266
+ if ("reject" in lexed)
444
267
  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++;
268
+ const bodies = [...lexed.substitutions];
269
+ const segments = [];
270
+ for (const segment of lexed.segments) {
271
+ for (const t of segment.tokens) {
272
+ if (t.kind !== "heredoc" || t.quoted)
500
273
  continue;
501
- }
502
- break;
274
+ const inner = substitutionsInHeredocBody(t.body);
275
+ if (inner === undefined)
276
+ return false;
277
+ for (const body of inner)
278
+ bodies.push(body);
503
279
  }
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
- };
280
+ segments.push(whitelistSegment(segment));
281
+ }
521
282
  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);
283
+ segments.some(({ words }) => {
284
+ const pos = readProgramPosition(words);
285
+ if (pos.refusal !== undefined)
286
+ return true;
287
+ const runs = pos.program?.basename;
288
+ return runs === "cd" || (runs !== undefined && UNMODELLED_BASE_MOVERS.has(runs));
528
289
  });
529
290
  for (const body of bodies) {
530
291
  const walked = classifyCompoundReadonlyDetailed(body, allow, boundary);
@@ -535,33 +296,22 @@ function refusedCommandExempt(command, allow, boundary, depth, baseMovedOutside
535
296
  if (!refusedCommandExempt(body, allow, boundary, depth + 1, baseMoved))
536
297
  return false;
537
298
  }
538
- const stdinFileJudgedInside = (raw) => {
539
- if (raw === undefined || raw === "" || raw.includes(SUBSTITUTION_PLACEHOLDER) || UNREADABLE_WHITELIST_WORD.test(raw))
299
+ const stdinFileJudgedInside = (word) => {
300
+ if (word === undefined || word === "" || word.includes(SUBSTITUTION_PLACEHOLDER) || UNREADABLE_WHITELIST_WORD.test(word))
540
301
  return false;
541
302
  if (boundary === undefined || baseMoved)
542
303
  return false;
543
- const resolved = resolveOperandLexically(boundary.cwd ?? boundary.roots[0], fold(raw), boundary.homeDir);
304
+ const resolved = resolveOperandLexically(boundary.cwd ?? boundary.roots[0], word, boundary.homeDir);
544
305
  if (resolved === undefined || boundary.denyMatch?.(resolved) != null)
545
306
  return false;
546
307
  return withinAnyRoot(boundary.roots, resolved);
547
308
  };
548
- const unlistedSegmentJudgedInside = (toks, from) => {
309
+ const unlistedSegmentJudgedInside = (words, from) => {
549
310
  if (boundary === undefined)
550
311
  return true;
551
312
  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);
313
+ for (let j = from; j < words.folded.length; j++) {
314
+ const word = words.folded[j];
565
315
  if (j > from && !endOfOptions && word === "--") {
566
316
  endOfOptions = true;
567
317
  continue;
@@ -580,47 +330,49 @@ function refusedCommandExempt(command, allow, boundary, depth, baseMovedOutside
580
330
  }
581
331
  return true;
582
332
  };
583
- for (const toks of segments) {
584
- for (let j = 0; j < toks.length; j++)
585
- if (toks[j] === "<" && !stdinFileJudgedInside(toks[j + 1]))
333
+ for (const { words, redirects } of segments) {
334
+ for (const r of redirects)
335
+ if (r.op === "<" && !stdinFileJudgedInside(r.target?.folded))
586
336
  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))
337
+ const pos = readProgramPosition(words);
338
+ if (pos.refusal !== undefined)
592
339
  return false;
593
- if (headWord.startsWith("-") && headWord !== "-")
340
+ for (const k of words.folded.keys()) {
341
+ const word = words.folded[k];
342
+ if (ASSIGNMENT_WORD.test(word) && /\$\(|`/.test(word) && (pos.assignments.includes(k) || pos.launchers.some((l) => l.index < k)))
343
+ return false;
344
+ }
345
+ const program = pos.program;
346
+ const listed = (name) => allow.has(name) || name === "cd";
347
+ if (program === undefined) {
348
+ if (pos.launchers.length === 0)
349
+ continue;
350
+ if (argvReentryUnjudged(pos, NO_PATH_OPERAND_COMMANDS))
351
+ return false;
352
+ if (!unlistedSegmentJudgedInside(words, pos.launchers[0].index))
353
+ return false;
354
+ continue;
355
+ }
356
+ if (program.name.includes(SUBSTITUTION_PLACEHOLDER) || /[$`]/.test(program.name))
594
357
  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")
358
+ if (argumentAsProgramRefusal(program.basename, program.basename, words.folded.slice(program.index + 1)) !== undefined)
598
359
  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)
360
+ if (SHELL_REENTRY_PROGRAM_RE.test(program.basename))
602
361
  return false;
603
- if (launcherUnwrap !== undefined) {
604
- const wrapped = runProgramOf(toks, i);
605
- if (wrapped === undefined || /[$`]/.test(wrapped) || SHELL_REENTRY_PROGRAM_RE.test(wrapped))
362
+ if (pos.launchers.length > 0) {
363
+ if (argvReentryUnjudged(pos, NO_PATH_OPERAND_COMMANDS))
606
364
  return false;
607
- const unwrapped = launcherUnwrap;
608
- if (argvReentryUnjudged(unwrapped))
609
- return false;
610
- if (unwrapped.kind === "unlisted") {
611
- if (!unlistedSegmentJudgedInside(toks, i))
365
+ if (!listed(program.basename)) {
366
+ if (!unlistedSegmentJudgedInside(words, pos.launchers[0].index))
612
367
  return false;
613
368
  continue;
614
369
  }
615
- head = unwrapped.head;
616
- i += unwrapped.index;
617
370
  }
618
- if (SHELL_REENTRY_PROGRAM_RE.test(head))
619
- return false;
371
+ const head = program.basename;
620
372
  if (head === "cd" || UNMODELLED_BASE_MOVERS.has(head))
621
373
  continue;
622
374
  if (!allow.has(head)) {
623
- if (!unlistedSegmentJudgedInside(toks, i))
375
+ if (!unlistedSegmentJudgedInside(words, program.index))
624
376
  return false;
625
377
  continue;
626
378
  }
@@ -629,110 +381,18 @@ function refusedCommandExempt(command, allow, boundary, depth, baseMovedOutside
629
381
  const flags = STDIN_ONLY_READER_FLAGS.get(head);
630
382
  if (flags === undefined)
631
383
  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;
384
+ for (let j = program.index + 1; j < words.folded.length; j++) {
385
+ if (!flags.test(words.folded[j]))
386
+ return false;
647
387
  }
648
388
  }
649
389
  return true;
650
390
  }
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
391
  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;
392
+ const lexed = lexShellText(segment, { mode: "strict", connectors: "text" });
393
+ if ("reject" in lexed)
394
+ return { folded: [], raw: [] };
395
+ return wordsOf(lexed.segments[0]);
736
396
  }
737
397
  function hasUnquotedExpansionMetachar(rawToken) {
738
398
  let open;
@@ -1304,20 +964,6 @@ const INTERPRETER_INLINE_PROGRAMS = new Map([
1304
964
  function interpreterKey(name) {
1305
965
  return name.replace(/\d+(?:\.\d+)*$/, "");
1306
966
  }
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
967
  function inlineProgramClause(model, args) {
1322
968
  let endOfOptions = false;
1323
969
  let banner = false;
@@ -1441,7 +1087,7 @@ function argumentAsProgramRefusal(written, name, args) {
1441
1087
  const interpreter = INTERPRETER_INLINE_PROGRAMS.get(interpreterKey(name));
1442
1088
  if (interpreter === undefined)
1443
1089
  return undefined;
1444
- const clause = inlineProgramClause(interpreter, withoutRedirectionWords(args));
1090
+ const clause = inlineProgramClause(interpreter, args);
1445
1091
  return clause === undefined ? undefined : unjudgedProgramTextRefusal(written, clause);
1446
1092
  }
1447
1093
  function guardedVerbFormReason(name, toks, raw) {
@@ -1834,13 +1480,8 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
1834
1480
  for (let si = 0; si < segments.length; si++) {
1835
1481
  const toks = tokenizeSegment(segments[si]);
1836
1482
  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
- }
1483
+ const pos = readProgramPosition(toks);
1484
+ const from = pos.written ?? folded.length;
1844
1485
  const written = folded[from];
1845
1486
  if (written === undefined)
1846
1487
  return { reason: "empty command" };
@@ -1858,15 +1499,11 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
1858
1499
  const name = pathPrefixed ? parsed.name.slice(parsed.name.lastIndexOf("/") + 1) : parsed.name;
1859
1500
  if (pathPrefixed)
1860
1501
  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));
1502
+ if (pos.refusal !== undefined)
1503
+ return { reason: programPositionRefusalSentence(pos.refusal), refusedWhole: true };
1504
+ const program = pos.program;
1505
+ if (program !== undefined) {
1506
+ const familyRefusal = argumentAsProgramRefusal(program.basename, program.basename, folded.slice(program.index + 1));
1870
1507
  if (familyRefusal !== undefined)
1871
1508
  return { reason: familyRefusal, refusedWhole: true };
1872
1509
  }
@@ -1880,20 +1517,18 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
1880
1517
  nameRefusal ??= `command "${parsed.name}" is not in the read-only allowlist`;
1881
1518
  if (SHELL_REENTRY_PROGRAM_RE.test(name))
1882
1519
  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 };
1520
+ if (pos.launchers.length > 0) {
1521
+ const listed = program !== undefined && (allow.has(program.basename) || program.basename === "cd");
1522
+ if (program !== undefined && !listed && SHELL_REENTRY_PROGRAM_RE.test(program.basename)) {
1523
+ return { reason: `"${written}" runs "${program.basename}", which runs its argument as shell text this walk does not judge ${NOT_AUTO_ALLOWED}`, refusedWhole: true };
1889
1524
  }
1890
- if (argvReentryUnjudged(unwrapped)) {
1525
+ if (argvReentryUnjudged(pos, NO_PATH_OPERAND_COMMANDS)) {
1891
1526
  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
1527
  }
1893
- if (unwrapped.kind === "unlisted")
1528
+ if (!listed || program === undefined)
1894
1529
  unlistedSegments.add(si);
1895
1530
  else
1896
- reheaded.set(si, { from: from + unwrapped.index, head: unwrapped.head });
1531
+ reheaded.set(si, { from: program.index, head: program.basename });
1897
1532
  continue;
1898
1533
  }
1899
1534
  unlistedSegments.add(si);
@@ -2040,10 +1675,10 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
2040
1675
  shapeRefusal ??= `multiple directory changes in one command require approval for clarity ${NOT_AUTO_ALLOWED}`;
2041
1676
  }
2042
1677
  if (boundary !== undefined) {
2043
- const walk = evaluateReadBoundary(foldedSegments, boundary, foldedReach);
1678
+ const walk = evaluateReadBoundary(foldedSegments, boundary, true, foldedReach);
2044
1679
  if (shapeRefusal === undefined)
2045
1680
  return walk;
2046
- const { reason: _walkSentence, ...structure } = walk;
1681
+ const { reason: _walkSentence, outOfRootGrantCandidate: _walkGrantCandidate, ...structure } = walk;
2047
1682
  return { ...structure, reason: shapeRefusal };
2048
1683
  }
2049
1684
  if (shapeRefusal !== undefined)
@@ -2054,7 +1689,7 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
2054
1689
  return {};
2055
1690
  }
2056
1691
  const BASE_CANDIDATE_MAX = 256;
2057
- function evaluateReadBoundary(foldedSegments, boundary, reach) {
1692
+ function evaluateReadBoundary(foldedSegments, boundary, mintGrantCandidate, reach) {
2058
1693
  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() };
2059
1694
  const outside = [];
2060
1695
  const inside = [];
@@ -2212,10 +1847,20 @@ function evaluateReadBoundary(foldedSegments, boundary, reach) {
2212
1847
  return { ...checkedField, ...undecidedField };
2213
1848
  const paths = outside.map((o) => `"${o.path}"`).join(", ");
2214
1849
  const allowed = boundary.roots.length > 0 ? boundary.roots.join(", ") : "(none)";
1850
+ const grantCandidate = (() => {
1851
+ if (!mintGrantCandidate || outOfRootSpellings.length > 0)
1852
+ return undefined;
1853
+ const proposed = outOfRootGrantCandidateOf(outside.map((o) => o.path), boundary);
1854
+ if (proposed === undefined)
1855
+ return undefined;
1856
+ const replay = evaluateReadBoundary(foldedSegments, { ...boundary, roots: [...boundary.roots, proposed] }, false, reach);
1857
+ return replay.reason === undefined && (replay.undecidedPaths ?? []).length === 0 ? proposed : undefined;
1858
+ })();
2215
1859
  return {
2216
1860
  reason: `"${outside[0].command}" reads ${paths}, outside the allowed directories for this session: ${allowed} ${NOT_AUTO_ALLOWED}`,
2217
1861
  outOfRootRead: true,
2218
1862
  outOfRootPaths: outside.map((o) => o.path),
1863
+ ...(grantCandidate !== undefined ? { outOfRootGrantCandidate: grantCandidate } : {}),
2219
1864
  ...outsideField,
2220
1865
  ...checkedField,
2221
1866
  ...undecidedField,
@@ -2225,44 +1870,21 @@ export function classifySimpleCommandReadBoundary(command, boundary) {
2225
1870
  const toks = tokenizeSegment(command);
2226
1871
  if (toks.folded.length === 0)
2227
1872
  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));
1873
+ const pos = readProgramPosition(toks);
1874
+ if (pos.refusal !== undefined)
1875
+ return { reason: programPositionRefusalSentence(pos.refusal), refusedWhole: true };
1876
+ if (pos.program !== undefined) {
1877
+ const familyRefusal = argumentAsProgramRefusal(pos.program.basename, pos.program.basename, toks.folded.slice(pos.program.index + 1));
2238
1878
  if (familyRefusal !== undefined)
2239
1879
  return { reason: familyRefusal, refusedWhole: true };
2240
1880
  }
2241
- return evaluateReadBoundary([toks], boundary);
1881
+ return evaluateReadBoundary([toks], boundary, true);
2242
1882
  }
2243
1883
  const NOT_GATED = { gated: false, paths: [] };
2244
- const ASSIGNMENT_WORD = /^[A-Za-z_][A-Za-z0-9_]*=/;
2245
1884
  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
1885
  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
1886
  export const COMMAND_LAUNCHERS_FOR_TEST = COMMAND_LAUNCHERS;
2262
1887
  const LAUNCHER_SCAN_HEAD = "cat";
2263
- function programBasename(word) {
2264
- return word.includes("/") ? (word.split("/").pop() ?? word) : word;
2265
- }
2266
1888
  export function classifyOutOfRootReadGate(command, allow, boundary) {
2267
1889
  const containmentOnly = {
2268
1890
  roots: boundary.roots,
@@ -2316,7 +1938,7 @@ export function classifyOutOfRootReadGate(command, allow, boundary) {
2316
1938
  return NOT_GATED;
2317
1939
  if (baseMoved)
2318
1940
  return { gated: true, paths: [] };
2319
- const verdict = evaluateReadBoundary(readers, containmentOnly, readerReach);
1941
+ const verdict = evaluateReadBoundary(readers, containmentOnly, false, readerReach);
2320
1942
  return verdict.outOfRootRead === true ? { gated: true, paths: verdict.outOfRootPaths ?? [] } : NOT_GATED;
2321
1943
  };
2322
1944
  const direct = judge(command);
@@ -2417,7 +2039,7 @@ export function classifyBoundedReadonlyPollLoopDetailed(command, allow, boundary
2417
2039
  if (readSegments.length === 0) {
2418
2040
  return { reason: "the loop body has no read command — a sleep-only loop observes nothing and is not auto-allowed" };
2419
2041
  }
2420
- const bodyHasCd = readSegments.some((seg) => tokensMoveBase(tokenizeSegment(seg), allow));
2042
+ const bodyHasCd = readSegments.some((seg) => tokensMoveBase(tokenizeSegment(seg)));
2421
2043
  const modelled = bodyHasCd ? Array.from({ length: beats }, () => readSegments.join("; ")).join("; ") : readSegments.join("; ");
2422
2044
  const verdict = classifyCompoundReadonlyDetailed(modelled, allow, boundary, { iterated: bodyHasCd });
2423
2045
  if (verdict.reason !== undefined)