@sema-agent/core 7.16.0 → 7.17.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 (193) hide show
  1. package/CHANGELOG.md +173 -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-recall.d.ts +0 -8
  49. package/dist/core/memory.d.ts +1 -1
  50. package/dist/core/oracle-isolation.js +2 -2
  51. package/dist/core/parent-spec-seat.d.ts +66 -0
  52. package/dist/core/parent-spec-seat.js +47 -0
  53. package/dist/core/permission-rule-model.d.ts +11 -6
  54. package/dist/core/permission-rule-model.js +53 -37
  55. package/dist/core/permission-rules.js +8 -3
  56. package/dist/core/protocol-naming.d.ts +2 -2
  57. package/dist/core/remote-env.d.ts +1 -1
  58. package/dist/core/retired-keys.d.ts +19 -0
  59. package/dist/core/retired-keys.js +15 -0
  60. package/dist/core/runner/active-skill-scope.js +3 -3
  61. package/dist/core/runner/assemble-result.d.ts +15 -13
  62. package/dist/core/runner/assemble-result.js +5 -4
  63. package/dist/core/runner/compaction-call-options.d.ts +25 -89
  64. package/dist/core/runner/contracts.d.ts +10 -9
  65. package/dist/core/runner/gate-exit.js +15 -1
  66. package/dist/core/runner/halt-attribution.d.ts +35 -0
  67. package/dist/core/runner/halt-attribution.js +16 -0
  68. package/dist/core/runner/memory-consolidation.d.ts +0 -2
  69. package/dist/core/runner/memory-consolidation.js +0 -1
  70. package/dist/core/runner/prepare-artifact.d.ts +53 -0
  71. package/dist/core/runner/prepare-artifact.js +77 -0
  72. package/dist/core/runner/prepare-caps-and-workflow.d.ts +4 -2
  73. package/dist/core/runner/prepare-caps-and-workflow.js +2 -3
  74. package/dist/core/runner/prepare-config-doors.d.ts +3 -3
  75. package/dist/core/runner/prepare-config-doors.js +5 -2
  76. package/dist/core/runner/prepare-context-lane.d.ts +2 -2
  77. package/dist/core/runner/prepare-context-lane.js +2 -13
  78. package/dist/core/runner/prepare-file-history.d.ts +1 -1
  79. package/dist/core/runner/prepare-file-history.js +3 -52
  80. package/dist/core/runner/prepare-hands-readface.d.ts +4 -7
  81. package/dist/core/runner/prepare-hands-readface.js +3 -2
  82. package/dist/core/runner/prepare-inherited-gate.d.ts +5 -2
  83. package/dist/core/runner/prepare-inherited-gate.js +7 -8
  84. package/dist/core/runner/prepare-memory.d.ts +4 -6
  85. package/dist/core/runner/prepare-memory.js +4 -2
  86. package/dist/core/runner/prepare-policy-chain.d.ts +3 -3
  87. package/dist/core/runner/prepare-policy-chain.js +15 -30
  88. package/dist/core/runner/prepare-prompt-assembly.js +1 -1
  89. package/dist/core/runner/prepare-safety-scan.d.ts +1 -1
  90. package/dist/core/runner/prepare-suspend-saga.js +0 -2
  91. package/dist/core/runner/prepare-task.js +10 -9
  92. package/dist/core/runner/prepare-wiring-manifest.js +9 -7
  93. package/dist/core/runner/resume-admission.js +2 -1
  94. package/dist/core/runner/resume-internals-and-config.js +4 -0
  95. package/dist/core/runner/retired-runner-deps.d.ts +3 -0
  96. package/dist/core/runner/retired-runner-deps.js +7 -0
  97. package/dist/core/runner/run-harness-handlers.js +2 -3
  98. package/dist/core/runner/run-settle-and-teardown.d.ts +1 -1
  99. package/dist/core/runner/run-settle-and-teardown.js +3 -1
  100. package/dist/core/runner/session-file-state-replay.js +3 -3
  101. package/dist/core/runner/session-rule-policy.js +3 -4
  102. package/dist/core/runner/stream-halt-verbs.js +1 -1
  103. package/dist/core/runner/stream-steer-verb.js +1 -4
  104. package/dist/core/runner/workspace-path.js +3 -0
  105. package/dist/core/runner-deps.d.ts +30 -49
  106. package/dist/core/safe-notify.d.ts +1 -1
  107. package/dist/core/sensitive-path-policy.d.ts +1 -1
  108. package/dist/core/sensitive-path-policy.js +4 -4
  109. package/dist/core/session.d.ts +2 -2
  110. package/dist/core/skill-tool-specifier.d.ts +1 -1
  111. package/dist/core/skills-directory.d.ts +1 -1
  112. package/dist/core/store-contracts/checkpoint-store-contract.js +1 -1
  113. package/dist/core/strategy-store.d.ts +8 -11
  114. package/dist/core/task-event.d.ts +20 -17
  115. package/dist/core/task-limits.d.ts +1 -1
  116. package/dist/core/task-notification.d.ts +5 -5
  117. package/dist/core/task-registry-agent.d.ts +5 -5
  118. package/dist/core/task-registry-monitor.d.ts +2 -2
  119. package/dist/core/task-registry-shared.d.ts +27 -27
  120. package/dist/core/task-registry-shared.js +3 -13
  121. package/dist/core/task-registry.d.ts +8 -8
  122. package/dist/core/task-result.d.ts +37 -37
  123. package/dist/core/task-spec.d.ts +5 -5
  124. package/dist/core/task-stream.d.ts +16 -11
  125. package/dist/core/tool-catalog-entries.d.ts +1 -0
  126. package/dist/core/tool-catalog-entries.js +5 -0
  127. package/dist/core/tool-conformance.d.ts +0 -2
  128. package/dist/core/tool-conformance.js +1 -3
  129. package/dist/core/tool-errors.d.ts +5 -0
  130. package/dist/core/tool-errors.js +1 -0
  131. package/dist/core/tool-policy.d.ts +51 -22
  132. package/dist/core/tool-policy.js +28 -5
  133. package/dist/core/tool-result-budget.d.ts +1 -1
  134. package/dist/core/tool-result-store.d.ts +11 -11
  135. package/dist/core/tool-spec.d.ts +34 -38
  136. package/dist/core/tools.d.ts +2 -2
  137. package/dist/core/trace.d.ts +12 -15
  138. package/dist/core/untrusted-text.d.ts +1 -1
  139. package/dist/core/wiring-manifest.d.ts +4 -3
  140. package/dist/core/workflow-journal-store.d.ts +11 -11
  141. package/dist/engine/execution-env/node-execution-env.d.ts +3 -2
  142. package/dist/engine/execution-env/node-execution-env.js +4 -2
  143. package/dist/engine/harness/agent-harness.d.ts +47 -4
  144. package/dist/engine/harness/agent-harness.js +52 -9
  145. package/dist/engine/loop/agent-loop.js +15 -8
  146. package/dist/engine/loop/types.d.ts +5 -0
  147. package/dist/index.d.ts +19 -14
  148. package/dist/index.js +15 -10
  149. package/dist/internal/harness.d.ts +1 -0
  150. package/dist/orchestration/run-spec.d.ts +3 -0
  151. package/dist/orchestration/run-spec.js +3 -3
  152. package/dist/orchestration/run-workflow-tool.d.ts +12 -6
  153. package/dist/orchestration/run-workflow-tool.js +23 -5
  154. package/dist/orchestration/workflow-primitives.d.ts +4 -2
  155. package/dist/orchestration/workflow-primitives.js +1 -4
  156. package/dist/orchestration/workflow-types.d.ts +34 -4
  157. package/dist/orchestration/workflow-types.js +2 -0
  158. package/dist/orchestration/workflow.d.ts +39 -7
  159. package/dist/orchestration/workflow.js +234 -113
  160. package/dist/prompt-assembly/assemble.d.ts +2 -2
  161. package/dist/prompt-assembly/assemble.js +9 -9
  162. package/dist/prompt-assembly/composer.d.ts +6 -7
  163. package/dist/prompt-assembly/composer.js +10 -10
  164. package/dist/prompt-assembly/epoch.js +1 -1
  165. package/dist/prompt-assembly/event-registry.d.ts +0 -21
  166. package/dist/prompt-assembly/packs/sema-default.js +30 -30
  167. package/dist/prompt-assembly/types.d.ts +14 -14
  168. package/dist/prompts/coordinator.d.ts +0 -13
  169. package/dist/prompts/coordinator.js +0 -3
  170. package/dist/stores/file/checkpoint-store.js +1 -5
  171. package/dist/stores/file/file-history-store.js +2 -2
  172. package/dist/stores/file/permission-rule-store.d.ts +4 -35
  173. package/dist/stores/file/permission-rule-store.js +4 -148
  174. package/dist/tools/artifact/artifact-text.d.ts +62 -0
  175. package/dist/tools/artifact/artifact-text.js +68 -0
  176. package/dist/tools/artifact/artifact-tool.d.ts +52 -0
  177. package/dist/tools/artifact/artifact-tool.js +351 -0
  178. package/dist/tools/artifact/local-stub.d.ts +26 -0
  179. package/dist/tools/artifact/local-stub.js +296 -0
  180. package/dist/tools/fs/bash-readonly-classifier.d.ts +74 -8
  181. package/dist/tools/fs/bash-readonly-classifier.js +171 -60
  182. package/dist/tools/fs/fs-bash.d.ts +14 -10
  183. package/dist/tools/fs/fs-bash.js +19 -27
  184. package/dist/tools/fs/fs-search-tools.js +5 -2
  185. package/dist/tools/fs/fs-shared.d.ts +2 -0
  186. package/dist/tools/fs/fs-shared.js +3 -11
  187. package/dist/tools/fs/index.d.ts +4 -0
  188. package/dist/tools/fs/index.js +4 -1
  189. package/dist/tools/fs/safety.d.ts +117 -1
  190. package/dist/tools/fs/safety.js +131 -31
  191. package/dist/tools/fs/search.js +8 -6
  192. package/package.json +2 -1
  193. package/test/export-surface.snapshot.json +115 -39
