@sema-agent/core 7.16.0 → 7.17.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 (207) hide show
  1. package/CHANGELOG.md +209 -0
  2. package/dist/agents/builtin-agents.d.ts +3 -3
  3. package/dist/agents/cross-session-envelope.d.ts +0 -1
  4. package/dist/agents/cross-session-envelope.js +0 -1
  5. package/dist/agents/peer-notices.d.ts +0 -9
  6. package/dist/agents/peer-notices.js +0 -63
  7. package/dist/agents/subagent.js +7 -15
  8. package/dist/brain/request-params.d.ts +2 -2
  9. package/dist/config/defaults.d.ts +0 -7
  10. package/dist/config/defaults.js +0 -2
  11. package/dist/core/artifact-host.d.ts +218 -0
  12. package/dist/core/artifact-host.js +92 -0
  13. package/dist/core/ask-origin.d.ts +29 -3
  14. package/dist/core/ask-origin.js +17 -9
  15. package/dist/core/ask-question.d.ts +25 -0
  16. package/dist/core/ask-question.js +4 -0
  17. package/dist/core/ask-unresolvable-notice.d.ts +30 -21
  18. package/dist/core/ask-unresolvable-notice.js +16 -8
  19. package/dist/core/auto-compaction.d.ts +10 -10
  20. package/dist/core/auto-mode-arming.d.ts +2 -2
  21. package/dist/core/background-agent-store.d.ts +5 -5
  22. package/dist/core/background-shell.d.ts +1 -1
  23. package/dist/core/checkpoint-execution-record.d.ts +0 -3
  24. package/dist/core/checkpoint-execution-record.js +0 -4
  25. package/dist/core/checkpoint-store.d.ts +71 -99
  26. package/dist/core/checkpoint-store.js +9 -16
  27. package/dist/core/context-edit.d.ts +36 -104
  28. package/dist/core/context-edit.js +37 -60
  29. package/dist/core/effective-path-target.d.ts +68 -8
  30. package/dist/core/effective-path-target.js +42 -30
  31. package/dist/core/engine-notice.d.ts +46 -32
  32. package/dist/core/env-budget.d.ts +31 -0
  33. package/dist/core/env-budget.js +39 -0
  34. package/dist/core/exec-output-tail.d.ts +5 -5
  35. package/dist/core/file-history-store.js +3 -6
  36. package/dist/core/fs-write-gate-policy.d.ts +1 -1
  37. package/dist/core/fs-write-gate-policy.js +3 -3
  38. package/dist/core/gate-lanes.js +9 -15
  39. package/dist/core/governance-codes.d.ts +1 -1
  40. package/dist/core/governance-codes.js +2 -0
  41. package/dist/core/hands-band.d.ts +1 -1
  42. package/dist/core/hooks.d.ts +18 -17
  43. package/dist/core/lsp-session.d.ts +1 -1
  44. package/dist/core/lsp.d.ts +1 -1
  45. package/dist/core/lsp.js +5 -4
  46. package/dist/core/mcp.d.ts +5 -5
  47. package/dist/core/mcp.js +4 -48
  48. package/dist/core/memory-engine/engine.js +2 -1
  49. package/dist/core/memory-engine/layout.d.ts +18 -6
  50. package/dist/core/memory-engine/layout.js +40 -21
  51. package/dist/core/memory-recall.d.ts +0 -8
  52. package/dist/core/memory.d.ts +1 -1
  53. package/dist/core/oracle-isolation.js +2 -2
  54. package/dist/core/parent-spec-seat.d.ts +66 -0
  55. package/dist/core/parent-spec-seat.js +47 -0
  56. package/dist/core/permission-rule-model.d.ts +11 -6
  57. package/dist/core/permission-rule-model.js +53 -37
  58. package/dist/core/permission-rules.js +8 -3
  59. package/dist/core/physical-path.d.ts +37 -0
  60. package/dist/core/physical-path.js +30 -0
  61. package/dist/core/protocol-naming.d.ts +2 -2
  62. package/dist/core/remote-env.d.ts +1 -1
  63. package/dist/core/retired-keys.d.ts +19 -0
  64. package/dist/core/retired-keys.js +15 -0
  65. package/dist/core/runner/active-skill-scope.js +3 -3
  66. package/dist/core/runner/assemble-result.d.ts +15 -13
  67. package/dist/core/runner/assemble-result.js +5 -4
  68. package/dist/core/runner/compaction-call-options.d.ts +25 -89
  69. package/dist/core/runner/contracts.d.ts +11 -10
  70. package/dist/core/runner/gate-exit.js +15 -1
  71. package/dist/core/runner/halt-attribution.d.ts +35 -0
  72. package/dist/core/runner/halt-attribution.js +16 -0
  73. package/dist/core/runner/memory-consolidation.d.ts +0 -2
  74. package/dist/core/runner/memory-consolidation.js +0 -1
  75. package/dist/core/runner/prepare-artifact.d.ts +52 -0
  76. package/dist/core/runner/prepare-artifact.js +63 -0
  77. package/dist/core/runner/prepare-caps-and-workflow.d.ts +4 -2
  78. package/dist/core/runner/prepare-caps-and-workflow.js +2 -3
  79. package/dist/core/runner/prepare-config-doors.d.ts +3 -3
  80. package/dist/core/runner/prepare-config-doors.js +5 -2
  81. package/dist/core/runner/prepare-context-lane.d.ts +2 -2
  82. package/dist/core/runner/prepare-context-lane.js +2 -13
  83. package/dist/core/runner/prepare-file-history.d.ts +1 -1
  84. package/dist/core/runner/prepare-file-history.js +3 -52
  85. package/dist/core/runner/prepare-hands-readface.d.ts +4 -7
  86. package/dist/core/runner/prepare-hands-readface.js +3 -2
  87. package/dist/core/runner/prepare-inherited-gate.d.ts +5 -2
  88. package/dist/core/runner/prepare-inherited-gate.js +7 -8
  89. package/dist/core/runner/prepare-memory.d.ts +4 -6
  90. package/dist/core/runner/prepare-memory.js +4 -2
  91. package/dist/core/runner/prepare-policy-chain.d.ts +3 -3
  92. package/dist/core/runner/prepare-policy-chain.js +15 -30
  93. package/dist/core/runner/prepare-prompt-assembly.js +1 -1
  94. package/dist/core/runner/prepare-question-face.js +2 -1
  95. package/dist/core/runner/prepare-safety-scan.d.ts +1 -1
  96. package/dist/core/runner/prepare-suspend-saga.js +0 -2
  97. package/dist/core/runner/prepare-task.js +12 -11
  98. package/dist/core/runner/prepare-wiring-manifest.js +9 -7
  99. package/dist/core/runner/resume-admission.js +2 -1
  100. package/dist/core/runner/resume-internals-and-config.js +4 -0
  101. package/dist/core/runner/retired-runner-deps.d.ts +3 -0
  102. package/dist/core/runner/retired-runner-deps.js +7 -0
  103. package/dist/core/runner/run-harness-handlers.js +5 -3
  104. package/dist/core/runner/run-settle-and-teardown.d.ts +1 -1
  105. package/dist/core/runner/run-settle-and-teardown.js +3 -1
  106. package/dist/core/runner/session-file-state-replay.js +3 -3
  107. package/dist/core/runner/session-rule-policy.js +3 -4
  108. package/dist/core/runner/stream-halt-verbs.js +1 -1
  109. package/dist/core/runner/stream-steer-verb.js +1 -4
  110. package/dist/core/runner/workspace-path.js +3 -0
  111. package/dist/core/runner-deps.d.ts +30 -49
  112. package/dist/core/safe-notify.d.ts +1 -1
  113. package/dist/core/sensitive-path-policy.d.ts +1 -1
  114. package/dist/core/sensitive-path-policy.js +11 -12
  115. package/dist/core/session.d.ts +2 -2
  116. package/dist/core/skill-tool-specifier.d.ts +1 -1
  117. package/dist/core/skills-directory.d.ts +1 -1
  118. package/dist/core/skills-directory.js +4 -3
  119. package/dist/core/spec-contract.js +5 -4
  120. package/dist/core/store-contracts/checkpoint-store-contract.js +1 -1
  121. package/dist/core/strategy-store.d.ts +8 -11
  122. package/dist/core/task-event.d.ts +20 -17
  123. package/dist/core/task-limits.d.ts +1 -1
  124. package/dist/core/task-notification.d.ts +5 -5
  125. package/dist/core/task-registry-agent.d.ts +5 -5
  126. package/dist/core/task-registry-monitor.d.ts +2 -2
  127. package/dist/core/task-registry-shared.d.ts +32 -28
  128. package/dist/core/task-registry-shared.js +4 -13
  129. package/dist/core/task-registry.d.ts +8 -8
  130. package/dist/core/task-result.d.ts +37 -37
  131. package/dist/core/task-spec.d.ts +5 -5
  132. package/dist/core/task-stream.d.ts +16 -11
  133. package/dist/core/tool-catalog-entries.d.ts +1 -0
  134. package/dist/core/tool-catalog-entries.js +6 -1
  135. package/dist/core/tool-conformance.d.ts +0 -2
  136. package/dist/core/tool-conformance.js +1 -3
  137. package/dist/core/tool-errors.d.ts +5 -0
  138. package/dist/core/tool-errors.js +1 -0
  139. package/dist/core/tool-policy.d.ts +66 -22
  140. package/dist/core/tool-policy.js +31 -5
  141. package/dist/core/tool-result-budget.d.ts +1 -1
  142. package/dist/core/tool-result-store.d.ts +11 -11
  143. package/dist/core/tool-spec.d.ts +34 -38
  144. package/dist/core/tools.d.ts +2 -2
  145. package/dist/core/trace.d.ts +12 -15
  146. package/dist/core/untrusted-text.d.ts +1 -1
  147. package/dist/core/wiring-manifest.d.ts +4 -3
  148. package/dist/core/workflow-journal-store.d.ts +11 -11
  149. package/dist/engine/execution-env/node-execution-env.d.ts +3 -2
  150. package/dist/engine/execution-env/node-execution-env.js +8 -5
  151. package/dist/engine/harness/agent-harness.d.ts +47 -4
  152. package/dist/engine/harness/agent-harness.js +52 -9
  153. package/dist/engine/loop/agent-loop.js +15 -8
  154. package/dist/engine/loop/types.d.ts +5 -0
  155. package/dist/index.d.ts +20 -14
  156. package/dist/index.js +16 -10
  157. package/dist/internal/harness.d.ts +1 -0
  158. package/dist/orchestration/run-spec.d.ts +3 -0
  159. package/dist/orchestration/run-spec.js +3 -3
  160. package/dist/orchestration/run-workflow-tool.d.ts +12 -6
  161. package/dist/orchestration/run-workflow-tool.js +23 -5
  162. package/dist/orchestration/workflow-primitives.d.ts +4 -2
  163. package/dist/orchestration/workflow-primitives.js +1 -4
  164. package/dist/orchestration/workflow-script-store.js +9 -25
  165. package/dist/orchestration/workflow-types.d.ts +34 -4
  166. package/dist/orchestration/workflow-types.js +2 -0
  167. package/dist/orchestration/workflow.d.ts +39 -7
  168. package/dist/orchestration/workflow.js +237 -113
  169. package/dist/prompt-assembly/assemble.d.ts +2 -2
  170. package/dist/prompt-assembly/assemble.js +9 -9
  171. package/dist/prompt-assembly/composer.d.ts +6 -7
  172. package/dist/prompt-assembly/composer.js +10 -10
  173. package/dist/prompt-assembly/epoch.js +1 -1
  174. package/dist/prompt-assembly/event-registry.d.ts +0 -21
  175. package/dist/prompt-assembly/packs/sema-default.js +30 -30
  176. package/dist/prompt-assembly/types.d.ts +14 -14
  177. package/dist/prompts/coordinator.d.ts +0 -13
  178. package/dist/prompts/coordinator.js +0 -3
  179. package/dist/stores/cc/task-list-store.js +2 -10
  180. package/dist/stores/file/checkpoint-store.js +1 -5
  181. package/dist/stores/file/file-history-store.js +2 -2
  182. package/dist/stores/file/fs-atomic.d.ts +15 -18
  183. package/dist/stores/file/fs-atomic.js +4 -14
  184. package/dist/stores/file/mailbox-store.d.ts +7 -11
  185. package/dist/stores/file/mailbox-store.js +4 -11
  186. package/dist/stores/file/permission-rule-store.d.ts +4 -35
  187. package/dist/stores/file/permission-rule-store.js +4 -148
  188. package/dist/tools/artifact/artifact-text.d.ts +62 -0
  189. package/dist/tools/artifact/artifact-text.js +68 -0
  190. package/dist/tools/artifact/artifact-tool.d.ts +52 -0
  191. package/dist/tools/artifact/artifact-tool.js +351 -0
  192. package/dist/tools/artifact/local-stub.d.ts +26 -0
  193. package/dist/tools/artifact/local-stub.js +297 -0
  194. package/dist/tools/fs/bash-readonly-classifier.d.ts +90 -8
  195. package/dist/tools/fs/bash-readonly-classifier.js +294 -70
  196. package/dist/tools/fs/fs-bash.d.ts +14 -10
  197. package/dist/tools/fs/fs-bash.js +50 -33
  198. package/dist/tools/fs/fs-search-tools.js +5 -2
  199. package/dist/tools/fs/fs-shared.d.ts +2 -0
  200. package/dist/tools/fs/fs-shared.js +3 -11
  201. package/dist/tools/fs/index.d.ts +4 -0
  202. package/dist/tools/fs/index.js +4 -1
  203. package/dist/tools/fs/safety.d.ts +117 -1
  204. package/dist/tools/fs/safety.js +131 -31
  205. package/dist/tools/fs/search.js +8 -6
  206. package/package.json +2 -1
  207. package/test/export-surface.snapshot.json +119 -39
