@sema-agent/core 7.24.4 → 7.25.1

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 (107) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/dist/agents/peer-session-drain.d.ts +16 -1
  3. package/dist/agents/peer-session-drain.js +16 -1
  4. package/dist/agents/send-message-tool.d.ts +10 -2
  5. package/dist/agents/send-message-tool.js +18 -7
  6. package/dist/agents/subagent.js +13 -10
  7. package/dist/core/arg-summary.d.ts +0 -7
  8. package/dist/core/arg-summary.js +2 -1
  9. package/dist/core/background-agent-store.d.ts +3 -3
  10. package/dist/core/checkpoint-store.d.ts +1 -0
  11. package/dist/core/delegation-frames.d.ts +5 -0
  12. package/dist/core/gate-fold.js +85 -0
  13. package/dist/core/gate-lanes.js +23 -1
  14. package/dist/core/gate-outcome.d.ts +8 -2
  15. package/dist/core/gate-outcome.js +3 -1
  16. package/dist/core/hooks.d.ts +29 -0
  17. package/dist/core/hooks.js +2 -0
  18. package/dist/core/mailbox-store.d.ts +5 -0
  19. package/dist/core/mailbox-store.js +1 -1
  20. package/dist/core/parent-spec-seat.d.ts +7 -1
  21. package/dist/core/parent-spec-seat.js +2 -0
  22. package/dist/core/permission-rule-consent.js +29 -47
  23. package/dist/core/permission-rule-provider.js +4 -3
  24. package/dist/core/permission-rule-store.d.ts +143 -7
  25. package/dist/core/permission-rule-store.js +165 -15
  26. package/dist/core/runner/contracts.d.ts +36 -2
  27. package/dist/core/runner/gate-exit.d.ts +14 -0
  28. package/dist/core/runner/gate-exit.js +4 -0
  29. package/dist/core/runner/prepare-delegation-surface.d.ts +4 -4
  30. package/dist/core/runner/prepare-delegation-surface.js +2 -1
  31. package/dist/core/runner/prepare-gate-stations.d.ts +4 -0
  32. package/dist/core/runner/prepare-gate-stations.js +7 -4
  33. package/dist/core/runner/prepare-hands-readface.d.ts +8 -4
  34. package/dist/core/runner/prepare-hands-readface.js +25 -10
  35. package/dist/core/runner/prepare-inherited-gate.d.ts +23 -0
  36. package/dist/core/runner/prepare-inherited-gate.js +11 -6
  37. package/dist/core/runner/prepare-policy-chain.d.ts +15 -1
  38. package/dist/core/runner/prepare-policy-chain.js +14 -4
  39. package/dist/core/runner/prepare-suspend-saga.d.ts +5 -2
  40. package/dist/core/runner/prepare-suspend-saga.js +4 -3
  41. package/dist/core/runner/prepare-task.js +10 -9
  42. package/dist/core/runner/prepare-wiring-manifest.d.ts +11 -4
  43. package/dist/core/runner/prepare-wiring-manifest.js +8 -4
  44. package/dist/core/runner/resume-apply.js +2 -0
  45. package/dist/core/runner/run-harness-handlers.js +1 -0
  46. package/dist/core/runner/run-identity-wiring.d.ts +2 -2
  47. package/dist/core/runner/run-leg.js +26 -0
  48. package/dist/core/runner/run-notification-lane.d.ts +2 -2
  49. package/dist/core/runner/run-notification-lane.js +2 -1
  50. package/dist/core/runner/run-settle-and-teardown.js +1 -0
  51. package/dist/core/runner/session-rule-policy.d.ts +3 -4
  52. package/dist/core/runner/session-rule-policy.js +11 -3
  53. package/dist/core/session-policy-store.d.ts +27 -3
  54. package/dist/core/session-policy-store.js +12 -4
  55. package/dist/core/store-contracts/background-agent-store-contract.js +10 -0
  56. package/dist/core/store-contracts/peer-directory-contract.d.ts +23 -0
  57. package/dist/core/store-contracts/peer-directory-contract.js +82 -0
  58. package/dist/core/task-event.d.ts +6 -0
  59. package/dist/core/task-notification.d.ts +79 -4
  60. package/dist/core/task-notification.js +90 -1
  61. package/dist/core/task-registry-agent.js +1 -2
  62. package/dist/core/task-registry-monitor.js +2 -1
  63. package/dist/core/task-registry-shared.d.ts +53 -5
  64. package/dist/core/task-registry-shared.js +13 -2
  65. package/dist/core/task-registry.js +8 -5
  66. package/dist/core/task-spec.d.ts +12 -5
  67. package/dist/core/tool-catalog-entries.js +1 -0
  68. package/dist/core/tool-policy.js +30 -23
  69. package/dist/core/tool-spec.d.ts +1 -1
  70. package/dist/core/wiring-manifest.d.ts +2 -2
  71. package/dist/engine/loop/agent-loop.js +1 -1
  72. package/dist/engine/loop/types.d.ts +4 -0
  73. package/dist/index.d.ts +6 -3
  74. package/dist/index.js +4 -3
  75. package/dist/prompts/coordinator.d.ts +2 -1
  76. package/dist/stores/file/adoption/adopt.js +23 -1
  77. package/dist/stores/file/permission-rule-store.js +3 -1
  78. package/dist/stores/file/session-policy-store.d.ts +31 -0
  79. package/dist/stores/file/session-policy-store.js +87 -7
  80. package/dist/tools/fs/bash-lexer.d.ts +13 -0
  81. package/dist/tools/fs/bash-lexer.js +61 -0
  82. package/dist/tools/fs/bash-program-position.d.ts +36 -29
  83. package/dist/tools/fs/bash-program-position.js +33 -13
  84. package/dist/tools/fs/bash-readonly-classifier.d.ts +26 -313
  85. package/dist/tools/fs/bash-readonly-classifier.js +81 -782
  86. package/dist/tools/fs/fs-bash.d.ts +43 -207
  87. package/dist/tools/fs/fs-bash.js +20 -341
  88. package/dist/tools/fs/index.d.ts +12 -13
  89. package/dist/tools/fs/index.js +3 -4
  90. package/dist/tools/fs/read-deny.d.ts +25 -0
  91. package/dist/tools/fs/read-deny.js +19 -0
  92. package/dist/tools/fs/safety.js +2 -6
  93. package/dist/tools/fs/shell-read-check.d.ts +99 -0
  94. package/dist/tools/fs/shell-read-check.js +229 -0
  95. package/dist/tools/fs/shell-read-establish.d.ts +70 -115
  96. package/dist/tools/fs/shell-read-establish.js +216 -102
  97. package/dist/tools/fs/shell-read-judge.d.ts +231 -0
  98. package/dist/tools/fs/shell-read-judge.js +170 -0
  99. package/dist/tools/fs/shell-read-plan.d.ts +3 -0
  100. package/dist/tools/fs/shell-read-plan.js +88 -27
  101. package/dist/tools/monitor.d.ts +5 -5
  102. package/dist/tools/monitor.js +3 -5
  103. package/dist/tools/task-list.d.ts +4 -0
  104. package/package.json +1 -1
  105. package/test/export-surface.snapshot.json +19 -3
  106. package/wire-changes/core@7.25.0.json +671 -0
  107. package/wire-changes/core@7.25.1.json +64 -0