@@ -1,4 +1,4 @@
1
- import { isAbsolutePathForm, isBlockedDevicePath, normalizeAbsPathLexically, withinAnyRoot } from "./safety.js";
1
+ import { isAbsoluteForFamily, isAbsolutePathForm, isBlockedDevicePath, isShellRootedSpellingUnmapped, joinForFamily, nativeUncSpellingOf, normalizeAbsPathLexically, 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,33 @@ 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 (isAbsoluteForFamily(family, raw))
228
+ return normalizeAbsPathLexically(nativeUncSpellingOf(family, raw));
229
+ if (base === undefined || family === undefined || !isAbsoluteForFamily(family, base))
229
230
  return undefined;
230
- return normalizeAbsPathLexically(`${base.replace(/[/\\]+$/, "")}/${raw}`);
231
+ if (isShellRootedSpellingUnmapped(family, raw))
232
+ return undefined;
233
+ return normalizeAbsPathLexically(joinForFamily(family, base, raw));
231
234
  }
232
235
  function tokenizeSegment(segment) {
233
236
  const raw = splitWordsQuoteAware(segment);
234
- return { folded: raw.map(foldQuoteRemovalToken), raw };
237
+ return { folded: raw.map((r) => { const f = foldQuoteRemovalToken(r); return tildeIsLiteral(r) ? literalTildeSpelling(f) : f; }), raw };
238
+ }
239
+ function literalTildeSpelling(word) {
240
+ return word.startsWith("~") ? `./${word}` : word;
241
+ }
242
+ function tildeIsLiteral(raw) {
243
+ if (!raw.startsWith("~"))
244
+ return true;
245
+ for (let i = 1; i < raw.length; i++) {
246
+ const ch = raw[i];
247
+ if (ch === '"' || ch === "'")
248
+ return true;
249
+ if (ch === "/")
250
+ return false;
251
+ }
252
+ return false;
235
253
  }