@@ -1,4 +1,4 @@
1
- import { isAbsolutePathForm, isBlockedDevicePath, normalizeAbsPathLexically, withinAnyRoot } from "./safety.js";
1
+ import { isAbsoluteForFamily, isAbsolutePathForm, isBlockedDevicePath, isShellRootedSpellingUnmapped, joinForFamily, nativeUncSpellingOf, normalizeAbsPathLexically, win32NamespaceScreen, pathFamilyOf, withinAnyRoot } from "./safety.js";
2
2
  export const NOT_AUTO_ALLOWED = "— not auto-allowed";
3
3
  export const BASH_READONLY_DEFAULT_ALLOW = [
4
4
  "ls", "cat", "head", "tail", "wc", "pwd", "echo", "whoami", "uname",
@@ -213,7 +213,7 @@ const NO_PATH_OPERAND_COMMANDS = new Set([
213
213
  function isPathShapedToken(tok) {
214
214
  return tok.includes("/") || tok.startsWith("~") || tok === "." || tok === "..";
215
215
  }
216
- function resolveOperandLexically(base, operand, homeDir) {
216
+ export function resolveOperandLexically(base, operand, homeDir) {
217
217
  let raw = operand;
218
218
  if (raw === "~" || raw.startsWith("~/")) {
219
219
  if (homeDir === undefined || !isAbsolutePathForm(homeDir))
@@ -223,15 +223,107 @@ function resolveOperandLexically(base, operand, homeDir) {
223
223
  else if (raw.startsWith("~")) {
224
224
  return undefined;
225
225
  }
226
- if (isAbsolutePathForm(raw))
227
- return normalizeAbsPathLexically(raw);
228
- if (base === undefined || !isAbsolutePathForm(base))
226
+ const family = base === undefined ? undefined : pathFamilyOf({ root: base });
227
+ if (!win32NamespaceScreen(raw, family).ok)
229
228
  return undefined;
230
- return normalizeAbsPathLexically(`${base.replace(/[/\\]+$/, "")}/${raw}`);
229
+ if (isAbsoluteForFamily(family, raw))
230
+ return normalizeAbsPathLexically(nativeUncSpellingOf(family, raw));
231
+ if (base === undefined || family === undefined || !isAbsoluteForFamily(family, base))
232
+ return undefined;
233
+ if (isShellRootedSpellingUnmapped(family, raw))
234
+ return undefined;
235
+ return normalizeAbsPathLexically(joinForFamily(family, base, raw));
236
+ }
237
+ function reheadSegment(toks, head, from = 0) {
238
+ return { folded: [head, ...toks.folded.slice(from + 1)], raw: [head, ...toks.raw.slice(from + 1)] };
239
+ }
240
+ const SHELL_CONTROL_PREFIXES = new Set(["!", "if", "then", "elif", "else", "while", "until", "do", "time"]);
241
+ const SHELL_BLOCK_WORDS = new Set(["for", "select", "case", "esac", "fi", "done", "function", "coproc", "[[", "]]", "in"]);
242
+ function isBareControlWord(toks, i, words) {
243
+ const word = toks.folded[i];
244
+ return word !== undefined && toks.raw[i] === word && words.has(word);
245
+ }
246
+ function tokensMoveBase(toks, allow) {
247
+ let from = 0;
248
+ while (from < toks.folded.length && isBareControlWord(toks, from, SHELL_CONTROL_PREFIXES))
249
+ from++;
250
+ const head = toks.folded[from];
251
+ if (head === undefined)
252
+ return false;
253
+ if (toks.raw[from] !== head)
254
+ return true;
255
+ if (head === "cd")
256
+ return true;
257
+ if (!COMMAND_LAUNCHERS.has(head))
258
+ return false;
259
+ const unwrapped = unwrapLauncher(toks.folded.slice(from), allow);
260
+ return unwrapped.kind === "refused" || (unwrapped.kind === "reader" && unwrapped.head === "cd");
261
+ }
262
+ const SHELL_REENTRY_PROGRAM_RE = /^(?:(?:ba|z|da|k|fi)?sh|eval|source|\.)$/;
263
+ export function refusedCommandMayRead(command, allow) {
264
+ for (const m of command.matchAll(/(?<![<>&\d])<(?![<&])\s*(\S*)/g)) {
265
+ const target = (m[1] ?? "").replace(/^["']+|["']+$/g, "");
266
+ if (target === "" || /^[$`(~]/.test(target))
267
+ return true;
268
+ }
269
+ const fold = (w) => w.replace(/\\(.)/g, "$1").replace(/["']/g, "");
270
+ const programOf = (w) => {
271
+ const inner = fold(w).replace(/^[$`(]+|[)`]+$/g, "");
272
+ return inner.includes("/") ? inner.slice(inner.lastIndexOf("/") + 1) : inner;
273
+ };
274
+ for (const word of command.split(/[\s<>|;&()]+/)) {
275
+ const base = programOf(word);
276
+ if (base === "")
277
+ continue;
278
+ if ((allow.has(base) && !NO_PATH_OPERAND_COMMANDS.has(base)) || SHELL_REENTRY_PROGRAM_RE.test(base))
279
+ return true;
280
+ }
281
+ for (const segment of command.split(/\n|;|&&|\|\||\||(?<![>&\d])&(?![>&])/)) {
282
+ for (const raw of segment.trim().split(/\s+/)) {
283
+ const word = fold(raw);
284
+ if (word === "")
285
+ continue;
286
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word) || SHELL_CONTROL_PREFIXES.has(word) || SHELL_BLOCK_WORDS.has(word) || word === "{" || word === "}" || COMMAND_LAUNCHERS.has(programOf(word)) || word.startsWith("-"))
287
+ continue;
288
+ if (/^[$`(]/.test(word))
289
+ return true;
290
+ break;
291
+ }
292
+ }
293
+ return false;
294
+ }
295
+ function unwrapLauncher(folded, allow) {
296
+ for (let i = 1; i < folded.length; i++) {
297
+ const t = folded[i];
298
+ if (ASSIGNMENT_WORD.test(t) || /^\d+[smhd]?$/.test(t) || COMMAND_LAUNCHERS.has(t))
299
+ continue;
300
+ if (t.startsWith("-"))
301
+ return { kind: "refused", reason: `"${folded[0]}" is given an option before the program it runs — which program runs is not readable without the launcher's option table ${NOT_AUTO_ALLOWED}` };
302
+ const head = t.includes("/") ? t.slice(t.lastIndexOf("/") + 1) : t;
303
+ if (SHELL_CONTROL_PREFIXES.has(head) || SHELL_BLOCK_WORDS.has(head))
304
+ return { kind: "refused", reason: `"${folded[0]}" is given shell control syntax ("${head}") where the program it runs would be ${NOT_AUTO_ALLOWED}` };
305
+ return allow.has(head) || head === "cd" ? { kind: "reader", index: i, head } : { kind: "unlisted" };
306
+ }
307
+ return { kind: "unlisted" };
231
308
  }
232
309
  function tokenizeSegment(segment) {
233
310
  const raw = splitWordsQuoteAware(segment);
234
- return { folded: raw.map(foldQuoteRemovalToken), raw };
311
+ return { folded: raw.map((r) => { const f = foldQuoteRemovalToken(r); return tildeIsLiteral(r) ? literalTildeSpelling(f) : f; }), raw };
312
+ }
313
+ function literalTildeSpelling(word) {
314
+ return word.startsWith("~") ? `./${word}` : word;
315
+ }
316
+ function tildeIsLiteral(raw) {
317
+ if (!raw.startsWith("~"))
318
+ return true;
319
+ for (let i = 1; i < raw.length; i++) {
320
+ const ch = raw[i];
321
+ if (ch === '"' || ch === "'")
322
+ return true;
323
+ if (ch === "/")
324
+ return false;
325
+ }
326
+ return false;
235
327
  }
236
328
  function splitWordsQuoteAware(segment) {
237
329
  const s = segment.trim();
@@ -575,11 +667,12 @@ function grepClusterValueOwner(tok) {
575
667
  return undefined;
576
668
  }
577
669
  function attachedOptionPayloads(tok) {
670
+ const literal = literalTildeSpelling;
578
671
  const out = [];
579
672
  if (tok.startsWith("--")) {
580
673
  const eq = tok.indexOf("=");
581
674
  if (eq > 0 && eq + 1 < tok.length)
582
- out.push(tok.slice(eq + 1));
675
+ out.push(literal(tok.slice(eq + 1)));
583
676
  return out;
584
677
  }
585
678
  for (let k = 1; k <= tok.length; k++) {
@@ -587,7 +680,7 @@ function attachedOptionPayloads(tok) {
587
680
  break;
588
681
  const payload = tok.slice(k);
589
682
  if (payload.length > 0)
590
- out.push(payload);
683
+ out.push(literal(payload));
591
684
  }
592
685
  return out;
593
686
  }
@@ -600,31 +693,35 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
600
693
  const candidates = [];
601
694
  const bareWordOperands = [];
602
695
  const positionalOperands = [];
696
+ let stdinOperand = false;
603
697
  const argGlobs = (k) => {
604
698
  const raw = tokens.raw[k + 1];
605
699
  return raw !== undefined && hasUnquotedGlobMetachar(raw);
606
700
  };
607
- for (const rawArg of tokens.raw.slice(1)) {
701
+ const expandingArgs = new Set();
702
+ tokens.raw.slice(1).forEach((rawArg, k) => {
608
703
  if (!hasUnquotedExpansionMetachar(rawArg))
609
- continue;
610
- return [
611
- {
612
- kind: "unresolvable",
613
- 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`,
614
- },
615
- ];
616
- }
704
+ return;
705
+ expandingArgs.add(k);
706
+ findings.push({
707
+ kind: "unresolvable",
708
+ operand: rawArg,
709
+ 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`,
710
+ });
711
+ });
712
+ if (name === "cd" && expandingArgs.size > 0)
713
+ return findings;
617
714
  if (name === "cd") {
618
715
  const targetIdx = args.findIndex((t) => !t.startsWith("-") || t === "-");
619
716
  const target = targetIdx === -1 ? undefined : args[targetIdx];
620
717
  if (target !== undefined && argGlobs(targetIdx)) {
621
- return [{ kind: "unresolvable", reason: `\`cd\` is given the pattern "${target}", which the shell expands to a directory this check cannot know ${NOT_AUTO_ALLOWED}` }];
718
+ 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}` }];
622
719
  }
623
720
  if (target === undefined) {
624
- return [{ kind: "unresolvable", reason: '`cd` with no argument targets the home directory, which cannot be checked against the allowed directories ' + NOT_AUTO_ALLOWED }];
721
+ 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 }];
625
722
  }
626
723
  if (target === "-") {
627
- return [{ kind: "unresolvable", reason: '`cd -` targets the previous working directory, which cannot be resolved statically ' + NOT_AUTO_ALLOWED }];
724
+ return [{ kind: "unresolvable", operand: "-", reason: '`cd -` targets the previous working directory, which cannot be resolved statically ' + NOT_AUTO_ALLOWED }];
628
725
  }
629
726
  candidates.push({ text: target, globbed: false });
630
727
  }
@@ -635,6 +732,8 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
635
732
  let endOfOptions = false;
636
733
  for (let k = 0; k < args.length; k++) {
637
734
  const t = args[k];
735
+ if (expandingArgs.has(k))
736
+ continue;
638
737
  if (!endOfOptions) {
639
738
  if (t === "--") {
640
739
  endOfOptions = true;
@@ -662,8 +761,10 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
662
761
  if (isGrepPatternSlot)
663
762
  continue;
664
763
  const m = RECURSIVE_READ_FORMS[name];
665
- if (m === undefined || m.dashIsStdin === true)
764
+ if (m === undefined || m.dashIsStdin === true) {
765
+ stdinOperand = true;
666
766
  continue;
767
+ }
667
768
  positionalOperands.push(t);
668
769
  continue;
669
770
  }
@@ -677,7 +778,7 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
677
778
  }
678
779
  }
679
780
  for (let k = 0; k < args.length; k++) {
680
- if (!argGlobs(k))
781
+ if (!argGlobs(k) || expandingArgs.has(k))
681
782
  continue;
682
783
  const resolved = resolveOperandLexically(boundary.cwd ?? boundary.roots[0], args[k], boundary.homeDir);
683
784
  findings.push({ kind: "undecided", path: resolved ?? args[k] });
@@ -687,6 +788,7 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
687
788
  if (resolved === undefined) {
688
789
  findings.push({
689
790
  kind: "unresolvable",
791
+ operand: candidate,
690
792
  reason: `"${name}" names the path "${candidate}", which cannot be resolved statically ${NOT_AUTO_ALLOWED}`,
691
793
  });
692
794
  continue;
@@ -706,7 +808,7 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
706
808
  findings.push({ kind: "inside", path: resolved });
707
809
  }
708
810
  if (boundary.denyMatch !== undefined && segmentSelectsRecursiveRead(name, args)) {
709
- const roots = positionalOperands.length > 0 ? positionalOperands : ["."];
811
+ const roots = positionalOperands.length > 0 ? positionalOperands : stdinOperand ? [] : ["."];
710
812
  for (const operand of roots) {
711
813
  const resolved = resolveOperandLexically(boundary.cwd ?? boundary.roots[0], operand, boundary.homeDir);
712
814
  findings.push({ kind: "recursive", path: resolved ?? operand });
@@ -714,8 +816,56 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
714
816
  }
715
817
  return findings;
716
818
  }
717
- function segmentCompoundForReadonly(command) {
718
- const trimmed = command.trim();
819
+ export function foldHomeVariableSpelling(command, homeDir) {
820
+ if (homeDir === undefined || !command.includes("$") || /[\\`]/.test(command))
821
+ return command;
822
+ const unquotedIsExact = /^[A-Za-z0-9_./+@:%=,-]+$/.test(homeDir);
823
+ const quotedIsExact = !/["$`\\\n\r]/.test(homeDir);
824
+ const UNQUOTED_HOME_WORD = /^\$(?:HOME|\{HOME\})(?=[/\s;|&"]|$)/;
825
+ const DOUBLE_QUOTED_HOME_WORD = /^\$(?:HOME|\{HOME\})(?=[/"]|$)/;
826
+ let open;
827
+ let wordStart = true;
828
+ let quoteHadContent = false;
829
+ let out = "";
830
+ for (let i = 0; i < command.length; i++) {
831
+ const ch = command[i];
832
+ if (open === undefined && (ch === '"' || ch === "'")) {
833
+ open = ch;
834
+ quoteHadContent = false;
835
+ out += ch;
836
+ continue;
837
+ }
838
+ if (open !== undefined && open === ch) {
839
+ open = undefined;
840
+ wordStart = wordStart && !quoteHadContent;
841
+ out += ch;
842
+ continue;
843
+ }
844
+ if (open !== undefined)
845
+ quoteHadContent = true;
846
+ if (open === undefined && /[\s;|&]/.test(ch)) {
847
+ wordStart = true;
848
+ out += ch;
849
+ continue;
850
+ }
851
+ if (ch === "$" && wordStart && ((open === '"' && quotedIsExact) || (open === undefined && unquotedIsExact))) {
852
+ const m = (open === '"' ? DOUBLE_QUOTED_HOME_WORD : UNQUOTED_HOME_WORD).exec(command.slice(i));
853
+ if (m !== null) {
854
+ wordStart = false;
855
+ if (open === '"')
856
+ quoteHadContent = true;
857
+ out += homeDir;
858
+ i += m[0].length - 1;
859
+ continue;
860
+ }
861
+ }
862
+ wordStart = false;
863
+ out += ch;
864
+ }
865
+ return open === undefined ? out : command;
866
+ }
867
+ function segmentCompoundForReadonly(command, homeDir) {
868
+ const trimmed = foldHomeVariableSpelling(command, homeDir).trim();
719
869
  if (!trimmed)
720
870
  return { reject: "empty command" };
721
871
  const redirectionStripped = stripExemptRedirections(trimmed);
@@ -725,14 +875,53 @@ function segmentCompoundForReadonly(command) {
725
875
  return splitShellCompoundSegments(terminatorStripped, { trailingTerminator: "keep" });
726
876
  }
727
877
  export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts) {
728
- const split = segmentCompoundForReadonly(command);
878
+ const split = segmentCompoundForReadonly(command, boundary?.homeDir);
729
879
  if ("reject" in split)
730
- return { reason: split.reject };
880
+ return { reason: split.reject, refusedWhole: true };
731
881
  const { segments, pipeFed } = split;
732
- for (const segment of segments) {
733
- const reason = coarseReadonlyCheck(segment, allow, { quotedOperatorsAreText: true });
734
- if (reason !== undefined)
735
- return { reason };
882
+ let nameRefusal;
883
+ const unlistedSegments = new Set();
884
+ const reheaded = new Map();
885
+ for (let si = 0; si < segments.length; si++) {
886
+ const toks = tokenizeSegment(segments[si]);
887
+ const folded = toks.folded;
888
+ let from = 0;
889
+ while (from < folded.length && isBareControlWord(toks, from, SHELL_CONTROL_PREFIXES))
890
+ from++;
891
+ const written = folded[from];
892
+ if (written === undefined)
893
+ return { reason: "empty command" };
894
+ if (isBareControlWord(toks, from, SHELL_BLOCK_WORDS)) {
895
+ unlistedSegments.add(si);
896
+ nameRefusal ??= `command "${written}" is not in the read-only allowlist`;
897
+ continue;
898
+ }
899
+ const parsed = parseLeadingCommandName(toks.raw.slice(from).join(" "), { quotedOperatorsAreText: true, pathPrefixedNameIsText: true });
900
+ if ("reject" in parsed)
901
+ return { reason: parsed.reject, refusedWhole: true };
902
+ const pathPrefixed = parsed.name.includes("/");
903
+ const name = pathPrefixed ? parsed.name.slice(parsed.name.lastIndexOf("/") + 1) : parsed.name;
904
+ if (pathPrefixed)
905
+ nameRefusal ??= "the command must be a bare name resolved via PATH (no path prefix)";
906
+ if (allow.has(name)) {
907
+ if (pathPrefixed || from > 0)
908
+ reheaded.set(si, { from, head: name });
909
+ if (from > 0)
910
+ nameRefusal ??= `command "${folded[0]}" is not in the read-only allowlist`;
911
+ continue;
912
+ }
913
+ nameRefusal ??= `command "${parsed.name}" is not in the read-only allowlist`;
914
+ if (COMMAND_LAUNCHERS.has(name)) {
915
+ const unwrapped = unwrapLauncher(folded.slice(from), allow);
916
+ if (unwrapped.kind === "refused")
917
+ return { reason: unwrapped.reason, refusedWhole: true };
918
+ if (unwrapped.kind === "unlisted")
919
+ unlistedSegments.add(si);
920
+ else
921
+ reheaded.set(si, { from: from + unwrapped.index, head: unwrapped.head });
922
+ continue;
923
+ }
924
+ unlistedSegments.add(si);
736
925
  }
737
926
  const HEAD_AUTO_ALLOW_MAX = 1_000_000;
738
927
  const headBoundIsSmall = (name, toks) => {
@@ -764,16 +953,22 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
764
953
  };
765
954
  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 };
766
955
  const foldedSegments = [];
956
+ let shapeRefusal = nameRefusal;
767
957
  for (let si = 0; si < segments.length; si++) {
768
- const segmentTokens = tokenizeSegment(segments[si]);
958
+ if (unlistedSegments.has(si))
959
+ continue;
960
+ const rehead = reheaded.get(si);
961
+ const segmentTokens = rehead === undefined ? tokenizeSegment(segments[si]) : reheadSegment(tokenizeSegment(segments[si]), rehead.head, rehead.from);
769
962
  const toks = segmentTokens.folded;
770
963
  if (toks.length === 0)
771
964
  continue;
772
965
  foldedSegments.push(segmentTokens);
773
966
  const name = toks[0];
774
967
  const guardReason = guardedVerbFormReason(name, toks, segmentTokens.raw);
775
- if (guardReason !== undefined)
776
- return { reason: guardReason };
968
+ if (guardReason !== undefined) {
969
+ shapeRefusal ??= guardReason;
970
+ continue;
971
+ }
777
972
  if (!(pipeFed[si] ?? false)) {
778
973
  const floor = STDIN_FILE_FLOOR[name];
779
974
  const restArgs = toks.slice(1);
@@ -815,14 +1010,17 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
815
1010
  }
816
1011
  }
817
1012
  if (floor !== undefined && hasStdinDash) {
818
- return { reason: `"${name}" reads stdin via an explicit "-" argument and would block until the tool timeout ${NOT_AUTO_ALLOWED}` };
1013
+ shapeRefusal ??= `"${name}" reads stdin via an explicit "-" argument and would block until the tool timeout ${NOT_AUTO_ALLOWED}`;
1014
+ continue;
819
1015
  }
820
1016
  if (floor !== undefined && operandCount < floor) {
821
- return { reason: `"${name}" with no file argument reads stdin and would block until the tool timeout ${NOT_AUTO_ALLOWED}` };
1017
+ shapeRefusal ??= `"${name}" with no file argument reads stdin and would block until the tool timeout ${NOT_AUTO_ALLOWED}`;
1018
+ continue;
822
1019
  }
823
1020
  }
824
1021
  if (name === "tail" && toks.slice(1).some((t) => t === "--follow" || t.startsWith("--follow=") || /^[-+][^\s]*[fF]/.test(t))) {
825
- return { reason: "`tail` in follow mode never terminates " + NOT_AUTO_ALLOWED };
1022
+ shapeRefusal ??= "`tail` in follow mode never terminates " + NOT_AUTO_ALLOWED;
1023
+ continue;
826
1024
  }
827
1025
  const GENERATOR_DEVICES = new Set(["/dev/zero", "/dev/random", "/dev/urandom", "/dev/full"]);
828
1026
  const deviceCandidates = [];
@@ -833,20 +1031,26 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
833
1031
  }
834
1032
  deviceCandidates.push(t);
835
1033
  }
836
- const deviceArgs = deviceCandidates.map(normalizeAbsPathLexically).filter(isBlockedDevicePath);
1034
+ const deviceArgs = deviceCandidates.map((t) => resolveOperandLexically(undefined, t, boundary?.homeDir) ?? normalizeAbsPathLexically(t)).filter(isBlockedDevicePath);
837
1035
  const rescuedByHead = headBoundIsSmall(name, toks) && deviceArgs.every((d) => GENERATOR_DEVICES.has(d));
838
1036
  if (!rescuedByHead && deviceArgs.length > 0) {
839
- return { reason: "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 };
1037
+ 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;
840
1038
  }
841
1039
  }
842
1040
  if (opts?.iterated !== true) {
843
1041
  const cdSegments = foldedSegments.filter((st) => st.folded[0] === "cd");
844
- if (cdSegments.length > 1) {
845
- return { reason: `multiple directory changes in one command require approval for clarity ${NOT_AUTO_ALLOWED}` };
846
- }
1042
+ if (cdSegments.length > 1)
1043
+ shapeRefusal ??= `multiple directory changes in one command require approval for clarity ${NOT_AUTO_ALLOWED}`;
847
1044
  }
848
- if (boundary !== undefined)
849
- return evaluateReadBoundary(foldedSegments, boundary);
1045
+ if (boundary !== undefined) {
1046
+ const walk = evaluateReadBoundary(foldedSegments, boundary);
1047
+ if (shapeRefusal === undefined)
1048
+ return walk;
1049
+ const { reason: _walkSentence, ...structure } = walk;
1050
+ return { ...structure, reason: shapeRefusal };
1051
+ }
1052
+ if (shapeRefusal !== undefined)
1053
+ return { reason: shapeRefusal };
850
1054
  if (foldedSegments.some((st) => st.folded[0] === "cd")) {
851
1055
  return { reason: `\`cd\` cannot be judged without a read boundary (no base to resolve the directory change against) ${NOT_AUTO_ALLOWED}` };
852
1056
  }
@@ -855,14 +1059,25 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
855
1059
  function evaluateReadBoundary(foldedSegments, boundary) {
856
1060
  const outside = [];
857
1061
  const inside = [];
1062
+ let unresolvable;
1063
+ const unresolved = [];
1064
+ let baseLost = false;
858
1065
  const undecided = [];
859
1066
  const recursive = [];
860
1067
  let currentBase = boundary.cwd ?? boundary.roots[0];
861
1068
  for (const toks of foldedSegments) {
1069
+ if (baseLost)
1070
+ break;
862
1071
  const segBoundary = currentBase === (boundary.cwd ?? boundary.roots[0]) ? boundary : { ...boundary, cwd: currentBase };
863
1072
  for (const finding of collectSegmentBoundaryFindings(toks, segBoundary)) {
864
- if (finding.kind === "unresolvable")
865
- return { reason: finding.reason };
1073
+ if (finding.kind === "unresolvable") {
1074
+ unresolvable ??= finding.reason;
1075
+ if (!unresolved.includes(finding.operand))
1076
+ unresolved.push(finding.operand);
1077
+ if (toks.folded[0] === "cd")
1078
+ baseLost = true;
1079
+ continue;
1080
+ }
866
1081
  const denied = boundary.denyMatch?.(finding.path);
867
1082
  if (denied != null) {
868
1083
  return {
@@ -893,7 +1108,7 @@ function evaluateReadBoundary(foldedSegments, boundary) {
893
1108
  if (!outside.some((o) => o.path === finding.path))
894
1109
  outside.push(finding);
895
1110
  }
896
- if (toks.folded[0] === "cd") {
1111
+ if (!baseLost && toks.folded[0] === "cd") {
897
1112
  const cdArgs = toks.folded.slice(1);
898
1113
  const cdTargetIdx = cdArgs.findIndex((t) => !t.startsWith("-") || t === "-");
899
1114
  const cdTarget = cdTargetIdx === -1 ? undefined : cdArgs[cdTargetIdx];
@@ -907,6 +1122,15 @@ function evaluateReadBoundary(foldedSegments, boundary) {
907
1122
  ...(undecidedAll.length > 0 ? { undecidedPaths: undecidedAll } : {}),
908
1123
  ...(recursive.length > 0 ? { recursiveReadPaths: recursive } : {}),
909
1124
  };
1125
+ if (unresolvable !== undefined) {
1126
+ return {
1127
+ reason: unresolvable,
1128
+ unresolvedOperands: unresolved,
1129
+ ...(outside.length > 0 ? { outOfRootRead: true, outOfRootPaths: outside.map((o) => o.path) } : {}),
1130
+ ...(inside.length > 0 ? { checkedPaths: inside } : {}),
1131
+ ...undecidedField,
1132
+ };
1133
+ }
910
1134
  if (outside.length === 0)
911
1135
  return inside.length > 0 ? { checkedPaths: inside, ...undecidedField } : { ...undecidedField };
912
1136
  const paths = outside.map((o) => `"${o.path}"`).join(", ");
@@ -948,7 +1172,7 @@ export function classifyOutOfRootReadGate(command, allow, boundary) {
948
1172
  ...(boundary.face !== undefined ? { face: boundary.face } : {}),
949
1173
  };
950
1174
  const judge = (text) => {
951
- const split = segmentCompoundForReadonly(text);
1175
+ const split = segmentCompoundForReadonly(text, containmentOnly.homeDir);
952
1176
  if ("reject" in split)
953
1177
  return NOT_GATED;
954
1178
  const readers = [];
@@ -1028,69 +1252,69 @@ function pollLoopSleepReason(segment) {
1028
1252
  return undefined;
1029
1253
  }
1030
1254
  export function classifyBoundedReadonlyPollLoop(command, allow, boundary) {
1255
+ return classifyBoundedReadonlyPollLoopDetailed(command, allow, boundary).reason;
1256
+ }
1257
+ export function classifyBoundedReadonlyPollLoopDetailed(command, allow, boundary) {
1031
1258
  if (NON_ASCII_WHITESPACE.test(command)) {
1032
- return "the command contains a non-ASCII or control whitespace character (only space and tab are allowed) — its word boundaries cannot be read the way bash would split them, so it is not auto-allowed";
1259
+ return { reason: "the command contains a non-ASCII or control whitespace character (only space and tab are allowed) — its word boundaries cannot be read the way bash would split them, so it is not auto-allowed" };
1033
1260
  }
1034
- const trimmed = command.trim();
1261
+ const trimmed = foldHomeVariableSpelling(command, boundary?.homeDir).trim();
1035
1262
  if (!trimmed)
1036
- return "empty command";
1263
+ return { reason: "empty command" };
1037
1264
  if (!pollLoopQuotesBalanced(trimmed)) {
1038
- return "unbalanced quote — the command's structure cannot be read reliably, so it is not auto-allowed";
1265
+ return { reason: "unbalanced quote — the command's structure cannot be read reliably, so it is not auto-allowed" };
1039
1266
  }
1040
1267
  const m = POLL_LOOP_SHAPE.exec(trimmed);
1041
1268
  if (m === null) {
1042
- return "not a bounded read-only poll loop (`for <v> in <literal bound>; do <read commands>; done` is the only accepted control structure)";
1269
+ return { reason: "not a bounded read-only poll loop (`for <v> in <literal bound>; do <read commands>; done` is the only accepted control structure)" };
1043
1270
  }
1044
1271
  const varName = m[1];
1045
1272
  const iterable = m[2];
1046
1273
  const body = m[3];
1047
1274
  if (!/^[a-z]$/.test(varName)) {
1048
- return `the loop variable "${varName}" must be a single lowercase letter — a name like PATH/IFS/LD_PRELOAD would change how the body's commands resolve`;
1275
+ return { reason: `the loop variable "${varName}" must be a single lowercase letter — a name like PATH/IFS/LD_PRELOAD would change how the body's commands resolve` };
1049
1276
  }
1050
1277
  const beats = pollLoopBeatsOf(iterable);
1051
1278
  if (beats === undefined) {
1052
- return `the loop bound "${iterable}" is not a recognized literal — only \`$(seq <int> <int>)\`, \`{<int>..<int>}\` (ascending) or a literal word list is accepted`;
1279
+ return { reason: `the loop bound "${iterable}" is not a recognized literal — only \`$(seq <int> <int>)\`, \`{<int>..<int>}\` (ascending) or a literal word list is accepted` };
1053
1280
  }
1054
1281
  if (beats > POLL_LOOP_MAX_BEATS) {
1055
- return `the loop runs ${beats} iterations, above the ${POLL_LOOP_MAX_BEATS}-iteration cap for auto-allow`;
1282
+ return { reason: `the loop runs ${beats} iterations, above the ${POLL_LOOP_MAX_BEATS}-iteration cap for auto-allow` };
1056
1283
  }
1057
1284
  if (POLL_BODY_HARD_REJECT.test(body)) {
1058
- return "the loop body may not contain redirection, pipes, backgrounding, substitution, subshells, braces, escapes, or line breaks";
1285
+ return { reason: "the loop body may not contain redirection, pipes, backgrounding, substitution, subshells, braces, escapes, or line breaks" };
1059
1286
  }
1060
1287
  const readSegments = [];
1061
1288
  for (const rawSegment of body.split(";")) {
1062
1289
  const segment = rawSegment.trim();
1063
1290
  if (segment.length === 0)
1064
- return "empty command in the loop body";
1291
+ return { reason: "empty command in the loop body" };
1065
1292
  const parsed = parseLeadingCommandName(segment);
1066
1293
  if ("reject" in parsed)
1067
- return parsed.reject;
1294
+ return { reason: parsed.reject };
1068
1295
  if (parsed.name === "sleep") {
1069
1296
  const sleepReason = pollLoopSleepReason(segment);
1070
1297
  if (sleepReason !== undefined)
1071
- return sleepReason;
1298
+ return { reason: sleepReason };
1072
1299
  continue;
1073
1300
  }
1074
1301
  readSegments.push(segment);
1075
1302
  }
1076
1303
  if (readSegments.length === 0) {
1077
- return "the loop body has no read command — a sleep-only loop observes nothing and is not auto-allowed";
1304
+ return { reason: "the loop body has no read command — a sleep-only loop observes nothing and is not auto-allowed" };
1078
1305
  }
1079
- const bodyHasCd = readSegments.some((seg) => {
1080
- const p = parseLeadingCommandName(seg);
1081
- return "name" in p && p.name === "cd";
1082
- });
1306
+ const bodyHasCd = readSegments.some((seg) => tokensMoveBase(tokenizeSegment(seg), allow));
1083
1307
  const modelled = bodyHasCd ? Array.from({ length: beats }, () => readSegments.join("; ")).join("; ") : readSegments.join("; ");
1084
1308
  const verdict = classifyCompoundReadonlyDetailed(modelled, allow, boundary, { iterated: bodyHasCd });
1085
1309
  if (verdict.reason !== undefined)
1086
- return verdict.reason;
1310
+ return verdict;
1087
1311
  if (verdict.recursiveReadPaths !== undefined) {
1088
1312
  const roots = verdict.recursiveReadPaths;
1089
1313
  const shown = roots.slice(0, POLL_LOOP_CAUSE_MAX_PATHS);
1090
- return `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` : ""}`;
1314
+ 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` : ""}` };
1091
1315
  }
1092
1316
  if (verdict.undecidedPaths !== undefined) {
1093
- return `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`;
1317
+ 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` };
1094
1318
  }
1095
- return undefined;
1319
+ return verdict;
1096
1320
  }
@@ -73,24 +73,28 @@ export declare function bashReversibilityProbe(allow?: readonly string[], bounda
73
73
  * · the boundary DECLARED an objection — a deny-listed operand, a recursive walk under the deny judge, an
74
74
  * operand outside the roots — ⇒ `{ reversible: false, mandated: true }` (+ the recursive-reach `cause`);
75
75
  * · the boundary could NOT READ where the command reads — an unexpanded glob operand (the compound face
76
- * reports it as undecided), a `~`-rooted operand with no home declared, a working-directory move the walk
77
- * cannot model — ⇒ `{ reversible: false }` (fail-closed; a plain ask, which a stored allow rule may retire —
78
- * the seat did not declare anything, it declined to guess);
76
+ * reports it as undecided), a `~`-rooted operand under an adapter that declares no home, a working-directory
77
+ * move the walk cannot model — ⇒ `{ reversible: false }` (fail-closed; a plain ask, which a stored allow rule
78
+ * may retire — the seat did not declare anything, it declined to guess). The engine wires the environment's
79
+ * declared home, so `~/x`, `$HOME/x` and `${HOME}/x` RESOLVE and are judged like the absolute spelling;
79
80
  * · otherwise ⇒ `{ reversible: true }`. This includes every command whose SHAPE the read faces refuse whole
80
81
  * (an unlisted program, a redirection, a substitution, a segment that is not a reader): those shapes carry
81
82
  * no read-face verdict, so this seat has no question to ask — the residual is the doctrine's, and under
82
- * `"off"` the doctrine asks nothing. `run_in_background` is likewise not this seat's question (it changes
83
- * WHEN the command runs, never WHERE it reads).
83
+ * `"off"` the doctrine asks nothing. A listed reader refused for a PER-SEGMENT shape (follow mode, a device,
84
+ * a blocking stdin read) is different: the compound face still walks its operands and carries the boundary's
85
+ * structural findings beside the sentence, so `tail -f <denied>` is the declared ask `cat <denied>` is.
86
+ * `run_in_background` is likewise not this seat's question (it changes WHEN the command runs, never WHERE it
87
+ * reads).
84
88
  *
85
89
  * Reach = the read faces' reach, and nothing wider: the compound face walks the operands of a command whose
86
90
  * every segment is a listed reader, and the bounded poll-loop face walks a loop body of listed readers. A
87
- * read hidden in a shape the faces refuse whole (`cat x | <unlisted>`, `$(cat x)`, `cat $DIR/x`, a launcher
88
- * `env cat x`, a path-prefixed `/bin/cat x`) is not seen here — deny-listed or out-of-root alike — exactly
91
+ * read hidden in a shape the faces refuse whole (`cat x | <unlisted>`, `$(cat x)`, `cat $DIR/x` a variable other
92
+ * than the home's own deterministic spellings — a launcher `env cat x`, a path-prefixed `/bin/cat x`) is not
93
+ * seen here — deny-listed or out-of-root alike — exactly
89
94
  * the shapes the `"classify"` doctrine exists to ask about. The text-level containment gate the classify
90
95
  * seat consults on its demotion arms is deliberately not consulted here ({@link boundaryDeclared}: it may
91
- * add a mandate to an ask, never originate one). The poll-loop face reports its refusal as one sentence,
92
- * so the boundary's share of it is read by removing the boundary's judging seats
93
- * ({@link pollLoopBoundaryVerdict}).
96
+ * add a mandate to an ask, never originate one). The poll-loop face's structured verdict
97
+ * ({@link classifyBoundedReadonlyPollLoopDetailed}) is read the same three ways as the compound face's.
94
98
  *
95
99
  * The reader set is the engine's classify set ({@link BASH_CLASSIFY_DEFAULT_ALLOW}) — here it is NOT an
96
100
  * auto-allow list but the set of programs whose operands the faces read; a wider set means more reads are