@@ -1,7 +1,6 @@
1
- import { sensitivePathDenyList } from "./read-deny.js";
2
- import { hasWin32AliasedComponent, isAbsoluteForFamily, isAbsolutePathForm, isBlockedDevicePath, isShellRootedSpellingUnmapped, joinForFamily, nativeUncSpellingOf, normalizeAbsPathLexically, win32NamespaceScreen, pathFamilyOf, withinAnyRoot } from "./safety.js";
3
- import { lexShellText, literalTildeSpelling, quoteMask, substitutionsInHeredocBody, SUBSTITUTION_PLACEHOLDER, wordsOf, } from "./bash-lexer.js";
4
- import { ASSIGNMENT_WORD, COMMAND_LAUNCHERS, NOT_AUTO_ALLOWED, SHELL_BLOCK_WORDS, argvReentryUnjudged, isBareControlWord, programPositionRunsShell, shellScanDeclineOf, programBasename, programPositionRefusalSentence, readProgramPosition, } from "./bash-program-position.js";
1
+ import { isAbsoluteForFamily, isAbsolutePathForm, isBlockedDevicePath, isShellRootedSpellingUnmapped, joinForFamily, nativeUncSpellingOf, normalizeAbsPathLexically, win32NamespaceScreen, pathFamilyOf } from "./safety.js";
2
+ import { lexShellText, literalTildeSpelling, ansiCQuoteClose, dollarOpensQuote, quoteMask, wordsOf, } from "./bash-lexer.js";
3
+ import { COMMAND_LAUNCHERS, NOT_AUTO_ALLOWED, SHELL_BLOCK_WORDS, argvReentryUnjudged, isBareControlWord, programPositionRefusalSentence, ASSIGNMENT_PREFIX, readProgramPosition, writtenAssignmentPrefix, } from "./bash-program-position.js";
5
4
  export const BASH_READONLY_DEFAULT_ALLOW = [
6
5
  "ls", "cat", "head", "tail", "wc", "pwd", "echo", "whoami", "uname",
7
6
  "grep", "cut", "tr", "basename", "dirname", "stat", "du", "df", "which",
@@ -154,55 +153,12 @@ function stripExemptRedirections(command) {
154
153
  flush();
155
154
  return out;
156
155
  }
157
- const POLL_LOOP_CAUSE_MAX_PATHS = 3;
158
156
  export function formatOutOfRootReadApprovalOption(directory) {
159
157
  const trimmed = directory.replace(/[/\\]+$/, "");
160
158
  const cut = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
161
159
  const leaf = cut >= 0 ? trimmed.slice(cut + 1) : trimmed;
162
160
  return `Yes, allow reading from ${leaf || directory}/ from this project`;
163
161
  }
164
- function parentDirLexically(p) {
165
- return normalizeAbsPathLexically(`${p}/..`);
166
- }
167
- export function deepestCommonDir(a, b) {
168
- if (a === b)
169
- return a;
170
- const shorter = Math.min(a.length, b.length);
171
- let end = 0;
172
- let i = 0;
173
- while (i < shorter && a[i] === b[i]) {
174
- if (a[i] === "/" || a[i] === "\\")
175
- end = i;
176
- i++;
177
- }
178
- if (i === shorter && (a[shorter] === "/" || a[shorter] === "\\" || b[shorter] === "/" || b[shorter] === "\\"))
179
- end = shorter;
180
- const cut = a.slice(0, end);
181
- const candidate = cut.includes("/") || cut.includes("\\") ? cut : a.slice(0, end + 1);
182
- if (candidate.length === 0 || !withinAnyRoot([candidate], a) || !withinAnyRoot([candidate], b))
183
- return undefined;
184
- return candidate;
185
- }
186
- function outOfRootGrantCandidateOf(paths, boundary) {
187
- if (paths.length === 0)
188
- return undefined;
189
- if (paths.some(hasWin32AliasedComponent))
190
- return undefined;
191
- let candidate = parentDirLexically(paths[0]);
192
- for (const path of paths.slice(1)) {
193
- if (candidate === undefined)
194
- return undefined;
195
- candidate = deepestCommonDir(candidate, parentDirLexically(path));
196
- }
197
- if (candidate === undefined)
198
- return undefined;
199
- const tooWide = parentDirLexically(candidate) === candidate ||
200
- (boundary.homeDir !== undefined && withinAnyRoot([candidate], boundary.homeDir)) ||
201
- boundary.roots.some((root) => withinAnyRoot([candidate], root));
202
- if (tooWide)
203
- return paths.length === 1 ? { path: paths[0], exact: true } : undefined;
204
- return { path: candidate };
205
- }
206
162
  export const NO_PATH_OPERAND_COMMANDS = new Set([
207
163
  "pwd", "echo", "printf", "whoami", "uname", "which", "tr", "basename", "dirname",
208
164
  "cal", "uptime", "id", "groups", "nproc", "locale", "free", "true", "false", "seq", "expr", "type", "sleep",
@@ -243,193 +199,7 @@ function spellingsOf(absolute) {
243
199
  function reheadSegment(toks, head, from = 0) {
244
200
  return { folded: [head, ...toks.folded.slice(from + 1)], raw: [head, ...toks.raw.slice(from + 1)] };
245
201
  }
246
- function tokensMoveBase(toks) {
247
- const pos = readProgramPosition(toks);
248
- if (pos.written === undefined)
249
- return false;
250
- const written = toks.folded[pos.written];
251
- if (toks.raw[pos.written] !== written)
252
- return true;
253
- if (pos.assignments.length > 0)
254
- return false;
255
- if (pos.launchers.length === 0) {
256
- if (written.startsWith("-") && written !== "-")
257
- return true;
258
- return written === "cd";
259
- }
260
- if (!COMMAND_LAUNCHERS.has(written))
261
- return false;
262
- return pos.refusal !== undefined || pos.program?.basename === "cd";
263
- }
264
202
  export const SHELL_REENTRY_PROGRAM_RE = /^(?:(?:ba|z|da|k|fi|a|mk|ya|pdk|c|tc)?sh|eval|source|\.|parallel)$/;
265
- export function refusedCommandMayRead(command, allow, boundary, declinesAreEvidence) {
266
- const scan = { declined: false };
267
- const exempt = refusedCommandExempt(command, allow, boundary, declinesAreEvidence, scan, 0);
268
- return { mayRead: !exempt, declined: scan.declined };
269
- }
270
- const STDIN_ONLY_READER_FLAGS = new Map([
271
- ["cat", /^-[AbeEnstTuv]+$/],
272
- ["wc", /^-[clmwL]+$/],
273
- ]);
274
- function whitelistSegment(segment) {
275
- const tokens = [...segment.tokens];
276
- for (let k = 0; k + 2 < tokens.length; k++) {
277
- const [a, b, c] = [tokens[k], tokens[k + 1], tokens[k + 2]];
278
- if (a.kind === "word" && b.kind === "group" && b.op === "(" && c.kind === "group" && c.op === ")" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(a.folded))
279
- tokens.splice(k, 3);
280
- }
281
- const words = tokens.filter((t) => t.kind === "word" && !(t.raw === t.folded && (t.folded === "{" || t.folded === "}")));
282
- let from = 0;
283
- while (from < words.length && SHELL_BLOCK_WORDS.has(words[from].folded))
284
- from++;
285
- return {
286
- words: { folded: words.slice(from).map((w) => w.folded), raw: words.slice(from).map((w) => w.raw) },
287
- redirects: tokens.filter((t) => t.kind === "redirect"),
288
- };
289
- }
290
- function refusedCommandExempt(command, allow, boundary, declinesAreEvidence, scan, depth, baseMovedOutside = false) {
291
- if (depth > 4)
292
- return false;
293
- const lexed = lexShellText(command, { mode: "lenient" });
294
- if ("reject" in lexed)
295
- return false;
296
- const bodies = [...lexed.substitutions];
297
- const segments = [];
298
- for (const segment of lexed.segments) {
299
- for (const t of segment.tokens) {
300
- if (t.kind !== "heredoc" || t.quoted)
301
- continue;
302
- const inner = substitutionsInHeredocBody(t.body);
303
- if (inner === undefined)
304
- return false;
305
- for (const body of inner)
306
- bodies.push(body);
307
- }
308
- segments.push(whitelistSegment(segment));
309
- }
310
- let declined;
311
- for (const { words } of segments) {
312
- const pos = readProgramPosition(words);
313
- if (programPositionRunsShell(pos, SHELL_REENTRY_PROGRAM_RE))
314
- return false;
315
- const decline = shellScanDeclineOf(pos, XARGS_TARGETS_ACCEPTED_UNJUDGED);
316
- if (decline !== undefined)
317
- scan.declined = true;
318
- if (decline !== undefined && declinesAreEvidence.has(decline))
319
- return false;
320
- declined ??= decline;
321
- }
322
- if (declined !== undefined)
323
- return true;
324
- const baseMoved = baseMovedOutside ||
325
- segments.some(({ words }) => {
326
- const runs = readProgramPosition(words).program?.basename;
327
- return runs === "cd" || (runs !== undefined && UNMODELLED_BASE_MOVERS.has(runs));
328
- });
329
- for (const body of bodies) {
330
- const walked = classifyCompoundReadonlyDetailed(body, allow, boundary);
331
- const fullyGreen = !baseMoved && walked.reason === undefined && walked.readDenied !== true && walked.outOfRootRead !== true && walked.refusedWhole !== true &&
332
- (walked.undecidedPaths?.length ?? 0) === 0 && (walked.unresolvedOperands?.length ?? 0) === 0 && (walked.recursiveReadPaths?.length ?? 0) === 0;
333
- if (fullyGreen)
334
- continue;
335
- if (!refusedCommandExempt(body, allow, boundary, declinesAreEvidence, scan, depth + 1, baseMoved))
336
- return false;
337
- }
338
- const stdinFileJudgedInside = (word) => {
339
- if (word === undefined || word === "" || word.includes(SUBSTITUTION_PLACEHOLDER) || UNREADABLE_WHITELIST_WORD.test(word))
340
- return false;
341
- if (boundary === undefined || baseMoved)
342
- return false;
343
- const resolved = resolveOperandLexically(boundary.cwd ?? boundary.roots[0], word, boundary.homeDir);
344
- if (resolved === undefined || boundary.denyMatch?.(resolved) != null)
345
- return false;
346
- return boundary.face === "open" || withinAnyRoot(boundary.roots, resolved);
347
- };
348
- const unlistedSegmentJudgedInside = (words, from, leadingAssignments = []) => {
349
- if (boundary === undefined)
350
- return true;
351
- const assignmentJudgedInside = (word) => {
352
- const payload = assignmentWordPayload(word);
353
- return payload === undefined || (payload.kind === "value" && stdinFileJudgedInside(payload.value));
354
- };
355
- for (const k of leadingAssignments) {
356
- if (k >= from)
357
- continue;
358
- if (!assignmentJudgedInside(words.folded[k]))
359
- return false;
360
- }
361
- const argv = argvProgramOf(words);
362
- const argvStart = argv?.from ?? words.folded.length;
363
- const declaredNotPaths = ASSIGNMENT_VALUE_NOT_PATHS.has(argv?.program ?? "");
364
- let endOfOptions = false;
365
- for (let j = from; j < words.folded.length; j++) {
366
- const word = words.folded[j];
367
- if (j > from && !endOfOptions && word === "--") {
368
- endOfOptions = true;
369
- continue;
370
- }
371
- if (j === from && !isPathShapedToken(word)) {
372
- if (UNREADABLE_WHITELIST_WORD.test(word) || word.includes(SUBSTITUTION_PLACEHOLDER))
373
- return false;
374
- continue;
375
- }
376
- const optionShaped = !endOfOptions && word.startsWith("-") && word !== "-";
377
- const candidates = optionShaped ? [...attachedOptionPayloads(word), ...(isPathShapedToken(word) ? [word] : [])] : [word];
378
- for (const candidate of candidates) {
379
- if (!stdinFileJudgedInside(candidate))
380
- return false;
381
- }
382
- if (!optionShaped && (j < argvStart || !declaredNotPaths) && !assignmentJudgedInside(word))
383
- return false;
384
- }
385
- return true;
386
- };
387
- for (const { words, redirects } of segments) {
388
- for (const r of redirects)
389
- if (r.op === "<" && !stdinFileJudgedInside(r.target?.folded))
390
- return false;
391
- const pos = readProgramPosition(words);
392
- for (const k of words.folded.keys()) {
393
- const word = words.folded[k];
394
- if (ASSIGNMENT_WORD.test(word) && /\$\(|`/.test(word) && (pos.assignments.includes(k) || pos.launchers.some((l) => l.index < k)))
395
- return false;
396
- }
397
- const program = pos.program;
398
- const listed = (name) => allow.has(name) || name === "cd";
399
- if (program === undefined) {
400
- if (pos.launchers.length === 0)
401
- continue;
402
- if (!unlistedSegmentJudgedInside(words, pos.launchers[0].index, pos.assignments))
403
- return false;
404
- continue;
405
- }
406
- if (program.name.includes(SUBSTITUTION_PLACEHOLDER) || /[$`]/.test(program.name))
407
- return false;
408
- if (pos.launchers.length > 0 && !listed(program.basename)) {
409
- if (!unlistedSegmentJudgedInside(words, pos.launchers[0].index, pos.assignments))
410
- return false;
411
- continue;
412
- }
413
- const head = program.basename;
414
- if (head === "cd" || UNMODELLED_BASE_MOVERS.has(head))
415
- continue;
416
- if (!allow.has(head)) {
417
- if (!unlistedSegmentJudgedInside(words, program.index, pos.assignments))
418
- return false;
419
- continue;
420
- }
421
- if (NO_PATH_OPERAND_COMMANDS.has(head))
422
- continue;
423
- const flags = STDIN_ONLY_READER_FLAGS.get(head);
424
- if (flags === undefined)
425
- return false;
426
- for (let j = program.index + 1; j < words.folded.length; j++) {
427
- if (!flags.test(words.folded[j]))
428
- return false;
429
- }
430
- }
431
- return true;
432
- }
433
203
  function tokenizeSegment(segment) {
434
204
  const lexed = lexShellText(segment, { mode: "strict", connectors: "text" });
435
205
  if ("reject" in lexed)
@@ -438,7 +208,21 @@ function tokenizeSegment(segment) {
438
208
  }
439
209
  export function hasUnquotedExpansionMetachar(rawToken) {
440
210
  let open;
441
- for (const ch of rawToken) {
211
+ for (let i = 0; i < rawToken.length; i++) {
212
+ const ch = rawToken[i];
213
+ const opener = rawToken[i + 1];
214
+ if (open === undefined && ch === "$" && dollarOpensQuote(rawToken, i)) {
215
+ if (opener === '"') {
216
+ open = opener;
217
+ i += 1;
218
+ continue;
219
+ }
220
+ const close = ansiCQuoteClose(rawToken, i + 1);
221
+ if (close === undefined)
222
+ return true;
223
+ i = close;
224
+ continue;
225
+ }
442
226
  if (open === undefined && (ch === '"' || ch === "'")) {
443
227
  open = ch;
444
228
  continue;
@@ -922,7 +706,7 @@ function sedReadingKind(reading) {
922
706
  }
923
707
  function readSedScript(script) {
924
708
  if (script.split("\n").some((line) => utf8Length(line) >= SED_SCRIPT_LINE_LIMIT))
925
- return "unseen";
709
+ return "oversized";
926
710
  let i = 0;
927
711
  let depth = 0;
928
712
  const len = script.length;
@@ -1231,8 +1015,10 @@ export function sedScriptSlotFiles(args) {
1231
1015
  const writes = new Set();
1232
1016
  const commands = new Set();
1233
1017
  let executesPatternSpace = false;
1018
+ const kinds = [];
1234
1019
  for (const dialect of SED_OPTION_DIALECTS) {
1235
1020
  const reading = readSedInvocationDetailed(args, dialect);
1021
+ kinds.push(sedReadingKind(reading));
1236
1022
  if (typeof reading === "string")
1237
1023
  continue;
1238
1024
  for (const f of reading.names.reads)
@@ -1243,12 +1029,17 @@ export function sedScriptSlotFiles(args) {
1243
1029
  commands.add(c);
1244
1030
  executesPatternSpace ||= reading.names.executesPatternSpace;
1245
1031
  }
1246
- return { reads: [...reads], writes: [...writes], commands: [...commands], executesPatternSpace };
1032
+ const unread = kinds.includes("oversized")
1033
+ ? "a script line is longer than an implementation compiles whole, so what runs is not the text written here"
1034
+ : kinds.every((k) => k === "unparsed")
1035
+ ? "its script could not be read to its end"
1036
+ : undefined;
1037
+ return { reads: [...reads], writes: [...writes], commands: [...commands], executesPatternSpace, ...(unread !== undefined ? { unread } : {}) };
1247
1038
  }
1248
1039
  const SCRIPT_SLOT_VERBS = new Map([
1249
1040
  ["sed", (args) => {
1250
1041
  const readings = SED_OPTION_DIALECTS.map((dialect) => readSedInvocation(args, dialect));
1251
- if (readings.includes("names") || readings.includes("unseen"))
1042
+ if (readings.includes("names") || readings.includes("unseen") || readings.includes("oversized"))
1252
1043
  return true;
1253
1044
  return readings.every((reading) => reading === "unparsed");
1254
1045
  }],
@@ -1667,28 +1458,51 @@ function isGrepFileStdinLongOption(tok) {
1667
1458
  return name !== undefined && isLongOptionAbbrevOf(name, "file");
1668
1459
  }
1669
1460
  const CLUSTER_OPTION_CHAR = /[A-Za-z0-9]/;
1670
- const ASSIGNMENT_OR_APPEND_WORD = /^[A-Za-z_][A-Za-z0-9_]*\+?=/;
1671
- export function assignmentWordPayload(word) {
1672
- if (!ASSIGNMENT_OR_APPEND_WORD.test(word))
1461
+ function tildeExpandsInValue(rawValue) {
1462
+ if (rawValue === undefined)
1463
+ return false;
1464
+ for (let at = 0; at <= rawValue.length;) {
1465
+ if (rawValue[at] === "~") {
1466
+ let quoted = false;
1467
+ for (let k = at + 1; k < rawValue.length && rawValue[k] !== "/" && rawValue[k] !== ":"; k++) {
1468
+ if (rawValue[k] === "'" || rawValue[k] === '"' || rawValue[k] === "\\") {
1469
+ quoted = true;
1470
+ break;
1471
+ }
1472
+ }
1473
+ if (!quoted)
1474
+ return true;
1475
+ }
1476
+ const next = rawValue.indexOf(":", at);
1477
+ if (next < 0)
1478
+ return false;
1479
+ at = next + 1;
1480
+ }
1481
+ return false;
1482
+ }
1483
+ export function assignmentWordPayload(word, raw = word) {
1484
+ const written = writtenAssignmentPrefix(word);
1485
+ if (written === undefined)
1673
1486
  return undefined;
1674
- const eq = word.indexOf("=");
1675
- const value = word.slice(eq + 1);
1676
- if (word[eq - 1] === "+")
1677
- return { kind: "unresolved", reason: `"${word}" appends to a value the text does not contain` };
1678
- if (value.includes("~"))
1487
+ const eq = written.prefix.length - 1;
1488
+ const spelled = word.slice(eq + 1);
1489
+ const expands = tildeExpandsInValue(raw.startsWith(written.prefix) ? raw.slice(written.prefix.length) : undefined);
1490
+ const value = expands ? spelled : literalTildeSpelling(spelled);
1491
+ if (written.appends) {
1492
+ const reason = `"${word}" appends to a value the text does not contain`;
1493
+ return spelled === "" || expands ? { kind: "unresolved", reason } : { kind: "unresolved", reason, value };
1494
+ }
1495
+ if (expands)
1679
1496
  return { kind: "unresolved", reason: `"${word}" carries a tilde whose expansion the text does not settle` };
1680
- return value === "" ? undefined : { kind: "value", value };
1497
+ return spelled === "" ? undefined : { kind: "value", value };
1681
1498
  }
1682
1499
  export const ASSIGNMENT_VALUE_NOT_PATHS = new Set(["make", "gmake", "bmake", "cmake", "ninja", "meson", "docker", "podman"]);
1683
1500
  export function argvProgramOf(tokens) {
1684
- let lead = 0;
1685
- while (lead < tokens.folded.length && ASSIGNMENT_OR_APPEND_WORD.test(tokens.folded[lead]))
1686
- lead++;
1687
- const pos = readProgramPosition({ folded: tokens.folded.slice(lead), raw: tokens.raw.slice(lead) });
1501
+ const pos = readProgramPosition(tokens);
1688
1502
  if (pos.program === undefined)
1689
1503
  return undefined;
1690
- const at = lead + pos.program.index;
1691
- return ASSIGNMENT_OR_APPEND_WORD.test(tokens.folded[at] ?? "") ? undefined : { program: pos.program.basename, from: at };
1504
+ const at = pos.program.index;
1505
+ return ASSIGNMENT_PREFIX.test(tokens.folded[at] ?? "") ? undefined : { program: pos.program.basename, from: at };
1692
1506
  }
1693
1507
  export function attachedOptionPayloads(tok) {
1694
1508
  const literal = literalTildeSpelling;
@@ -1711,182 +1525,6 @@ export function attachedOptionPayloads(tok) {
1711
1525
  }
1712
1526
  return out;
1713
1527
  }
1714
- function collectSegmentBoundaryFindings(tokens, boundary) {
1715
- const name = tokens.folded[0];
1716
- const slotVerb = tokens.slotVerb ?? name;
1717
- const declaredNotPaths = ASSIGNMENT_VALUE_NOT_PATHS.has(tokens.argvProgram ?? slotVerb);
1718
- const argvFrom = tokens.argvFrom ?? (tokens.slotVerb === undefined ? 1 : Number.MAX_SAFE_INTEGER);
1719
- if (NO_PATH_OPERAND_COMMANDS.has(name))
1720
- return [];
1721
- const declaresNoPathOperands = tokens.slotVerb !== undefined && NO_PATH_OPERAND_COMMANDS.has(tokens.slotVerb) && tokens.argvFrom !== undefined;
1722
- const args = declaresNoPathOperands ? tokens.folded.slice(1, tokens.argvFrom + 1) : tokens.folded.slice(1);
1723
- const findings = [];
1724
- const candidates = [];
1725
- const bareWordOperands = [];
1726
- const judgeOperand = (text, globbed) => {
1727
- (isPathShapedToken(text) || isAbsolutePathForm(text) ? candidates : bareWordOperands).push({ text, globbed });
1728
- };
1729
- const positionalOperands = [];
1730
- const argvOperandsStartAt = tokens.slotVerb !== undefined ? 1 : 0;
1731
- let stdinOperand = false;
1732
- const argGlobs = (k) => {
1733
- const raw = tokens.raw[k + 1];
1734
- return raw !== undefined && hasUnquotedGlobMetachar(raw);
1735
- };
1736
- const expandingArgs = new Set();
1737
- tokens.raw.slice(1).forEach((rawArg, k) => {
1738
- if (!hasUnquotedExpansionMetachar(rawArg))
1739
- return;
1740
- expandingArgs.add(k);
1741
- findings.push({
1742
- kind: "unresolvable",
1743
- operand: rawArg,
1744
- reason: `"${name}" is given the argument "${rawArg}", which the shell expands (brace/variable/command expansion) before the command runs — the path it would actually read cannot be resolved statically, so it is not auto-allowed`,
1745
- });
1746
- });
1747
- if (name === "cd" && expandingArgs.size > 0)
1748
- return findings;
1749
- if (name === "cd") {
1750
- const targetIdx = args.findIndex((t) => !t.startsWith("-") || t === "-");
1751
- const target = targetIdx === -1 ? undefined : args[targetIdx];
1752
- if (target !== undefined && argGlobs(targetIdx)) {
1753
- return [{ kind: "unresolvable", operand: target, reason: `\`cd\` is given the pattern "${target}", which the shell expands to a directory this check cannot know ${NOT_AUTO_ALLOWED}` }];
1754
- }
1755
- if (target === undefined) {
1756
- return [{ kind: "unresolvable", operand: "cd", reason: '`cd` with no argument targets the home directory, which cannot be checked against the allowed directories ' + NOT_AUTO_ALLOWED }];
1757
- }
1758
- if (target === "-") {
1759
- return [{ kind: "unresolvable", operand: "-", reason: '`cd -` targets the previous working directory, which cannot be resolved statically ' + NOT_AUTO_ALLOWED }];
1760
- }
1761
- candidates.push({ text: target, globbed: false });
1762
- }
1763
- else {
1764
- const eoo = args.indexOf("--");
1765
- const firstOperandSlot = VERB_OPTION_SLOTS[name]?.firstOperand;
1766
- const firstOperandSuppliedByFlag = firstOperandSlot !== undefined && (eoo === -1 ? args : args.slice(0, eoo)).some((t) => suppliesFirstOperandSlot(firstOperandSlot, t));
1767
- let sawOperand = false;
1768
- let sawArgvOperand = false;
1769
- let endOfOptions = false;
1770
- for (let k = 0; k < args.length; k++) {
1771
- const t = args[k];
1772
- if (expandingArgs.has(k))
1773
- continue;
1774
- if (!endOfOptions) {
1775
- if (t === "--") {
1776
- endOfOptions = true;
1777
- continue;
1778
- }
1779
- if (t.startsWith("-") && t !== "-") {
1780
- const slot = readVerbOptionSlot(slotVerb, t);
1781
- const permutes = !verbOptionsArePositionIndependent(slotVerb);
1782
- if (sawArgvOperand && permutes) {
1783
- judgeOperand(t, argGlobs(k));
1784
- if (slot !== undefined && slot.form === "separated") {
1785
- const ahead = args[k + 1];
1786
- if (ahead !== undefined && !expandingArgs.has(k + 1)) {
1787
- for (const c of slotValueCandidates(ahead, slot.kind))
1788
- judgeOperand(c, argGlobs(k + 1));
1789
- }
1790
- }
1791
- }
1792
- if (slot !== undefined && slot.form !== "unexplained" && !(slot.form === "separated" && sawArgvOperand && permutes)) {
1793
- if (slot.form === "attached") {
1794
- for (const c of slotValueCandidates(literalTildeSpelling(slot.value), slot.kind))
1795
- judgeOperand(c, argGlobs(k));
1796
- }
1797
- else if (slot.form === "separated") {
1798
- const value = args[++k];
1799
- if (value !== undefined && !expandingArgs.has(k)) {
1800
- const named = slotValueCandidates(value, slot.kind);
1801
- for (const c of named)
1802
- judgeOperand(c, argGlobs(k));
1803
- if (named.length > 0)
1804
- positionalOperands.push(value);
1805
- }
1806
- }
1807
- continue;
1808
- }
1809
- for (const payload of attachedOptionPayloads(t))
1810
- judgeOperand(payload, argGlobs(k));
1811
- continue;
1812
- }
1813
- }
1814
- const inFirstOperandSlot = firstOperandSlot !== undefined && !firstOperandSuppliedByFlag && !sawOperand;
1815
- sawOperand = true;
1816
- if (k >= argvOperandsStartAt)
1817
- sawArgvOperand = true;
1818
- if (t === "-") {
1819
- if (inFirstOperandSlot)
1820
- continue;
1821
- const m = RECURSIVE_READ_FORMS[slotVerb];
1822
- if (m === undefined || m.dashIsStdin === true) {
1823
- stdinOperand = true;
1824
- continue;
1825
- }
1826
- if (k >= argvOperandsStartAt)
1827
- positionalOperands.push(t);
1828
- continue;
1829
- }
1830
- if (inFirstOperandSlot) {
1831
- const named = slotValueCandidates(t, firstOperandSlot.kind);
1832
- for (const c of named)
1833
- judgeOperand(c, argGlobs(k));
1834
- if (named.length > 0 && k >= argvOperandsStartAt)
1835
- positionalOperands.push(t);
1836
- continue;
1837
- }
1838
- if (k >= argvOperandsStartAt)
1839
- positionalOperands.push(t);
1840
- judgeOperand(t, argGlobs(k));
1841
- const payload = assignmentWordPayload(t);
1842
- if (payload !== undefined && (k + 1 < argvFrom || !declaredNotPaths)) {
1843
- if (payload.kind === "value")
1844
- judgeOperand(payload.value, argGlobs(k));
1845
- else
1846
- findings.push({ kind: "unresolvable", operand: t, reason: `"${name}": ${payload.reason} ${NOT_AUTO_ALLOWED}` });
1847
- }
1848
- }
1849
- }
1850
- for (let k = 0; k < args.length; k++) {
1851
- if (!argGlobs(k) || expandingArgs.has(k))
1852
- continue;
1853
- const r = resolveOperandSpellings(boundary.cwd ?? boundary.roots[0], args[k], boundary.homeDir);
1854
- findings.push({ kind: "undecided", path: r?.folded ?? args[k], ...(r?.spelled !== undefined ? { spelled: r.spelled } : {}) });
1855
- }
1856
- for (const { text: candidate, globbed } of candidates) {
1857
- const spellings = resolveOperandSpellings(boundary.cwd ?? boundary.roots[0], candidate, boundary.homeDir);
1858
- if (spellings === undefined) {
1859
- findings.push({
1860
- kind: "unresolvable",
1861
- operand: candidate,
1862
- reason: `"${name}" names the path "${candidate}", which cannot be resolved statically ${NOT_AUTO_ALLOWED}`,
1863
- });
1864
- continue;
1865
- }
1866
- const resolved = spellings.folded;
1867
- if (withinAnyRoot(boundary.roots, resolved)) {
1868
- if (!globbed)
1869
- findings.push({ kind: "inside", path: resolved, ...(spellings.spelled !== undefined ? { spelled: spellings.spelled } : {}) });
1870
- }
1871
- else
1872
- findings.push({ kind: "outside", command: name, path: resolved, ...(spellings.spelled !== undefined ? { spelled: spellings.spelled } : {}) });
1873
- }
1874
- for (const { text: operand, globbed } of bareWordOperands) {
1875
- const spellings = resolveOperandSpellings(boundary.cwd ?? boundary.roots[0], operand, boundary.homeDir);
1876
- if (spellings === undefined)
1877
- continue;
1878
- if (!globbed && withinAnyRoot(boundary.roots, spellings.folded))
1879
- findings.push({ kind: "inside", path: spellings.folded, ...(spellings.spelled !== undefined ? { spelled: spellings.spelled } : {}) });
1880
- }
1881
- if (boundary.denyMatch !== undefined && (boundary.denyEntries?.length ?? 1) > 0 && segmentSelectsRecursiveRead(slotVerb, args.slice(argvOperandsStartAt))) {
1882
- const roots = positionalOperands.length > 0 ? positionalOperands : stdinOperand ? [] : ["."];
1883
- for (const operand of roots) {
1884
- const resolved = resolveOperandLexically(boundary.cwd ?? boundary.roots[0], operand, boundary.homeDir);
1885
- findings.push({ kind: "recursive", path: resolved ?? operand });
1886
- }
1887
- }
1888
- return findings;
1889
- }
1890
1528
  export function foldHomeVariableSpelling(command, homeDir) {
1891
1529
  if (homeDir === undefined || !command.includes("$") || /[\\`]/.test(command))
1892
1530
  return command;
@@ -1945,11 +1583,11 @@ function segmentCompoundForReadonly(command, homeDir) {
1945
1583
  : redirectionStripped;
1946
1584
  return splitShellCompoundSegments(terminatorStripped, { trailingTerminator: "keep" });
1947
1585
  }
1948
- export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts) {
1586
+ export function classifyCompoundReadonlyDetailed(command, allow, boundary) {
1949
1587
  const split = segmentCompoundForReadonly(command, boundary?.homeDir);
1950
1588
  if ("reject" in split)
1951
- return { reason: split.reject, refusedWhole: true };
1952
- const { segments, pipeFed, connectors } = split;
1589
+ return { reason: split.reject };
1590
+ const { segments, pipeFed } = split;
1953
1591
  let nameRefusal;
1954
1592
  const unlistedSegments = new Set();
1955
1593
  const reheaded = new Map();
@@ -1962,7 +1600,7 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
1962
1600
  if (written === undefined)
1963
1601
  return { reason: "empty command" };
1964
1602
  if (written === "coproc")
1965
- return { reason: "`coproc` backgrounds a command — a backgrounded process outlives the command", refusedWhole: true };
1603
+ return { reason: "`coproc` backgrounds a command — a backgrounded process outlives the command" };
1966
1604
  if (isBareControlWord(toks, from, SHELL_BLOCK_WORDS)) {
1967
1605
  unlistedSegments.add(si);
1968
1606
  nameRefusal ??= `command "${written}" is not in the read-only allowlist`;
@@ -1976,13 +1614,13 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
1976
1614
  }
1977
1615
  const parsed = parseLeadingCommandName(toks.raw.slice(from).join(" "), { quotedOperatorsAreText: true, pathPrefixedNameIsText: true });
1978
1616
  if ("reject" in parsed)
1979
- return { reason: parsed.reject, refusedWhole: true };
1617
+ return { reason: parsed.reject };
1980
1618
  const pathPrefixed = parsed.name.includes("/");
1981
1619
  const name = pathPrefixed ? parsed.name.slice(parsed.name.lastIndexOf("/") + 1) : parsed.name;
1982
1620
  if (pathPrefixed)
1983
1621
  nameRefusal ??= "the command must be a bare name resolved via PATH (no path prefix)";
1984
1622
  if (pos.refusal !== undefined)
1985
- return { reason: programPositionRefusalSentence(pos.refusal), refusedWhole: true };
1623
+ return { reason: programPositionRefusalSentence(pos.refusal) };
1986
1624
  if (allow.has(name)) {
1987
1625
  if (pathPrefixed || from > 0)
1988
1626
  reheaded.set(si, { from, head: name });
@@ -1992,14 +1630,14 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
1992
1630
  }
1993
1631
  nameRefusal ??= `command "${parsed.name}" is not in the read-only allowlist`;
1994
1632
  if (SHELL_REENTRY_PROGRAM_RE.test(name))
1995
- return { reason: unjudgedProgramTextRefusal(parsed.name, "runs its argument as shell text"), refusedWhole: true };
1633
+ return { reason: unjudgedProgramTextRefusal(parsed.name, "runs its argument as shell text") };
1996
1634
  if (pos.launchers.length > 0) {
1997
1635
  const listed = program !== undefined && (allow.has(program.basename) || program.basename === "cd");
1998
1636
  if (program !== undefined && !listed && SHELL_REENTRY_PROGRAM_RE.test(program.basename)) {
1999
- return { reason: `"${written}" runs "${program.basename}", which runs its argument as shell text this walk does not judge ${NOT_AUTO_ALLOWED}`, refusedWhole: true };
1637
+ return { reason: `"${written}" runs "${program.basename}", which runs its argument as shell text this classifier does not read ${NOT_AUTO_ALLOWED}` };
2000
1638
  }
2001
1639
  if (argvReentryUnjudged(pos, XARGS_TARGETS_ACCEPTED_UNJUDGED)) {
2002
- 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 };
1640
+ return { reason: `"${written}" takes the operands of the program it runs from its standard input, which this classifier does not read ${NOT_AUTO_ALLOWED}` };
2003
1641
  }
2004
1642
  if (!listed || program === undefined)
2005
1643
  unlistedSegments.add(si);
@@ -2039,44 +1677,16 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
2039
1677
  };
2040
1678
  const STDIN_FILE_FLOOR = { cat: 1, grep: 2, head: 1, tail: 1, wc: 1, cut: 1, tr: Infinity, diff: 2, cmp: 2, comm: 2, sed: 2 };
2041
1679
  const foldedSegments = [];
2042
- const foldedReach = [];
2043
- let crossedAllAnd = true;
2044
- let crossedAnyOr = false;
2045
1680
  let shapeRefusal = nameRefusal;
2046
1681
  for (let si = 0; si < segments.length; si++) {
2047
- if (si > 0) {
2048
- if (connectors[si] !== "&&")
2049
- crossedAllAnd = false;
2050
- if (connectors[si] === "||")
2051
- crossedAnyOr = true;
2052
- }
2053
- const moveProvable = pipeFed[si] !== true && pipeFed[si + 1] !== true && reheaded.get(si) === undefined;
2054
- const pushReach = () => {
2055
- foldedReach.push({ andJoined: si > 0 && crossedAllAnd, orBroken: !crossedAllAnd && crossedAnyOr, moveProvable });
2056
- crossedAllAnd = true;
2057
- crossedAnyOr = false;
2058
- };
2059
- if (unlistedSegments.has(si)) {
2060
- const unlisted = tokenizeSegment(segments[si]);
2061
- if (unlisted.folded.length === 0)
2062
- continue;
2063
- const unlistedArgv = argvProgramOf(unlisted);
2064
- foldedSegments.push({
2065
- folded: [LAUNCHER_SCAN_HEAD, ...unlisted.folded],
2066
- raw: [LAUNCHER_SCAN_HEAD, ...unlisted.raw],
2067
- slotVerb: programBasename(unlisted.folded[0]),
2068
- ...(unlistedArgv !== undefined ? { argvProgram: unlistedArgv.program, argvFrom: unlistedArgv.from + 1 } : {}),
2069
- });
2070
- pushReach();
1682
+ if (unlistedSegments.has(si))
2071
1683
  continue;
2072
- }
2073
1684
  const rehead = reheaded.get(si);
2074
1685
  const segmentTokens = rehead === undefined ? tokenizeSegment(segments[si]) : reheadSegment(tokenizeSegment(segments[si]), rehead.head, rehead.from);
2075
1686
  const toks = segmentTokens.folded;
2076
1687
  if (toks.length === 0)
2077
1688
  continue;
2078
1689
  foldedSegments.push(segmentTokens);
2079
- pushReach();
2080
1690
  const name = toks[0];
2081
1691
  const guardReason = guardedVerbFormReason(name, toks, segmentTokens.raw);
2082
1692
  if (guardReason !== undefined) {
@@ -2152,311 +1762,13 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
2152
1762
  shapeRefusal ??= "reads a device/special file that is either unbounded (/dev/zero, /dev/stdin, /proc/<pid>/fd/0, … — blocks the pipeline until the tool timeout) or process-private (/proc/<pid>/environ, /proc/<pid>/mem, …) " + NOT_AUTO_ALLOWED;
2153
1763
  }
2154
1764
  }
2155
- if (opts?.iterated !== true) {
2156
- const cdSegments = foldedSegments.filter((st) => st.folded[0] === "cd");
2157
- if (cdSegments.length > 1)
2158
- shapeRefusal ??= `multiple directory changes in one command require approval for clarity ${NOT_AUTO_ALLOWED}`;
2159
- }
2160
- if (boundary !== undefined) {
2161
- const walk = evaluateReadBoundary(foldedSegments, boundary, true, foldedReach);
2162
- if (shapeRefusal === undefined)
2163
- return walk;
2164
- const { reason: _walkSentence, outOfRootGrantCandidate: walkGrantCandidate, outOfRootGrantCoversExactPath: walkGrantExact, ...structure } = walk;
2165
- const nameOnly = shapeRefusal === nameRefusal;
2166
- return {
2167
- ...structure,
2168
- reason: shapeRefusal,
2169
- ...(nameOnly && walkGrantCandidate !== undefined ? { outOfRootGrantCandidate: walkGrantCandidate, ...(walkGrantExact === true ? { outOfRootGrantCoversExactPath: true } : {}) } : {}),
2170
- ...(nameOnly ? { segmentNameRefusal: true } : {}),
2171
- };
2172
- }
2173
- if (shapeRefusal !== undefined)
2174
- return { reason: shapeRefusal };
2175
- if (foldedSegments.some((st) => st.folded[0] === "cd")) {
2176
- return { reason: `\`cd\` cannot be judged without a read boundary (no base to resolve the directory change against) ${NOT_AUTO_ALLOWED}` };
2177
- }
2178
- return {};
1765
+ const cdSegments = foldedSegments.filter((st) => st.folded[0] === "cd");
1766
+ if (cdSegments.length > 1)
1767
+ shapeRefusal ??= `multiple directory changes in one command require approval for clarity ${NOT_AUTO_ALLOWED}`;
1768
+ return shapeRefusal !== undefined ? { reason: shapeRefusal } : {};
2179
1769
  }
2180
- const BASE_CANDIDATE_MAX = 256;
2181
- function evaluateReadBoundary(foldedSegments, boundary, mintGrantCandidate, reach) {
2182
- 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() };
2183
- const outside = [];
2184
- const inside = [];
2185
- let unresolvable;
2186
- const unresolved = [];
2187
- let baseLost = false;
2188
- const undecided = [];
2189
- const checkedSpellings = [];
2190
- const undecidedSpellings = [];
2191
- const outOfRootSpellings = [];
2192
- const recordSpelling = (into, seenHere, finding) => {
2193
- if (finding.spelled === undefined || seenHere.has(finding.spelled))
2194
- return;
2195
- seenHere.add(finding.spelled);
2196
- into.push(finding.spelled);
2197
- };
2198
- const recursive = [];
2199
- let candidates = [{ base: boundary.cwd ?? boundary.roots[0], skippedCd: -1 }];
2200
- let andRunStart = 0;
2201
- const findingsByWords = new Map();
2202
- const findingsRowFor = (wordsKey) => {
2203
- const row = findingsByWords.get(wordsKey);
2204
- if (row !== undefined)
2205
- return row;
2206
- const fresh = new Map();
2207
- findingsByWords.set(wordsKey, fresh);
2208
- return fresh;
2209
- };
2210
- const findingsFor = (row, toks, base) => {
2211
- const hit = row.get(base);
2212
- if (hit !== undefined)
2213
- return hit;
2214
- const segBoundary = base === (boundary.cwd ?? boundary.roots[0]) ? boundary : { ...boundary, cwd: base };
2215
- const computed = collectSegmentBoundaryFindings(toks, segBoundary);
2216
- row.set(base, computed);
2217
- return computed;
2218
- };
2219
- for (let si = 0; si < foldedSegments.length; si++) {
2220
- const toks = foldedSegments[si];
2221
- if (baseLost)
2222
- break;
2223
- if (si > 0 && reach?.[si]?.andJoined !== true)
2224
- andRunStart = reach?.[si]?.orBroken === true ? si + 1 : si;
2225
- const reachable = candidates.filter((c) => c.skippedCd < andRunStart);
2226
- const row = findingsRowFor(JSON.stringify([toks.folded, toks.raw, toks.slotVerb]));
2227
- for (const { base: currentBase } of reachable) {
2228
- for (const finding of findingsFor(row, toks, currentBase)) {
2229
- if (finding.kind === "unresolvable") {
2230
- unresolvable ??= finding.reason;
2231
- if (!seen.unresolved.has(finding.operand)) {
2232
- seen.unresolved.add(finding.operand);
2233
- unresolved.push(finding.operand);
2234
- }
2235
- if (toks.folded[0] === "cd")
2236
- baseLost = true;
2237
- continue;
2238
- }
2239
- const denied = boundary.denyMatch?.(finding.path);
2240
- if (denied != null) {
2241
- return {
2242
- reason: `a command operand resolves to "${finding.path}", which matches ${sensitivePathDenyList(denied)} ${NOT_AUTO_ALLOWED}`,
2243
- readDenied: true,
2244
- readDeniedPath: finding.path,
2245
- readDeniedPattern: denied,
2246
- };
2247
- }
2248
- if (finding.kind === "inside") {
2249
- if (!seen.inside.has(finding.path)) {
2250
- seen.inside.add(finding.path);
2251
- inside.push(finding.path);
2252
- }
2253
- recordSpelling(checkedSpellings, seen.checkedSpelled, finding);
2254
- continue;
2255
- }
2256
- if (finding.kind === "undecided") {
2257
- if (!seen.undecided.has(finding.path)) {
2258
- seen.undecided.add(finding.path);
2259
- undecided.push(finding.path);
2260
- }
2261
- recordSpelling(undecidedSpellings, seen.undecidedSpelled, finding);
2262
- continue;
2263
- }
2264
- if (finding.kind === "recursive") {
2265
- if (!seen.recursive.has(finding.path)) {
2266
- seen.recursive.add(finding.path);
2267
- recursive.push(finding.path);
2268
- }
2269
- continue;
2270
- }
2271
- if (boundary.face === "open") {
2272
- if (!seen.inside.has(finding.path)) {
2273
- seen.inside.add(finding.path);
2274
- inside.push(finding.path);
2275
- }
2276
- continue;
2277
- }
2278
- if (!seen.outside.has(finding.path)) {
2279
- seen.outside.add(finding.path);
2280
- outside.push(finding);
2281
- }
2282
- recordSpelling(outOfRootSpellings, seen.outsideSpelled, finding);
2283
- }
2284
- }
2285
- if (!baseLost && toks.folded[0] === "cd") {
2286
- const cdArgs = toks.folded.slice(1);
2287
- const cdTargetIdx = cdArgs.findIndex((t) => !t.startsWith("-") || t === "-");
2288
- const cdTarget = cdTargetIdx === -1 ? undefined : cdArgs[cdTargetIdx];
2289
- if (cdTarget !== undefined && cdTarget !== "-") {
2290
- const provable = reach?.[si]?.moveProvable === true;
2291
- const branched = [];
2292
- for (const c of candidates) {
2293
- if (c.skippedCd < andRunStart)
2294
- branched.push({ base: resolveOperandLexically(c.base, cdTarget, boundary.homeDir) ?? c.base, skippedCd: c.skippedCd });
2295
- branched.push({ base: c.base, skippedCd: provable ? si : c.skippedCd });
2296
- }
2297
- const byBase = new Map();
2298
- for (const c of branched) {
2299
- const prior = byBase.get(c.base);
2300
- if (prior === undefined || c.skippedCd < prior.skippedCd)
2301
- byBase.set(c.base, c);
2302
- }
2303
- const next = [...byBase.values()];
2304
- if (next.length > BASE_CANDIDATE_MAX) {
2305
- unresolvable ??= `too many directory changes to track where "${cdTarget}" leaves the working directory ${NOT_AUTO_ALLOWED}`;
2306
- if (!seen.unresolved.has(cdTarget)) {
2307
- seen.unresolved.add(cdTarget);
2308
- unresolved.push(cdTarget);
2309
- }
2310
- baseLost = true;
2311
- }
2312
- else
2313
- candidates = next;
2314
- }
2315
- }
2316
- }
2317
- const undecidedAll = [...undecided, ...recursive.filter((p) => !seen.undecided.has(p))];
2318
- const undecidedField = {
2319
- ...(undecidedAll.length > 0 ? { undecidedPaths: undecidedAll } : {}),
2320
- ...(recursive.length > 0 ? { recursiveReadPaths: recursive } : {}),
2321
- ...(undecidedSpellings.length > 0 ? { undecidedSpellings } : {}),
2322
- };
2323
- const checkedField = {
2324
- ...(inside.length > 0 ? { checkedPaths: inside } : {}),
2325
- ...(checkedSpellings.length > 0 ? { checkedSpellings } : {}),
2326
- };
2327
- const outsideField = outOfRootSpellings.length > 0 ? { outOfRootSpellings } : {};
2328
- if (unresolvable !== undefined) {
2329
- return {
2330
- reason: unresolvable,
2331
- unresolvedOperands: unresolved,
2332
- ...(outside.length > 0 ? { outOfRootRead: true, outOfRootPaths: outside.map((o) => o.path), ...outsideField } : {}),
2333
- ...checkedField,
2334
- ...undecidedField,
2335
- };
2336
- }
2337
- if (outside.length === 0)
2338
- return { ...checkedField, ...undecidedField };
2339
- const paths = outside.map((o) => `"${o.path}"`).join(", ");
2340
- const allowed = boundary.roots.length > 0 ? boundary.roots.join(", ") : "(none)";
2341
- const grantCandidate = (() => {
2342
- if (!mintGrantCandidate || outOfRootSpellings.length > 0)
2343
- return undefined;
2344
- const proposed = outOfRootGrantCandidateOf(outside.map((o) => o.path), boundary);
2345
- if (proposed === undefined)
2346
- return undefined;
2347
- const replay = evaluateReadBoundary(foldedSegments, { ...boundary, roots: [...boundary.roots, proposed.path] }, false, reach);
2348
- return replay.reason === undefined && (replay.undecidedPaths ?? []).length === 0 ? proposed : undefined;
2349
- })();
2350
- return {
2351
- reason: `"${outside[0].command}" reads ${paths}, outside the allowed directories for this session: ${allowed} ${NOT_AUTO_ALLOWED}`,
2352
- outOfRootRead: true,
2353
- outOfRootPaths: outside.map((o) => o.path),
2354
- ...(grantCandidate !== undefined ? { outOfRootGrantCandidate: grantCandidate.path, ...(grantCandidate.exact === true ? { outOfRootGrantCoversExactPath: true } : {}) } : {}),
2355
- ...outsideField,
2356
- ...checkedField,
2357
- ...undecidedField,
2358
- };
2359
- }
2360
- export function classifySimpleCommandReadBoundary(command, boundary) {
2361
- const toks = tokenizeSegment(command);
2362
- if (toks.folded.length === 0)
2363
- return {};
2364
- const pos = readProgramPosition(toks);
2365
- if (pos.refusal !== undefined)
2366
- return { reason: programPositionRefusalSentence(pos.refusal), refusedWhole: true };
2367
- if (pos.program !== undefined) {
2368
- const familyRefusal = argumentAsProgramRefusal(pos.program.basename, pos.program.basename, toks.folded.slice(pos.program.index + 1));
2369
- if (familyRefusal !== undefined)
2370
- return { reason: familyRefusal };
2371
- }
2372
- return evaluateReadBoundary([toks], boundary, true);
2373
- }
2374
- const NOT_GATED = { gated: false, paths: [], checked: [], undecided: [] };
2375
- const UNREADABLE_WHITELIST_WORD = /[$`()*?[\]{}]/;
2376
1770
  export const UNMODELLED_BASE_MOVERS = new Set(["pushd", "popd"]);
2377
1771
  export const COMMAND_LAUNCHERS_FOR_TEST = COMMAND_LAUNCHERS;
2378
- const LAUNCHER_SCAN_HEAD = "cat";
2379
- export function classifyOutOfRootReadGate(command, allow, boundary) {
2380
- const containmentOnly = {
2381
- roots: boundary.roots,
2382
- ...(boundary.cwd !== undefined ? { cwd: boundary.cwd } : {}),
2383
- ...(boundary.homeDir !== undefined ? { homeDir: boundary.homeDir } : {}),
2384
- ...(boundary.face !== undefined ? { face: boundary.face } : {}),
2385
- };
2386
- const judge = (text) => {
2387
- const split = segmentCompoundForReadonly(text, containmentOnly.homeDir);
2388
- if ("reject" in split)
2389
- return NOT_GATED;
2390
- const readers = [];
2391
- const readerReach = [];
2392
- let crossedAllAnd = true;
2393
- let crossedAnyOr = false;
2394
- let baseMoved = false;
2395
- for (let si = 0; si < split.segments.length; si++) {
2396
- const segment = split.segments[si];
2397
- if (si > 0) {
2398
- if (split.connectors[si] !== "&&")
2399
- crossedAllAnd = false;
2400
- if (split.connectors[si] === "||")
2401
- crossedAnyOr = true;
2402
- }
2403
- const moveProvable = split.pipeFed[si] !== true && split.pipeFed[si + 1] !== true;
2404
- const pushReader = (st) => {
2405
- readers.push(st);
2406
- readerReach.push({ andJoined: si > 0 && crossedAllAnd, orBroken: !crossedAllAnd && crossedAnyOr, moveProvable });
2407
- crossedAllAnd = true;
2408
- crossedAnyOr = false;
2409
- };
2410
- const toks = tokenizeSegment(segment);
2411
- if (toks.folded.length === 0)
2412
- continue;
2413
- let i = 0;
2414
- while (i < toks.folded.length && ASSIGNMENT_WORD.test(toks.folded[i]))
2415
- i++;
2416
- const written = toks.folded[i];
2417
- if (written === undefined)
2418
- continue;
2419
- const name = programBasename(written);
2420
- if (UNMODELLED_BASE_MOVERS.has(name))
2421
- baseMoved = true;
2422
- if (!allow.has(name) && name !== "cd") {
2423
- const argv = argvProgramOf(toks);
2424
- pushReader({ folded: [LAUNCHER_SCAN_HEAD, ...toks.folded], raw: [LAUNCHER_SCAN_HEAD, ...toks.raw], slotVerb: name, ...(argv !== undefined ? { argvProgram: argv.program, argvFrom: argv.from + 1 } : {}) });
2425
- continue;
2426
- }
2427
- pushReader(i === 0 && name === written ? toks : { folded: [name, ...toks.folded.slice(i + 1)], raw: [toks.raw[i] ?? name, ...toks.raw.slice(i + 1)] });
2428
- }
2429
- if (readers.length === 0)
2430
- return NOT_GATED;
2431
- if (baseMoved)
2432
- return { gated: true, paths: [], checked: [], undecided: [] };
2433
- const verdict = evaluateReadBoundary(readers, containmentOnly, false, readerReach);
2434
- const denyPass = boundary.denyMatch === undefined ? undefined : evaluateReadBoundary(readers, boundary, false, readerReach);
2435
- const denied = denyPass?.readDenied === true && denyPass.readDeniedPath !== undefined && denyPass.readDeniedPattern !== undefined
2436
- ? { path: denyPass.readDeniedPath, pattern: denyPass.readDeniedPattern }
2437
- : undefined;
2438
- const resolvedOperands = {
2439
- checked: [...(verdict.checkedPaths ?? []), ...(verdict.checkedSpellings ?? [])],
2440
- undecided: [...(verdict.undecidedPaths ?? []), ...(verdict.undecidedSpellings ?? [])],
2441
- };
2442
- if (verdict.outOfRootRead === true) {
2443
- return { gated: true, paths: verdict.outOfRootPaths ?? [], ...resolvedOperands, ...(denied !== undefined ? { denied } : {}) };
2444
- }
2445
- if (denied !== undefined)
2446
- return { gated: false, paths: [], ...resolvedOperands, denied };
2447
- return resolvedOperands.checked.length === 0 && resolvedOperands.undecided.length === 0 ? NOT_GATED : { gated: false, paths: [], ...resolvedOperands };
2448
- };
2449
- const direct = judge(command);
2450
- if (direct.gated || direct.denied !== undefined)
2451
- return direct;
2452
- const shape = POLL_LOOP_SHAPE.exec(command.trim());
2453
- if (shape === null)
2454
- return direct;
2455
- const loop = judge(shape[3]);
2456
- return loop.gated || loop.denied !== undefined
2457
- ? loop
2458
- : { ...loop, checked: [...direct.checked, ...loop.checked], undecided: [...direct.undecided, ...loop.undecided] };
2459
- }
2460
1772
  export function classifyCompoundReadonly(command, allow, boundary) {
2461
1773
  return classifyCompoundReadonlyDetailed(command, allow, boundary).reason;
2462
1774
  }
@@ -2549,18 +1861,5 @@ export function classifyBoundedReadonlyPollLoopDetailed(command, allow, boundary
2549
1861
  if (readSegments.length === 0) {
2550
1862
  return { reason: "the loop body has no read command — a sleep-only loop observes nothing and is not auto-allowed" };
2551
1863
  }
2552
- const bodyHasCd = readSegments.some((seg) => tokensMoveBase(tokenizeSegment(seg)));
2553
- const modelled = bodyHasCd ? Array.from({ length: beats }, () => readSegments.join("; ")).join("; ") : readSegments.join("; ");
2554
- const verdict = classifyCompoundReadonlyDetailed(modelled, allow, boundary, { iterated: bodyHasCd });
2555
- if (verdict.reason !== undefined)
2556
- return verdict;
2557
- if (verdict.recursiveReadPaths !== undefined) {
2558
- const roots = verdict.recursiveReadPaths;
2559
- const shown = roots.slice(0, POLL_LOOP_CAUSE_MAX_PATHS);
2560
- return { ...verdict, reason: `this lexical check cannot bound what a recursive read form in the loop body traverses — ${roots.length} path(s) taken as traversal roots: ${shown.join(", ")}${roots.length > shown.length ? `, and ${roots.length - shown.length} more` : ""}` };
2561
- }
2562
- if (verdict.undecidedPaths !== undefined) {
2563
- return { ...verdict, reason: `the loop body carries an unexpanded glob (${verdict.undecidedPaths.join(", ")}) — what a REPEATED read touches is decided at run time, so it is not auto-allowed` };
2564
- }
2565
- return verdict;
1864
+ return classifyCompoundReadonlyDetailed(readSegments.join("; "), allow, boundary);
2566
1865
  }