236
254
  function splitWordsQuoteAware(segment) {
237
255
  const s = segment.trim();
@@ -575,11 +593,12 @@ function grepClusterValueOwner(tok) {
575
593
  return undefined;
576
594
  }
577
595
  function attachedOptionPayloads(tok) {
596
+ const literal = literalTildeSpelling;
578
597
  const out = [];
579
598
  if (tok.startsWith("--")) {
580
599
  const eq = tok.indexOf("=");
581
600
  if (eq > 0 && eq + 1 < tok.length)
582
- out.push(tok.slice(eq + 1));
601
+ out.push(literal(tok.slice(eq + 1)));
583
602
  return out;
584
603
  }
585
604
  for (let k = 1; k <= tok.length; k++) {
@@ -587,7 +606,7 @@ function attachedOptionPayloads(tok) {
587
606
  break;
588
607
  const payload = tok.slice(k);
589
608
  if (payload.length > 0)
590
- out.push(payload);
609
+ out.push(literal(payload));
591
610
  }
592
611
  return out;
593
612
  }
@@ -600,31 +619,35 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
600
619
  const candidates = [];
601
620
  const bareWordOperands = [];
602
621
  const positionalOperands = [];
622
+ let stdinOperand = false;
603
623
  const argGlobs = (k) => {
604
624
  const raw = tokens.raw[k + 1];
605
625
  return raw !== undefined && hasUnquotedGlobMetachar(raw);
606
626
  };
607
- for (const rawArg of tokens.raw.slice(1)) {
627
+ const expandingArgs = new Set();
628
+ tokens.raw.slice(1).forEach((rawArg, k) => {
608
629
  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
- }
630
+ return;
631
+ expandingArgs.add(k);
632
+ findings.push({
633
+ kind: "unresolvable",
634
+ operand: rawArg,
635
+ 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`,
636
+ });
637
+ });
638
+ if (name === "cd" && expandingArgs.size > 0)
639
+ return findings;
617
640
  if (name === "cd") {
618
641
  const targetIdx = args.findIndex((t) => !t.startsWith("-") || t === "-");
619
642
  const target = targetIdx === -1 ? undefined : args[targetIdx];
620
643
  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}` }];
644
+ 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
645
  }
623
646
  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 }];
647
+ 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
648
  }
626
649
  if (target === "-") {
627
- return [{ kind: "unresolvable", reason: '`cd -` targets the previous working directory, which cannot be resolved statically ' + NOT_AUTO_ALLOWED }];
650
+ return [{ kind: "unresolvable", operand: "-", reason: '`cd -` targets the previous working directory, which cannot be resolved statically ' + NOT_AUTO_ALLOWED }];
628
651
  }
629
652
  candidates.push({ text: target, globbed: false });
630
653
  }
@@ -635,6 +658,8 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
635
658
  let endOfOptions = false;
