@sema-agent/core 7.24.4 → 7.25.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 (101) hide show
  1. package/CHANGELOG.md +78 -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/background-agent-store.d.ts +3 -3
  8. package/dist/core/checkpoint-store.d.ts +1 -0
  9. package/dist/core/delegation-frames.d.ts +5 -0
  10. package/dist/core/gate-fold.js +85 -0
  11. package/dist/core/gate-lanes.js +23 -1
  12. package/dist/core/gate-outcome.d.ts +8 -2
  13. package/dist/core/gate-outcome.js +3 -1
  14. package/dist/core/hooks.d.ts +29 -0
  15. package/dist/core/hooks.js +2 -0
  16. package/dist/core/mailbox-store.d.ts +5 -0
  17. package/dist/core/mailbox-store.js +1 -1
  18. package/dist/core/parent-spec-seat.d.ts +7 -1
  19. package/dist/core/parent-spec-seat.js +2 -0
  20. package/dist/core/permission-rule-consent.js +29 -47
  21. package/dist/core/permission-rule-provider.js +4 -3
  22. package/dist/core/permission-rule-store.d.ts +143 -7
  23. package/dist/core/permission-rule-store.js +165 -15
  24. package/dist/core/runner/contracts.d.ts +36 -2
  25. package/dist/core/runner/gate-exit.d.ts +14 -0
  26. package/dist/core/runner/gate-exit.js +4 -0
  27. package/dist/core/runner/prepare-delegation-surface.d.ts +4 -4
  28. package/dist/core/runner/prepare-delegation-surface.js +2 -1
  29. package/dist/core/runner/prepare-gate-stations.d.ts +4 -0
  30. package/dist/core/runner/prepare-gate-stations.js +7 -4
  31. package/dist/core/runner/prepare-hands-readface.d.ts +8 -4
  32. package/dist/core/runner/prepare-hands-readface.js +25 -10
  33. package/dist/core/runner/prepare-inherited-gate.d.ts +23 -0
  34. package/dist/core/runner/prepare-inherited-gate.js +11 -6
  35. package/dist/core/runner/prepare-policy-chain.d.ts +15 -1
  36. package/dist/core/runner/prepare-policy-chain.js +14 -4
  37. package/dist/core/runner/prepare-suspend-saga.d.ts +5 -2
  38. package/dist/core/runner/prepare-suspend-saga.js +4 -3
  39. package/dist/core/runner/prepare-task.js +10 -9
  40. package/dist/core/runner/prepare-wiring-manifest.d.ts +11 -4
  41. package/dist/core/runner/prepare-wiring-manifest.js +8 -4
  42. package/dist/core/runner/resume-apply.js +2 -0
  43. package/dist/core/runner/run-harness-handlers.js +1 -0
  44. package/dist/core/runner/run-identity-wiring.d.ts +2 -2
  45. package/dist/core/runner/run-leg.js +26 -0
  46. package/dist/core/runner/run-notification-lane.d.ts +2 -2
  47. package/dist/core/runner/run-notification-lane.js +2 -1
  48. package/dist/core/runner/run-settle-and-teardown.js +1 -0
  49. package/dist/core/runner/session-rule-policy.d.ts +3 -4
  50. package/dist/core/runner/session-rule-policy.js +11 -3
  51. package/dist/core/session-policy-store.d.ts +27 -3
  52. package/dist/core/session-policy-store.js +12 -4
  53. package/dist/core/store-contracts/background-agent-store-contract.js +10 -0
  54. package/dist/core/store-contracts/peer-directory-contract.d.ts +23 -0
  55. package/dist/core/store-contracts/peer-directory-contract.js +82 -0
  56. package/dist/core/task-event.d.ts +6 -0
  57. package/dist/core/task-notification.d.ts +79 -4
  58. package/dist/core/task-notification.js +90 -1
  59. package/dist/core/task-registry-agent.js +1 -2
  60. package/dist/core/task-registry-monitor.js +2 -1
  61. package/dist/core/task-registry-shared.d.ts +53 -5
  62. package/dist/core/task-registry-shared.js +13 -2
  63. package/dist/core/task-registry.js +8 -5
  64. package/dist/core/task-spec.d.ts +12 -5
  65. package/dist/core/tool-catalog-entries.js +1 -0
  66. package/dist/core/tool-spec.d.ts +1 -1
  67. package/dist/core/wiring-manifest.d.ts +2 -2
  68. package/dist/engine/loop/agent-loop.js +1 -1
  69. package/dist/engine/loop/types.d.ts +4 -0
  70. package/dist/index.d.ts +6 -3
  71. package/dist/index.js +4 -3
  72. package/dist/prompts/coordinator.d.ts +2 -1
  73. package/dist/stores/file/adoption/adopt.js +23 -1
  74. package/dist/stores/file/permission-rule-store.js +3 -1
  75. package/dist/stores/file/session-policy-store.d.ts +31 -0
  76. package/dist/stores/file/session-policy-store.js +87 -7
  77. package/dist/tools/fs/bash-program-position.d.ts +4 -26
  78. package/dist/tools/fs/bash-program-position.js +0 -11
  79. package/dist/tools/fs/bash-readonly-classifier.d.ts +20 -309
  80. package/dist/tools/fs/bash-readonly-classifier.js +28 -766
  81. package/dist/tools/fs/fs-bash.d.ts +43 -207
  82. package/dist/tools/fs/fs-bash.js +20 -341
  83. package/dist/tools/fs/index.d.ts +12 -13
  84. package/dist/tools/fs/index.js +3 -4
  85. package/dist/tools/fs/read-deny.d.ts +25 -0
  86. package/dist/tools/fs/read-deny.js +19 -0
  87. package/dist/tools/fs/safety.js +2 -6
  88. package/dist/tools/fs/shell-read-check.d.ts +99 -0
  89. package/dist/tools/fs/shell-read-check.js +229 -0
  90. package/dist/tools/fs/shell-read-establish.d.ts +70 -115
  91. package/dist/tools/fs/shell-read-establish.js +216 -102
  92. package/dist/tools/fs/shell-read-judge.d.ts +231 -0
  93. package/dist/tools/fs/shell-read-judge.js +170 -0
  94. package/dist/tools/fs/shell-read-plan.d.ts +3 -0
  95. package/dist/tools/fs/shell-read-plan.js +82 -19
  96. package/dist/tools/monitor.d.ts +5 -5
  97. package/dist/tools/monitor.js +3 -5
  98. package/dist/tools/task-list.d.ts +4 -0
  99. package/package.json +1 -1
  100. package/test/export-surface.snapshot.json +19 -3
  101. package/wire-changes/core@7.25.0.json +671 -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, quoteMask, wordsOf, } from "./bash-lexer.js";
3
+ import { ASSIGNMENT_WORD, COMMAND_LAUNCHERS, NOT_AUTO_ALLOWED, SHELL_BLOCK_WORDS, argvReentryUnjudged, isBareControlWord, programPositionRefusalSentence, readProgramPosition, } 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)
@@ -922,7 +692,7 @@ function sedReadingKind(reading) {
922
692
  }
923
693
  function readSedScript(script) {
924
694
  if (script.split("\n").some((line) => utf8Length(line) >= SED_SCRIPT_LINE_LIMIT))
925
- return "unseen";
695
+ return "oversized";
926
696
  let i = 0;
927
697
  let depth = 0;
928
698
  const len = script.length;
@@ -1231,8 +1001,10 @@ export function sedScriptSlotFiles(args) {
1231
1001
  const writes = new Set();
1232
1002
  const commands = new Set();
1233
1003
  let executesPatternSpace = false;
1004
+ const kinds = [];
1234
1005
  for (const dialect of SED_OPTION_DIALECTS) {
1235
1006
  const reading = readSedInvocationDetailed(args, dialect);
1007
+ kinds.push(sedReadingKind(reading));
1236
1008
  if (typeof reading === "string")
1237
1009
  continue;
1238
1010
  for (const f of reading.names.reads)
@@ -1243,12 +1015,17 @@ export function sedScriptSlotFiles(args) {
1243
1015
  commands.add(c);
1244
1016
  executesPatternSpace ||= reading.names.executesPatternSpace;
1245
1017
  }
1246
- return { reads: [...reads], writes: [...writes], commands: [...commands], executesPatternSpace };
1018
+ const unread = kinds.includes("oversized")
1019
+ ? "a script line is longer than an implementation compiles whole, so what runs is not the text written here"
1020
+ : kinds.every((k) => k === "unparsed")
1021
+ ? "its script could not be read to its end"
1022
+ : undefined;
1023
+ return { reads: [...reads], writes: [...writes], commands: [...commands], executesPatternSpace, ...(unread !== undefined ? { unread } : {}) };
1247
1024
  }
1248
1025
  const SCRIPT_SLOT_VERBS = new Map([
1249
1026
  ["sed", (args) => {
1250
1027
  const readings = SED_OPTION_DIALECTS.map((dialect) => readSedInvocation(args, dialect));
1251
- if (readings.includes("names") || readings.includes("unseen"))
1028
+ if (readings.includes("names") || readings.includes("unseen") || readings.includes("oversized"))
1252
1029
  return true;
1253
1030
  return readings.every((reading) => reading === "unparsed");
1254
1031
  }],
@@ -1711,182 +1488,6 @@ export function attachedOptionPayloads(tok) {
1711
1488
  }
1712
1489
  return out;
1713
1490
  }
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
1491
  export function foldHomeVariableSpelling(command, homeDir) {
1891
1492
  if (homeDir === undefined || !command.includes("$") || /[\\`]/.test(command))
1892
1493
  return command;
@@ -1945,11 +1546,11 @@ function segmentCompoundForReadonly(command, homeDir) {
1945
1546
  : redirectionStripped;
1946
1547
  return splitShellCompoundSegments(terminatorStripped, { trailingTerminator: "keep" });
1947
1548
  }
1948
- export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts) {
1549
+ export function classifyCompoundReadonlyDetailed(command, allow, boundary) {
1949
1550
  const split = segmentCompoundForReadonly(command, boundary?.homeDir);
1950
1551
  if ("reject" in split)
1951
- return { reason: split.reject, refusedWhole: true };
1952
- const { segments, pipeFed, connectors } = split;
1552
+ return { reason: split.reject };
1553
+ const { segments, pipeFed } = split;
1953
1554
  let nameRefusal;
1954
1555
  const unlistedSegments = new Set();
1955
1556
  const reheaded = new Map();
@@ -1962,7 +1563,7 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
1962
1563
  if (written === undefined)
1963
1564
  return { reason: "empty command" };
1964
1565
  if (written === "coproc")
1965
- return { reason: "`coproc` backgrounds a command — a backgrounded process outlives the command", refusedWhole: true };
1566
+ return { reason: "`coproc` backgrounds a command — a backgrounded process outlives the command" };
1966
1567
  if (isBareControlWord(toks, from, SHELL_BLOCK_WORDS)) {
1967
1568
  unlistedSegments.add(si);
1968
1569
  nameRefusal ??= `command "${written}" is not in the read-only allowlist`;
@@ -1976,13 +1577,13 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
1976
1577
  }
1977
1578
  const parsed = parseLeadingCommandName(toks.raw.slice(from).join(" "), { quotedOperatorsAreText: true, pathPrefixedNameIsText: true });
1978
1579
  if ("reject" in parsed)
1979
- return { reason: parsed.reject, refusedWhole: true };
1580
+ return { reason: parsed.reject };
1980
1581
  const pathPrefixed = parsed.name.includes("/");
1981
1582
  const name = pathPrefixed ? parsed.name.slice(parsed.name.lastIndexOf("/") + 1) : parsed.name;
1982
1583
  if (pathPrefixed)
1983
1584
  nameRefusal ??= "the command must be a bare name resolved via PATH (no path prefix)";
1984
1585
  if (pos.refusal !== undefined)
1985
- return { reason: programPositionRefusalSentence(pos.refusal), refusedWhole: true };
1586
+ return { reason: programPositionRefusalSentence(pos.refusal) };
1986
1587
  if (allow.has(name)) {
1987
1588
  if (pathPrefixed || from > 0)
1988
1589
  reheaded.set(si, { from, head: name });
@@ -1992,14 +1593,14 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
1992
1593
  }
1993
1594
  nameRefusal ??= `command "${parsed.name}" is not in the read-only allowlist`;
1994
1595
  if (SHELL_REENTRY_PROGRAM_RE.test(name))
1995
- return { reason: unjudgedProgramTextRefusal(parsed.name, "runs its argument as shell text"), refusedWhole: true };
1596
+ return { reason: unjudgedProgramTextRefusal(parsed.name, "runs its argument as shell text") };
1996
1597
  if (pos.launchers.length > 0) {
1997
1598
  const listed = program !== undefined && (allow.has(program.basename) || program.basename === "cd");
1998
1599
  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 };
1600
+ return { reason: `"${written}" runs "${program.basename}", which runs its argument as shell text this classifier does not read ${NOT_AUTO_ALLOWED}` };
2000
1601
  }
2001
1602
  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 };
1603
+ 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
1604
  }
2004
1605
  if (!listed || program === undefined)
2005
1606
  unlistedSegments.add(si);
@@ -2039,44 +1640,16 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
2039
1640
  };
2040
1641
  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
1642
  const foldedSegments = [];
2042
- const foldedReach = [];
2043
- let crossedAllAnd = true;
2044
- let crossedAnyOr = false;
2045
1643
  let shapeRefusal = nameRefusal;
2046
1644
  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();
1645
+ if (unlistedSegments.has(si))
2071
1646
  continue;
2072
- }
2073
1647
  const rehead = reheaded.get(si);
2074
1648
  const segmentTokens = rehead === undefined ? tokenizeSegment(segments[si]) : reheadSegment(tokenizeSegment(segments[si]), rehead.head, rehead.from);
2075
1649
  const toks = segmentTokens.folded;
2076
1650
  if (toks.length === 0)
2077
1651
  continue;
2078
1652
  foldedSegments.push(segmentTokens);
2079
- pushReach();
2080
1653
  const name = toks[0];
2081
1654
  const guardReason = guardedVerbFormReason(name, toks, segmentTokens.raw);
2082
1655
  if (guardReason !== undefined) {
@@ -2152,311 +1725,13 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
2152
1725
  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
1726
  }
2154
1727
  }
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 {};
1728
+ const cdSegments = foldedSegments.filter((st) => st.folded[0] === "cd");
1729
+ if (cdSegments.length > 1)
1730
+ shapeRefusal ??= `multiple directory changes in one command require approval for clarity ${NOT_AUTO_ALLOWED}`;
1731
+ return shapeRefusal !== undefined ? { reason: shapeRefusal } : {};
2179
1732
  }
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
1733
  export const UNMODELLED_BASE_MOVERS = new Set(["pushd", "popd"]);
2377
1734
  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
1735
  export function classifyCompoundReadonly(command, allow, boundary) {
2461
1736
  return classifyCompoundReadonlyDetailed(command, allow, boundary).reason;
2462
1737
  }
@@ -2549,18 +1824,5 @@ export function classifyBoundedReadonlyPollLoopDetailed(command, allow, boundary
2549
1824
  if (readSegments.length === 0) {
2550
1825
  return { reason: "the loop body has no read command — a sleep-only loop observes nothing and is not auto-allowed" };
2551
1826
  }
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;
1827
+ return classifyCompoundReadonlyDetailed(readSegments.join("; "), allow, boundary);
2566
1828
  }