636
659
  for (let k = 0; k < args.length; k++) {
637
660
  const t = args[k];
661
+ if (expandingArgs.has(k))
662
+ continue;
638
663
  if (!endOfOptions) {
639
664
  if (t === "--") {
640
665
  endOfOptions = true;
@@ -662,8 +687,10 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
662
687
  if (isGrepPatternSlot)
663
688
  continue;
664
689
  const m = RECURSIVE_READ_FORMS[name];
665
- if (m === undefined || m.dashIsStdin === true)
690
+ if (m === undefined || m.dashIsStdin === true) {
691
+ stdinOperand = true;
666
692
  continue;
693
+ }
667
694
  positionalOperands.push(t);
668
695
  continue;
669
696
  }
@@ -677,7 +704,7 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
677
704
  }
678
705
  }
679
706
  for (let k = 0; k < args.length; k++) {
680
- if (!argGlobs(k))
707
+ if (!argGlobs(k) || expandingArgs.has(k))
681
708
  continue;
682
709
  const resolved = resolveOperandLexically(boundary.cwd ?? boundary.roots[0], args[k], boundary.homeDir);
683
710
  findings.push({ kind: "undecided", path: resolved ?? args[k] });
@@ -687,6 +714,7 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
687
714
  if (resolved === undefined) {
688
715
  findings.push({
689
716
  kind: "unresolvable",
717
+ operand: candidate,
690
718
  reason: `"${name}" names the path "${candidate}", which cannot be resolved statically ${NOT_AUTO_ALLOWED}`,
691
719
  });
692
720
  continue;
@@ -706,7 +734,7 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
706
734
  findings.push({ kind: "inside", path: resolved });
707
735
  }
708
736
  if (boundary.denyMatch !== undefined && segmentSelectsRecursiveRead(name, args)) {
709
- const roots = positionalOperands.length > 0 ? positionalOperands : ["."];
737
+ const roots = positionalOperands.length > 0 ? positionalOperands : stdinOperand ? [] : ["."];
710
738
  for (const operand of roots) {
711
739
  const resolved = resolveOperandLexically(boundary.cwd ?? boundary.roots[0], operand, boundary.homeDir);
712
740
  findings.push({ kind: "recursive", path: resolved ?? operand });
@@ -714,8 +742,56 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
714
742
  }
715
743
  return findings;
716
744
  }
717
- function segmentCompoundForReadonly(command) {
718
- const trimmed = command.trim();
745
+ export function foldHomeVariableSpelling(command, homeDir) {
746
+ if (homeDir === undefined || !command.includes("$") || /[\\`]/.test(command))
747
+ return command;
748
+ const unquotedIsExact = /^[A-Za-z0-9_./+@:%=,-]+$/.test(homeDir);
749
+ const quotedIsExact = !/["$`\\\n\r]/.test(homeDir);
750
+ const UNQUOTED_HOME_WORD = /^\$(?:HOME|\{HOME\})(?=[/\s;|&"]|$)/;
751
+ const DOUBLE_QUOTED_HOME_WORD = /^\$(?:HOME|\{HOME\})(?=[/"]|$)/;
752
+ let open;
753
+ let wordStart = true;
754
+ let quoteHadContent = false;
755
+ let out = "";
756
+ for (let i = 0; i < command.length; i++) {
757
+ const ch = command[i];
758
+ if (open === undefined && (ch === '"' || ch === "'")) {
759
+ open = ch;
760
+ quoteHadContent = false;
761
+ out += ch;
762
+ continue;
763
+ }
764
+ if (open !== undefined && open === ch) {
765
+ open = undefined;
766
+ wordStart = wordStart && !quoteHadContent;
767
+ out += ch;
768
+ continue;
769
+ }
770
+ if (open !== undefined)
771
+ quoteHadContent = true;
772
+ if (open === undefined && /[\s;|&]/.test(ch)) {
773
+ wordStart = true;
774
+ out += ch;
775
+ continue;
776
+ }
777
+ if (ch === "$" && wordStart && ((open === '"' && quotedIsExact) || (open === undefined && unquotedIsExact))) {
778
+ const m = (open === '"' ? DOUBLE_QUOTED_HOME_WORD : UNQUOTED_HOME_WORD).exec(command.slice(i));
779
+ if (m !== null) {
780
+ wordStart = false;
781
+ if (open === '"')
782
+ quoteHadContent = true;
783
+ out += homeDir;
784
+ i += m[0].length - 1;
785
+ continue;
786
+ }
787
+ }
788
+ wordStart = false;
789
+ out += ch;
790
+ }
791
+ return open === undefined ? out : command;
792
+ }
793
+ function segmentCompoundForReadonly(command, homeDir) {
794
+ const trimmed = foldHomeVariableSpelling(command, homeDir).trim();
719
795
  if (!trimmed)
720
796
  return { reject: "empty command" };
721
797
  const redirectionStripped = stripExemptRedirections(trimmed);
@@ -725,7 +801,7 @@ function segmentCompoundForReadonly(command) {
725
801
  return splitShellCompoundSegments(terminatorStripped, { trailingTerminator: "keep" });
726
802
  }
727
803
  export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts) {
728
- const split = segmentCompoundForReadonly(command);
804
+ const split = segmentCompoundForReadonly(command, boundary?.homeDir);
729
805
  if ("reject" in split)
730
806
  return { reason: split.reject };
731
807
  const { segments, pipeFed } = split;
@@ -764,6 +840,7 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
764
840
  };
765
841
  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
842
  const foldedSegments = [];
843
+ let shapeRefusal;
767
844
  for (let si = 0; si < segments.length; si++) {
768
845
  const segmentTokens = tokenizeSegment(segments[si]);
769
846
  const toks = segmentTokens.folded;
@@ -772,8 +849,10 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
772
849
  foldedSegments.push(segmentTokens);
773
850
  const name = toks[0];
774
851
  const guardReason = guardedVerbFormReason(name, toks, segmentTokens.raw);
775
- if (guardReason !== undefined)
776
- return { reason: guardReason };
852
+ if (guardReason !== undefined) {
853
+ shapeRefusal ??= guardReason;
854
+ continue;
855
+ }
777
856
  if (!(pipeFed[si] ?? false)) {
778
857
  const floor = STDIN_FILE_FLOOR[name];
779
858
  const restArgs = toks.slice(1);
@@ -815,14 +894,17 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
815
894
  }
816
895
  }
817
896
  if (floor !== undefined && hasStdinDash) {
818
- return { reason: `"${name}" reads stdin via an explicit "-" argument and would block until the tool timeout ${NOT_AUTO_ALLOWED}` };
897
+ shapeRefusal ??= `"${name}" reads stdin via an explicit "-" argument and would block until the tool timeout ${NOT_AUTO_ALLOWED}`;
898
+ continue;
819
899
  }
820
900
  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}` };
901
+ shapeRefusal ??= `"${name}" with no file argument reads stdin and would block until the tool timeout ${NOT_AUTO_ALLOWED}`;
902
+ continue;
822
903
  }
823
904
  }
824
905
  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 };
906
+ shapeRefusal ??= "`tail` in follow mode never terminates " + NOT_AUTO_ALLOWED;
907
+ continue;
826
908
  }
827
909
  const GENERATOR_DEVICES = new Set(["/dev/zero", "/dev/random", "/dev/urandom", "/dev/full"]);
828
910
  const deviceCandidates = [];
@@ -833,20 +915,26 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
833
915
  }
834
916
  deviceCandidates.push(t);
835
917
  }
836
- const deviceArgs = deviceCandidates.map(normalizeAbsPathLexically).filter(isBlockedDevicePath);
918
+ const deviceArgs = deviceCandidates.map((t) => resolveOperandLexically(undefined, t, boundary?.homeDir) ?? normalizeAbsPathLexically(t)).filter(isBlockedDevicePath);
837
919
  const rescuedByHead = headBoundIsSmall(name, toks) && deviceArgs.every((d) => GENERATOR_DEVICES.has(d));
838
920
  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 };
921
+ 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
922
  }
841
923
  }
842
924
  if (opts?.iterated !== true) {
843
925
  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
- }
926
+ if (cdSegments.length > 1)
927
+ shapeRefusal ??= `multiple directory changes in one command require approval for clarity ${NOT_AUTO_ALLOWED}`;
928
+ }
929
+ if (boundary !== undefined) {
930
+ const walk = evaluateReadBoundary(foldedSegments, boundary);
931
+ if (shapeRefusal === undefined)
932
+ return walk;
933
+ const { reason: _walkSentence, ...structure } = walk;
934
+ return { ...structure, reason: shapeRefusal };
847
935
  }
848
- if (boundary !== undefined)
849
- return evaluateReadBoundary(foldedSegments, boundary);
936
+ if (shapeRefusal !== undefined)
937
+ return { reason: shapeRefusal };
850
938
  if (foldedSegments.some((st) => st.folded[0] === "cd")) {
851
939
  return { reason: `\`cd\` cannot be judged without a read boundary (no base to resolve the directory change against) ${NOT_AUTO_ALLOWED}` };
852
940
  }
@@ -855,14 +943,25 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
855
943
  function evaluateReadBoundary(foldedSegments, boundary) {
856
944
  const outside = [];
857
945
  const inside = [];
946
+ let unresolvable;
947
+ const unresolved = [];
948
+ let baseLost = false;
858
949
  const undecided = [];
859
950
  const recursive = [];
860
951
  let currentBase = boundary.cwd ?? boundary.roots[0];
861
952
  for (const toks of foldedSegments) {
953
+ if (baseLost)
954
+ break;
862
955
  const segBoundary = currentBase === (boundary.cwd ?? boundary.roots[0]) ? boundary : { ...boundary, cwd: currentBase };
863
956
  for (const finding of collectSegmentBoundaryFindings(toks, segBoundary)) {
864
- if (finding.kind === "unresolvable")
865
- return { reason: finding.reason };
957
+ if (finding.kind === "unresolvable") {
958
+ unresolvable ??= finding.reason;
959
+ if (!unresolved.includes(finding.operand))
960
+ unresolved.push(finding.operand);
961
+ if (toks.folded[0] === "cd")
962
+ baseLost = true;
963
+ continue;
964
+ }
866
965
  const denied = boundary.denyMatch?.(finding.path);
867
966
  if (denied != null) {
868
967
  return {
@@ -893,7 +992,7 @@ function evaluateReadBoundary(foldedSegments, boundary) {
893
992
  if (!outside.some((o) => o.path === finding.path))
894
993
  outside.push(finding);
895
994
  }
896
- if (toks.folded[0] === "cd") {
995
+ if (!baseLost && toks.folded[0] === "cd") {
897
996
  const cdArgs = toks.folded.slice(1);
898
997
  const cdTargetIdx = cdArgs.findIndex((t) => !t.startsWith("-") || t === "-");
899
998
  const cdTarget = cdTargetIdx === -1 ? undefined : cdArgs[cdTargetIdx];
@@ -907,6 +1006,15 @@ function evaluateReadBoundary(foldedSegments, boundary) {
907
1006
  ...(undecidedAll.length > 0 ? { undecidedPaths: undecidedAll } : {}),
908
1007
  ...(recursive.length > 0 ? { recursiveReadPaths: recursive } : {}),
909
1008
  };
1009
+ if (unresolvable !== undefined) {
1010
+ return {
1011
+ reason: unresolvable,
1012
+ unresolvedOperands: unresolved,
1013
+ ...(outside.length > 0 ? { outOfRootRead: true, outOfRootPaths: outside.map((o) => o.path) } : {}),
1014
+ ...(inside.length > 0 ? { checkedPaths: inside } : {}),
1015
+ ...undecidedField,
1016
+ };
1017
+ }
910
1018
  if (outside.length === 0)
911
1019
  return inside.length > 0 ? { checkedPaths: inside, ...undecidedField } : { ...undecidedField };
912
1020
  const paths = outside.map((o) => `"${o.path}"`).join(", ");
@@ -948,7 +1056,7 @@ export function classifyOutOfRootReadGate(command, allow, boundary) {
948
1056
  ...(boundary.face !== undefined ? { face: boundary.face } : {}),
949
1057
  };
950
1058
  const judge = (text) => {
951
- const split = segmentCompoundForReadonly(text);
1059
+ const split = segmentCompoundForReadonly(text, containmentOnly.homeDir);
952
1060
  if ("reject" in split)
953
1061
  return NOT_GATED;
954
1062
  const readers = [];
@@ -1028,53 +1136,56 @@ function pollLoopSleepReason(segment) {
1028
1136
  return undefined;
1029
1137
  }
1030
1138
  export function classifyBoundedReadonlyPollLoop(command, allow, boundary) {
1139
+ return classifyBoundedReadonlyPollLoopDetailed(command, allow, boundary).reason;
1140
+ }
1141
+ export function classifyBoundedReadonlyPollLoopDetailed(command, allow, boundary) {
1031
1142
  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";
1143
+ 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
1144
  }
1034
- const trimmed = command.trim();
1145
+ const trimmed = foldHomeVariableSpelling(command, boundary?.homeDir).trim();
1035
1146
  if (!trimmed)
1036
- return "empty command";
1147
+ return { reason: "empty command" };
1037
1148
  if (!pollLoopQuotesBalanced(trimmed)) {
1038
- return "unbalanced quote — the command's structure cannot be read reliably, so it is not auto-allowed";
1149
+ return { reason: "unbalanced quote — the command's structure cannot be read reliably, so it is not auto-allowed" };
1039
1150
  }
1040
1151
  const m = POLL_LOOP_SHAPE.exec(trimmed);
1041
1152
  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)";
1153
+ 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
1154
  }
1044
1155
  const varName = m[1];
1045
1156
  const iterable = m[2];
1046
1157
  const body = m[3];
1047
1158
  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`;
1159
+ 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
1160
  }
1050
1161
  const beats = pollLoopBeatsOf(iterable);
1051
1162
  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`;
1163
+ 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
1164
  }
1054
1165
  if (beats > POLL_LOOP_MAX_BEATS) {
1055
- return `the loop runs ${beats} iterations, above the ${POLL_LOOP_MAX_BEATS}-iteration cap for auto-allow`;
1166
+ return { reason: `the loop runs ${beats} iterations, above the ${POLL_LOOP_MAX_BEATS}-iteration cap for auto-allow` };
1056
1167
  }
1057
1168
  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";
1169
+ return { reason: "the loop body may not contain redirection, pipes, backgrounding, substitution, subshells, braces, escapes, or line breaks" };
1059
1170
  }
1060
1171
  const readSegments = [];
1061
1172
  for (const rawSegment of body.split(";")) {
1062
1173
  const segment = rawSegment.trim();
1063
1174
  if (segment.length === 0)
1064
- return "empty command in the loop body";
1175
+ return { reason: "empty command in the loop body" };
1065
1176
  const parsed = parseLeadingCommandName(segment);
1066
1177
  if ("reject" in parsed)
1067
- return parsed.reject;
1178
+ return { reason: parsed.reject };
1068
1179
  if (parsed.name === "sleep") {
1069
1180
  const sleepReason = pollLoopSleepReason(segment);
1070
1181
  if (sleepReason !== undefined)
1071
- return sleepReason;
1182
+ return { reason: sleepReason };
1072
1183
  continue;
1073
1184
  }
1074
1185
  readSegments.push(segment);
1075
1186
  }
1076
1187
  if (readSegments.length === 0) {
1077
- return "the loop body has no read command — a sleep-only loop observes nothing and is not auto-allowed";
1188
+ return { reason: "the loop body has no read command — a sleep-only loop observes nothing and is not auto-allowed" };
1078
1189
  }
1079
1190
  const bodyHasCd = readSegments.some((seg) => {
1080
1191
  const p = parseLeadingCommandName(seg);
@@ -1083,14 +1194,14 @@ export function classifyBoundedReadonlyPollLoop(command, allow, boundary) {
1083
1194
  const modelled = bodyHasCd ? Array.from({ length: beats }, () => readSegments.join("; ")).join("; ") : readSegments.join("; ");
1084
1195
  const verdict = classifyCompoundReadonlyDetailed(modelled, allow, boundary, { iterated: bodyHasCd });
1085
1196
  if (verdict.reason !== undefined)
1086
- return verdict.reason;
1197
+ return verdict;
1087
1198
  if (verdict.recursiveReadPaths !== undefined) {
1088
1199
  const roots = verdict.recursiveReadPaths;
1089
1200
  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` : ""}`;
1201
+ 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
1202
  }
1092
1203
  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`;
1204
+ 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
1205
  }
1095
- return undefined;
1206
+ return verdict;
1096
1207
  }
@@ -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
@@ -11,7 +11,7 @@ import { isRemoteExecutionEnv, hasDestroy, isIsolated } from "../../core/remote-
11
11
  import { ghRateLimitHint } from "./gh-rate-limit.js";
12
12
  import { resolveBashTimeoutCaps, bashTimeoutCapsSec, bashTimeoutArgRefusal, bashTimeoutParamDescription, envErrorDetail, bashMaxOutputChars, clipShellOutput, writeShellOverflowFile, createShellOverflowSpoolFence, shellRecoveryHint, CWD_SENTINEL, BASH_READONLY_CONFINEMENT_NOTE, } from "./fs-shared.js";
13
13
  import { PROBE_CAUSE_PATH_MAX, inlineUntrusted } from "../../core/untrusted-text.js";
14
- import { BASH_CLASSIFY_DEFAULT_ALLOW, BASH_READONLY_DEFAULT_ALLOW, coarseReadonlyCheck, classifyBoundedReadonlyPollLoop, classifyCompoundReadonlyDetailed, classifyOutOfRootReadGate, classifySimpleCommandReadBoundary, NOT_AUTO_ALLOWED, } from "./bash-readonly-classifier.js";
14
+ import { BASH_CLASSIFY_DEFAULT_ALLOW, BASH_READONLY_DEFAULT_ALLOW, coarseReadonlyCheck, classifyBoundedReadonlyPollLoopDetailed, classifyCompoundReadonlyDetailed, classifyOutOfRootReadGate, classifySimpleCommandReadBoundary, NOT_AUTO_ALLOWED, } from "./bash-readonly-classifier.js";
15
15
  import { toolFace } from "../../core/tool-catalog-entries.js";
16
16
  const RECURSIVE_CAUSE_MAX_PATHS = 3;
17
17
  const RECURSIVE_READ_CAUSE_CODE = "shell.recursive_read_unbounded";
@@ -36,7 +36,7 @@ export function bashReversibilityProbe(allow, boundary) {
36
36
  if (backgrounded)
37
37
  return { reversible: false, ...boundaryGate(detailed) };
38
38
  if (detailed.reason === undefined) {
39
- if (detailed.undecidedPaths !== undefined && detailed.undecidedPaths.length > 0) {
39
+ if (boundaryUnread(detailed)) {
40
40
  const cause = recursiveReadCause(detailed);
41
41
  if (cause !== undefined)
42
42
  return { reversible: false, ...boundaryGate(detailed), cause };
@@ -44,23 +44,16 @@ export function bashReversibilityProbe(allow, boundary) {
44
44
  }
45
45
  return { reversible: true };
46
46
  }
47
- const loopRefusal = classifyBoundedReadonlyPollLoop(command, allowSet, resolved);
48
- if (loopRefusal === undefined)
47
+ const loop = classifyBoundedReadonlyPollLoopDetailed(command, allowSet, resolved);
48
+ if (loop.reason === undefined)
49
49
  return { reversible: true };
50
- return { reversible: false, ...(pollLoopBoundaryVerdict(command, allowSet, resolved, loopRefusal) === "declared" ? { mandated: true } : boundaryGate(detailed)) };
50
+ if (boundaryDeclared(loop))
51
+ return { reversible: false, mandated: true };
52
+ return { reversible: false, ...boundaryGate(detailed) };
51
53
  };
52
54
  }
53
- function attributeRefusal(face, resolved, fullRefusal) {
54
- if (resolved === undefined)
55
- return "shape";
56
- const { denyMatch: _deny, ...withoutDenyJudge } = resolved;
57
- const opened = face({ ...withoutDenyJudge, face: "open" });
58
- if (opened === undefined || opened !== fullRefusal)
59
- return "declared";
60
- return face(undefined) === opened ? "shape" : "unread";
61
- }
62
- function pollLoopBoundaryVerdict(command, allowSet, resolved, fullRefusal) {
63
- return attributeRefusal((b) => classifyBoundedReadonlyPollLoop(command, allowSet, b), resolved, fullRefusal);
55
+ function boundaryUnread(verdict) {
56
+ return (verdict.undecidedPaths !== undefined && verdict.undecidedPaths.length > 0) || (verdict.unresolvedOperands !== undefined && verdict.unresolvedOperands.length > 0);
64
57
  }
65
58
  function boundaryDeclared(verdict) {
66
59
  return denyJudgeSpoke(verdict) || verdict.outOfRootRead === true;
@@ -106,19 +99,18 @@ export function bashReadBoundaryProbe(boundary) {
106
99
  const cause = recursiveReadCause(detailed);
107
100
  return { reversible: false, mandated: true, ...(cause !== undefined ? { cause } : {}) };
108
101
  }
109
- if (detailed.reason === undefined) {
110
- return detailed.undecidedPaths !== undefined && detailed.undecidedPaths.length > 0 ? { reversible: false } : { reversible: true };
111
- }
112
- const compound = attributeRefusal((b) => classifyCompoundReadonlyDetailed(command, allowSet, b).reason, resolved, detailed.reason);
113
- if (compound === "declared")
114
- return { reversible: false, mandated: true };
115
- if (compound === "unread")
102
+ if (boundaryUnread(detailed))
116
103
  return { reversible: false };
117
- const loopRefusal = classifyBoundedReadonlyPollLoop(command, allowSet, resolved);
118
- if (loopRefusal === undefined)
104
+ if (detailed.reason === undefined)
105
+ return { reversible: true };
106
+ const loop = classifyBoundedReadonlyPollLoopDetailed(command, allowSet, resolved);
107
+ if (loop.reason === undefined)
119
108
  return { reversible: true };
120
- const loop = pollLoopBoundaryVerdict(command, allowSet, resolved, loopRefusal);
121
- return loop === "shape" ? { reversible: true } : loop === "declared" ? { reversible: false, mandated: true } : { reversible: false };
109
+ if (boundaryDeclared(loop))
110
+ return { reversible: false, mandated: true };
111
+ if (boundaryUnread(loop))
112
+ return { reversible: false };
113
+ return { reversible: true };
122
114
  };
123
115
  }
124
116
  export { FULL_SHELL_CONTRACT_ID } from "../../core/tool-catalog-entries.js";
@@ -1,6 +1,6 @@
1
1
  import { Type } from "typebox";
2
2
  import { defineTool, errorResult } from "../../core/tools.js";
3
- import { resolveKey, violationText, violationDetails } from "./safety.js";
3
+ import { clipSpelling, pathFamilyOf, resolveKey, violationText, violationDetails, WIN32_NAMESPACE_REFUSAL_TEXT } from "./safety.js";
4
4
  import { runGrepDetailed, runGlobDetailed, invalidGlobTokens } from "./search.js";
5
5
  import { toolFace } from "../../core/tool-catalog-entries.js";
6
6
  import { pathTargetBaseOf, splitAbsoluteGlobPattern } from "../../core/effective-path-target.js";
@@ -205,7 +205,10 @@ export function createGlobTool(env, rootCanonical, additionalRoots, readDeny, re
205
205
  let path = declaredTarget === undefined ? undefined : pathTargetValue(args, declaredTarget);
206
206
  const { max_results } = args;
207
207
  if (path === undefined) {
208
- const split = splitAbsoluteGlobPattern(pattern);
208
+ const split = splitAbsoluteGlobPattern(pattern, pathFamilyOf({ root: rootCanonical }));
209
+ if (split !== undefined && "refused" in split) {
210
+ return errorResult(`Error (Glob): pattern "${clipSpelling(pattern)}" ${WIN32_NAMESPACE_REFUSAL_TEXT[split.refused]}; refused (${split.refused}).`);
211
+ }
209
212
  if (split) {
210
213
  path = split.baseDir;
211
214
  pattern = split.relativePattern;
@@ -273,6 +273,8 @@ export declare function bashTimeoutCapsSec(caps: {
273
273
  defaultSec: number;
274
274
  maxSec: number;
275
275
  };
276
+ /** Read per call (a deployment steers it without a restart); the one shared resolver holds the grammar,
277
+ * the `[OUTPUT_BUDGET_MIN_CHARS, ceiling]` range and the loud outlet for a value it could not honor. */
276
278
  export declare function bashMaxOutputChars(): number;
277
279
  export declare const FILE_PATH_PARAMS: {
278
280
  file_path: Type.TString;