@sema-agent/core 5.65.0 → 7.0.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 (165) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/dist/agents/subagent.d.ts +2 -2
  3. package/dist/agents/subagent.js +11 -0
  4. package/dist/agents/verify.d.ts +1 -1
  5. package/dist/brain/anthropic.js +1 -1
  6. package/dist/brain/errors.d.ts +29 -0
  7. package/dist/brain/errors.js +20 -0
  8. package/dist/brain/open-responses.js +2 -2
  9. package/dist/brain/route-adjudicator.d.ts +8 -1
  10. package/dist/brain/route-adjudicator.js +1 -0
  11. package/dist/brain/status-sink.js +12 -1
  12. package/dist/brain/stream-engine.js +17 -6
  13. package/dist/core/auto-compaction.d.ts +26 -0
  14. package/dist/core/auto-compaction.js +7 -2
  15. package/dist/core/auto-mode-arming.d.ts +138 -0
  16. package/dist/core/auto-mode-arming.js +181 -0
  17. package/dist/core/auto-mode-defaults.d.ts +13 -0
  18. package/dist/core/auto-mode-defaults.js +5 -0
  19. package/dist/core/auto-mode-prompt.d.ts +14 -3
  20. package/dist/core/auto-mode-prompt.js +10 -7
  21. package/dist/core/auto-mode-rebuild.d.ts +75 -0
  22. package/dist/core/auto-mode-rebuild.js +41 -0
  23. package/dist/core/auto-mode.d.ts +15 -0
  24. package/dist/core/auto-mode.js +4 -2
  25. package/dist/core/checkpoint-store.d.ts +18 -0
  26. package/dist/core/context-edit.d.ts +47 -5
  27. package/dist/core/context-guard.d.ts +1 -1
  28. package/dist/core/file-history-retention.d.ts +106 -0
  29. package/dist/core/file-history-retention.js +36 -0
  30. package/dist/core/file-history-store.d.ts +768 -0
  31. package/dist/core/file-history-store.js +880 -0
  32. package/dist/core/governance-codes.d.ts +13 -3
  33. package/dist/core/governance-codes.js +15 -0
  34. package/dist/core/hooks.d.ts +39 -7
  35. package/dist/core/hooks.js +38 -21
  36. package/dist/core/lsp.d.ts +2 -2
  37. package/dist/core/mcp.d.ts +29 -7
  38. package/dist/core/memory-engine/consolidation-driver.d.ts +11 -0
  39. package/dist/core/memory-engine/consolidation-driver.js +71 -4
  40. package/dist/core/memory-engine/consolidation.d.ts +25 -2
  41. package/dist/core/memory-engine/consolidation.js +4 -1
  42. package/dist/core/memory-engine/distiller.d.ts +84 -1
  43. package/dist/core/memory-engine/distiller.js +68 -0
  44. package/dist/core/memory-engine/dual-root.js +3 -0
  45. package/dist/core/memory-engine/engine.d.ts +328 -15
  46. package/dist/core/memory-engine/engine.js +355 -29
  47. package/dist/core/memory-engine/file-backend.d.ts +30 -0
  48. package/dist/core/memory-engine/file-backend.js +14 -13
  49. package/dist/core/memory-engine/frontmatter.d.ts +22 -1
  50. package/dist/core/memory-engine/frontmatter.js +3 -0
  51. package/dist/core/memory-engine/header-hints.d.ts +5 -0
  52. package/dist/core/memory-engine/index.d.ts +5 -4
  53. package/dist/core/memory-engine/index.js +5 -4
  54. package/dist/core/memory-engine/layout.d.ts +88 -2
  55. package/dist/core/memory-engine/layout.js +112 -3
  56. package/dist/core/memory-engine/provenance-wording.d.ts +7 -0
  57. package/dist/core/memory-engine/provenance-wording.js +3 -0
  58. package/dist/core/memory-engine/tools.d.ts +89 -8
  59. package/dist/core/memory-engine/tools.js +263 -22
  60. package/dist/core/memory-engine/types.d.ts +64 -1
  61. package/dist/core/memory-recall.d.ts +6 -0
  62. package/dist/core/memory.d.ts +27 -1
  63. package/dist/core/memory.js +16 -2
  64. package/dist/core/permission-rule-consent.d.ts +62 -2
  65. package/dist/core/permission-rule-consent.js +105 -14
  66. package/dist/core/permission-rule-model.d.ts +118 -16
  67. package/dist/core/permission-rule-model.js +57 -9
  68. package/dist/core/permission-rule-session.d.ts +124 -0
  69. package/dist/core/permission-rule-session.js +121 -0
  70. package/dist/core/permission-rule-store.d.ts +65 -2
  71. package/dist/core/permission-rule-store.js +75 -16
  72. package/dist/core/permission-rule-sync.d.ts +9 -0
  73. package/dist/core/permission-rule-sync.js +52 -19
  74. package/dist/core/retention-policy.d.ts +9 -0
  75. package/dist/core/retention-policy.js +5 -2
  76. package/dist/core/retention.d.ts +13 -2
  77. package/dist/core/runner/assemble-result.d.ts +19 -1
  78. package/dist/core/runner/assemble-result.js +17 -2
  79. package/dist/core/runner/compaction-call-options.d.ts +93 -0
  80. package/dist/core/runner/compaction-call-options.js +3 -0
  81. package/dist/core/runner/memory-capture-optout.d.ts +80 -0
  82. package/dist/core/runner/memory-capture-optout.js +53 -0
  83. package/dist/core/runner/prepare-config-doors.d.ts +5 -0
  84. package/dist/core/runner/prepare-config-doors.js +16 -0
  85. package/dist/core/runner/prepare-hands-readface.d.ts +110 -5
  86. package/dist/core/runner/prepare-hands-readface.js +99 -7
  87. package/dist/core/runner/prepare-memory.d.ts +88 -0
  88. package/dist/core/runner/prepare-memory.js +305 -24
  89. package/dist/core/runner/prepare-task.d.ts +141 -1
  90. package/dist/core/runner/prepare-task.js +478 -81
  91. package/dist/core/runner/runtask.d.ts +9 -20
  92. package/dist/core/runner/runtask.js +133 -96
  93. package/dist/core/runner/session-file-state-replay.d.ts +18 -10
  94. package/dist/core/runner/session-file-state-replay.js +52 -1
  95. package/dist/core/runner/tool-disclosure.js +2 -1
  96. package/dist/core/runner/turn-attachments.d.ts +22 -12
  97. package/dist/core/session-store.d.ts +1 -1
  98. package/dist/core/session-store.js +6 -1
  99. package/dist/core/session.d.ts +34 -1
  100. package/dist/core/store-contracts/file-history-store-contract.d.ts +3 -0
  101. package/dist/core/store-contracts/file-history-store-contract.js +720 -0
  102. package/dist/core/store-contracts/permission-rule-sync-contract.js +15 -1
  103. package/dist/core/task-registry-shared.js +11 -1
  104. package/dist/core/tool-errors.js +1 -0
  105. package/dist/core/tool-policy.d.ts +172 -1
  106. package/dist/core/tool-policy.js +32 -1
  107. package/dist/core/tool-result-store.js +2 -1
  108. package/dist/core/trace.d.ts +31 -2
  109. package/dist/core/types.d.ts +795 -89
  110. package/dist/core/types.js +4 -3
  111. package/dist/core/untrusted-text.d.ts +1 -1
  112. package/dist/core/untrusted-text.js +8 -0
  113. package/dist/core/workflow-run-store-contract.js +6 -2
  114. package/dist/core/workflow-run-store.d.ts +4 -1
  115. package/dist/engine/compaction/compaction.d.ts +88 -10
  116. package/dist/engine/compaction/compaction.js +109 -30
  117. package/dist/engine/execution-env/node-execution-env.d.ts +9 -1
  118. package/dist/engine/execution-env/node-execution-env.js +28 -0
  119. package/dist/engine/harness/agent-harness.d.ts +52 -1
  120. package/dist/engine/harness/agent-harness.js +36 -1
  121. package/dist/engine/harness/types.d.ts +26 -1
  122. package/dist/engine/llm/types.d.ts +50 -4
  123. package/dist/engine/loop/agent-loop.d.ts +5 -1
  124. package/dist/engine/loop/agent-loop.js +25 -0
  125. package/dist/engine/loop/types.d.ts +19 -0
  126. package/dist/engine/lsp/node-lsp-manager.d.ts +1 -1
  127. package/dist/engine/session/session.js +1 -1
  128. package/dist/index.d.ts +20 -9
  129. package/dist/index.js +16 -7
  130. package/dist/orchestration/run-workflow-tool.d.ts +20 -2
  131. package/dist/orchestration/run-workflow-tool.js +22 -3
  132. package/dist/orchestration/workflow-governance.d.ts +59 -1
  133. package/dist/orchestration/workflow-governance.js +61 -8
  134. package/dist/orchestration/workflow-meta.d.ts +4 -2
  135. package/dist/orchestration/workflow-primitives.js +56 -13
  136. package/dist/orchestration/workflow-types.d.ts +78 -2
  137. package/dist/orchestration/workflow.d.ts +20 -0
  138. package/dist/orchestration/workflow.js +163 -14
  139. package/dist/prompt-assembly/event-registry.js +1 -1
  140. package/dist/prompts/default.d.ts +7 -7
  141. package/dist/stores/file/file-history-store.d.ts +368 -0
  142. package/dist/stores/file/file-history-store.js +1248 -0
  143. package/dist/stores/file/index.d.ts +22 -13
  144. package/dist/stores/file/index.js +4 -4
  145. package/dist/stores/file/permission-rule-store.d.ts +11 -0
  146. package/dist/stores/file/permission-rule-store.js +23 -9
  147. package/dist/stores/file/strategy-store.d.ts +3 -3
  148. package/dist/tools/fs/bash-readonly-classifier.d.ts +87 -3
  149. package/dist/tools/fs/bash-readonly-classifier.js +106 -4
  150. package/dist/tools/fs/fs-bash.js +9 -5
  151. package/dist/tools/fs/fs-shared.d.ts +52 -1
  152. package/dist/tools/fs/fs-shared.js +14 -0
  153. package/dist/tools/fs/fs-write.d.ts +5 -5
  154. package/dist/tools/fs/fs-write.js +71 -14
  155. package/dist/tools/fs/index.d.ts +6 -1
  156. package/dist/tools/fs/index.js +1 -1
  157. package/dist/tools/web.js +2 -1
  158. package/package.json +5 -1
  159. package/test/export-surface.snapshot.json +169 -23
  160. package/dist/core/file-snapshot-store.d.ts +0 -165
  161. package/dist/core/file-snapshot-store.js +0 -259
  162. package/dist/core/store-contracts/file-snapshot-store-contract.d.ts +0 -13
  163. package/dist/core/store-contracts/file-snapshot-store-contract.js +0 -134
  164. package/dist/stores/file/file-snapshot-store.d.ts +0 -58
  165. package/dist/stores/file/file-snapshot-store.js +0 -353
@@ -4,12 +4,13 @@ import type { ToolResultStore } from "../../core/tool-result-store.js";
4
4
  import { type EvictPolicy } from "../../core/session-store.js";
5
5
  import type { SessionStore } from "../../core/session.js";
6
6
  import { type FileCheckpointStoreOptions } from "./checkpoint-store.js";
7
+ import { type FileFileHistoryStoreOptions } from "./file-history-store.js";
8
+ import type { FileHistoryStore } from "../../core/file-history-store.js";
7
9
  import type { StrategyStore } from "../../core/strategy-store.js";
8
10
  import type { BackgroundAgentStore } from "../../core/background-agent-store.js";
9
11
  import type { MailboxStore } from "../../core/mailbox-store.js";
10
12
  import type { RosterStore } from "../../agents/roster-store.js";
11
13
  import type { SessionPolicyStore } from "../../core/session-policy-store.js";
12
- import type { FileSnapshotStore } from "../../core/file-snapshot-store.js";
13
14
  import type { WorkflowJournalStore } from "../../core/workflow-journal-store.js";
14
15
  import type { UsageWindowStore } from "../../core/usage-window-store.js";
15
16
  export { FileCheckpointStore, type FileCheckpointStoreOptions } from "./checkpoint-store.js";
@@ -17,7 +18,7 @@ export { FileMemoryStore } from "./memory-store.js";
17
18
  export { FileSessionRepo, type FileSessionRepoOptions } from "./session-store.js";
18
19
  export { FileToolResultStore } from "./tool-result-store.js";
19
20
  export { FileSessionPolicyStore, type FileSessionPolicyStoreOptions, type SessionPolicyCorruptReadInfo } from "./session-policy-store.js";
20
- export { FileFileSnapshotStore, type FileFileSnapshotStoreOptions } from "./file-snapshot-store.js";
21
+ export { FileFileHistoryStore, type FileFileHistoryStoreOptions } from "./file-history-store.js";
21
22
  export { FileWorkflowJournalStore, MAX_JOURNAL_RESULT_BYTES, oversizeJournalResult } from "./workflow-journal-store.js";
22
23
  export { FileUsageWindowStore } from "./usage-window-store.js";
23
24
  export { FileStrategyStore, type FileStrategyStoreOptions } from "./strategy-store.js";
@@ -64,9 +65,13 @@ export interface FileStorageBackendOptions {
64
65
  evict?: EvictPolicy;
65
66
  /** Checkpoint store tuning (fsync cadence for `put`/`setPendingSteer`, ledger compaction threshold). */
66
67
  checkpoint?: FileCheckpointStoreOptions;
67
- /** design/101 §E19 — file-snapshot enumerator bounds (maxFiles/maxBytes/ignoreDirs). Default
68
- * {@link DEFAULT_SNAPSHOT_BOUNDS}. */
69
- snapshotBounds?: Partial<import("../../core/file-snapshot-store.js").FileSnapshotBounds>;
68
+ /**
69
+ * design/381 §片2 — file-history tuning, today just the boundary RETENTION bound (omitted ⇒ the
70
+ * engine default; `{ retention: { keep: "unbounded" } }` hands the GC schedule to the deployment).
71
+ * The corrupt-read sink is deliberately NOT accepted here: it is wired from {@link
72
+ * FileStorageBackendOptions.onCorruptRead} above, because one failure grammar takes one sink.
73
+ */
74
+ fileHistory?: Omit<FileFileHistoryStoreOptions, "onCorruptRead">;
70
75
  /** design/81 Slice 5 — an optional {@link import("../../core/memory.js").Embedder}. Injected ⇒ the memory
71
76
  * store does portable (in-process) vector recall over a rebuildable sidecar index (`vectorMode:"portable"`);
72
77
  * absent ⇒ the lexical floor, byte-identical to before. Config-driven (the deployment injects it); OFF by
@@ -84,10 +89,9 @@ export interface FileStorageBackendOptions {
84
89
  * nowhere for the deployments that need it most.
85
90
  *
86
91
  * Reach TODAY: {@link FileStorageBackend.sessionPolicyStore} (rules read as ABSENT ⇒ an unconstrained
87
- * task), {@link FileStorageBackend.sessionStore}'s repo (a session listing that silently omits what it
88
- * could not read) and {@link FileStorageBackend.fileSnapshotStore} (a snapshot/scope read as missing,
89
- * and a blob GC that aborted). The aggregated roster and mailbox stores receive the same sink. A
90
- * store that grows the seat later is wired here in the same constructor.
92
+ * task) and {@link FileStorageBackend.sessionStore}'s repo (a session listing that silently omits what
93
+ * it could not read). The aggregated roster and mailbox stores receive the same sink. A store that
94
+ * grows the seat later is wired here in the same constructor.
91
95
  *
92
96
  * The payload is the UNION shape ({@link FileStorageCorruptReadInfo}): `path` + `reason` always,
93
97
  * with the session-policy face's `sessionId`/`principal` present only when the reading store keys by
@@ -123,10 +127,6 @@ export declare class FileStorageBackend {
123
127
  /** design/99 §E6 — file-backed per-session permission rules (E6). Wire into `RunnerDeps.sessionPolicyStore`
124
128
  * for a local deployment that needs session rules to survive a restart. */
125
129
  readonly sessionPolicyStore: SessionPolicyStore;
126
- /** design/101 §E19 — file-backed working-tree snapshots (rewind-files). Wire into
127
- * `RunnerDeps.fileSnapshotStore`. Content-addressed blobs persist across restarts (local-only; a remote env
128
- * defers rewind to its own VM-snapshot backend). */
129
- readonly fileSnapshotStore: FileSnapshotStore;
130
130
  /** SVC-2 (onboarded per) — file-backed durable workflow resume journal. Wire into
131
131
  * `RunnerDeps.workflowJournalStore` so `resumeFromRunId` survives a process restart on a TOC box. */
132
132
  readonly workflowJournalStore: WorkflowJournalStore;
@@ -152,6 +152,15 @@ export declare class FileStorageBackend {
152
152
  /** design/147 S1c — the durable name→agent roster (layer 0.5 resolution), `root/roster.json`.
153
153
  * Wire into `RunnerDeps.rosterStore` so name-addressing survives a restart alongside the rows. */
154
154
  readonly rosterStore: RosterStore;
155
+ /**
156
+ * design/381 slice 2 — the durable per-edited-file rewind history over the same data root
157
+ * (`root/file-history/`). Wire into `RunnerDeps.fileHistoryStore` so `/rewind` survives a restart:
158
+ * WIRING IS THE OPT-IN (DV-9), so a deployment that omits it runs with rewind off, exactly as
159
+ * before this member existed. It replaces the retired whole-tree `fileSnapshotStore` member —
160
+ * per-edited-file means no size gate, no exclusion table, no tree traversal, and files the agent
161
+ * never edited are structurally out of reach of a restore.
162
+ */
163
+ readonly fileHistoryStore: FileHistoryStore;
155
164
  /**
156
165
  * design/84 Seam B (TOC) — the per-scope consolidation lock for `consolidateScope`'s `acquire` injection
157
166
  * point: `consolidateScope(scope, { store: backend.memoryStore, llm, acquire: backend.consolidationLock })`.
@@ -8,19 +8,19 @@ import { FileMemoryStore } from "./memory-store.js";
8
8
  import { FileSessionRepo } from "./session-store.js";
9
9
  import { FileToolResultStore } from "./tool-result-store.js";
10
10
  import { FileSessionPolicyStore } from "./session-policy-store.js";
11
- import { FileFileSnapshotStore } from "./file-snapshot-store.js";
12
11
  import { FileWorkflowJournalStore } from "./workflow-journal-store.js";
13
12
  import { FileUsageWindowStore } from "./usage-window-store.js";
14
13
  import { FileBackgroundAgentStore } from "./background-agent-store.js";
15
14
  import { FileMailboxStore } from "./mailbox-store.js";
16
15
  import { FileRosterStore } from "../../agents/roster-store.js";
17
16
  import { FileStrategyStore } from "./strategy-store.js";
17
+ import { FileFileHistoryStore } from "./file-history-store.js";
18
18
  export { FileCheckpointStore } from "./checkpoint-store.js";
19
19
  export { FileMemoryStore } from "./memory-store.js";
20
20
  export { FileSessionRepo } from "./session-store.js";
21
21
  export { FileToolResultStore } from "./tool-result-store.js";
22
22
  export { FileSessionPolicyStore } from "./session-policy-store.js";
23
- export { FileFileSnapshotStore } from "./file-snapshot-store.js";
23
+ export { FileFileHistoryStore } from "./file-history-store.js";
24
24
  export { FileWorkflowJournalStore, MAX_JOURNAL_RESULT_BYTES, oversizeJournalResult } from "./workflow-journal-store.js";
25
25
  export { FileUsageWindowStore } from "./usage-window-store.js";
26
26
  export { FileStrategyStore } from "./strategy-store.js";
@@ -36,13 +36,13 @@ export class FileStorageBackend {
36
36
  memoryStore;
37
37
  toolResultStore;
38
38
  sessionPolicyStore;
39
- fileSnapshotStore;
40
39
  workflowJournalStore;
41
40
  usageWindowStore;
42
41
  backgroundAgentStore;
43
42
  mailboxStore;
44
43
  strategyStore;
45
44
  rosterStore;
45
+ fileHistoryStore;
46
46
  consolidationLock;
47
47
  lock;
48
48
  fileAgentRows;
@@ -72,7 +72,6 @@ export class FileStorageBackend {
72
72
  this.memoryStore = guardedMemoryStore(this.fileMemory, opts.utilityGate);
73
73
  this.toolResultStore = new FileToolResultStore(this.root);
74
74
  this.sessionPolicyStore = new FileSessionPolicyStore(this.root, corruptRead);
75
- this.fileSnapshotStore = new FileFileSnapshotStore(this.root, opts.snapshotBounds, corruptRead);
76
75
  this.fileWorkflowJournal = new FileWorkflowJournalStore(this.root);
77
76
  built.push(() => this.fileWorkflowJournal.dispose());
78
77
  this.workflowJournalStore = this.fileWorkflowJournal;
@@ -93,6 +92,7 @@ export class FileStorageBackend {
93
92
  }
94
93
  : {}),
95
94
  });
95
+ this.fileHistoryStore = new FileFileHistoryStore(this.root, { ...(opts.fileHistory ?? {}), ...(corruptRead ?? {}) });
96
96
  this.consolidationLock = createFileConsolidationLock(join(this.root, "consolidation-locks"));
97
97
  }
98
98
  catch (err) {
@@ -111,6 +111,17 @@ declare class FilePermissionRuleStore implements WritablePermissionRuleStore {
111
111
  /** Read and validate. Any refusal answers with an empty set AND says so — never a silent empty store. */
112
112
  private read;
113
113
  private disclose;
114
+ /**
115
+ * design/382 §4.3 — the AT-REST half of the durable two-member scope face: rows carrying a session
116
+ * scope, read out of persisted bytes (a hand edit or a foreign writer — the engine's own write arms
117
+ * refuse them loudly), are DROPPED from every read face and DISCLOSED. Never quarantined (the
118
+ * quarantine area is itself durable), never silently ridden: a session authorization must not gain
119
+ * an afterlife by being planted where the store cannot legally hold it. The write base
120
+ * (`current()`) is deliberately NOT screened — foreign bytes are left in place for a person to look
121
+ * at, exactly like every other damaged-row posture here; they simply never reach a live view, the
122
+ * sync wire (`readRaw`) or adjudication.
123
+ */
124
+ private screenDurableRows;
114
125
  private write;
115
126
  list(): Promise<StoredAllowRules>;
116
127
  /** The quarantine area (design/182 §5.2/§8.3): introspection only — never part of `list()`. */
@@ -1,7 +1,7 @@
1
1
  import { closeSync, constants as FS, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { createHash, randomBytes } from "node:crypto";
4
- import { PERMISSION_RULE_WRITER, applySyncJoin, applyTombstones, assertDeleteDeltaCarriesNoAdd, assertRedemptionNotQuarantined, foldDelta, } from "../../core/permission-rule-store.js";
4
+ import { PERMISSION_RULE_WRITER, applySyncJoin, applyTombstones, assertDeleteDeltaCarriesNoAdd, assertRedemptionNotQuarantined, assertWriteDeltaScopeDurable, foldDelta, } from "../../core/permission-rule-store.js";
5
5
  import { canonicalize } from "../../core/canonical-json.js";
6
6
  import { BootLock } from "./fs-atomic.js";
7
7
  import { assertAdoptionBootGate } from "./adoption/marker.js";
@@ -158,6 +158,17 @@ class FilePermissionRuleStore {
158
158
  catch {
159
159
  }
160
160
  }
161
+ screenDurableRows(file) {
162
+ const isSession = (scope) => scope?.kind === "session";
163
+ const rules = file.rules.filter((r) => !isSession(r?.scope));
164
+ const tombstones = file.tombstones.filter((t) => !isSession(t?.scope));
165
+ const quarantined = (file.quarantined ?? []).filter((q) => !isSession(q?.scope));
166
+ const dropped = file.rules.length - rules.length + (file.tombstones.length - tombstones.length) + ((file.quarantined ?? []).length - quarantined.length);
167
+ if (dropped > 0) {
168
+ this.disclose(`${this.file} carries ${dropped} session-scope row(s) — a session authorization cannot live in the durable store (design/382 §4.3); those rows are ignored on every read face and never ride sync`);
169
+ }
170
+ return { rules, tombstones, quarantined };
171
+ }
161
172
  write(next) {
162
173
  assertSafeDir(this.dir);
163
174
  atomicPublish(this.dir, this.file, JSON.stringify({ ...next, checksum: checksumOf(next) }, null, 2));
@@ -166,16 +177,17 @@ class FilePermissionRuleStore {
166
177
  const r = this.read();
167
178
  if (!("file" in r))
168
179
  return { ...EMPTY_READ };
180
+ const screened = this.screenDurableRows(r.file);
169
181
  return {
170
- rules: applyTombstones(r.file.rules, r.file.tombstones),
171
- tombstones: r.file.tombstones,
182
+ rules: applyTombstones(screened.rules, screened.tombstones),
183
+ tombstones: screened.tombstones,
172
184
  rev: r.file.rev,
173
185
  checksum: r.file.checksum,
174
186
  };
175
187
  }
176
188
  async quarantined() {
177
189
  const r = this.read();
178
- return "file" in r ? (r.file.quarantined ?? []) : [];
190
+ return "file" in r ? this.screenDurableRows(r.file).quarantined : [];
179
191
  }
180
192
  async readOrgState() {
181
193
  const r = this.read();
@@ -245,17 +257,19 @@ class FilePermissionRuleStore {
245
257
  }),
246
258
  readRaw: async () => this.serialize(() => {
247
259
  const cur = this.current();
260
+ const screened = this.screenDurableRows(cur);
248
261
  return {
249
262
  actor: cur.actor,
250
263
  counter: cur.counter,
251
264
  rev: cur.rev,
252
- rules: structuredClone(cur.rules),
253
- tombstones: structuredClone(cur.tombstones),
265
+ rules: structuredClone(screened.rules),
266
+ tombstones: structuredClone(screened.tombstones),
254
267
  ...(cur.sync?.observedVector !== undefined ? { observedVector: structuredClone(cur.sync.observedVector) } : {}),
255
- quarantined: structuredClone(cur.quarantined ?? []),
268
+ quarantined: structuredClone(screened.quarantined),
256
269
  };
257
270
  }),
258
271
  apply: async (delta, opts) => this.serialize(() => {
272
+ delta = structuredClone(delta);
259
273
  const cur = this.current();
260
274
  if (cur.rev !== opts.expectedRev)
261
275
  return { conflict: true, rev: cur.rev };
@@ -286,8 +300,8 @@ class FilePermissionRuleStore {
286
300
  return { rev: cur.rev + 1, sync: report };
287
301
  }
288
302
  const next = delta.kind === "redemption-add"
289
- ? (assertRedemptionNotQuarantined(cur.quarantined ?? [], delta), { ...cur, rev: cur.rev + 1, rules: foldDelta(cur.rules, delta) })
290
- : (assertDeleteDeltaCarriesNoAdd(delta), { ...cur, rev: cur.rev + 1, tombstones: [...cur.tombstones, delta.tombstone] });
303
+ ? (assertWriteDeltaScopeDurable(delta), assertRedemptionNotQuarantined(cur.quarantined ?? [], delta), { ...cur, rev: cur.rev + 1, rules: foldDelta(cur.rules, delta) })
304
+ : (assertWriteDeltaScopeDurable(delta), assertDeleteDeltaCarriesNoAdd(delta), { ...cur, rev: cur.rev + 1, tombstones: [...cur.tombstones, delta.tombstone] });
291
305
  if (!this.writeAndVerify(next)) {
292
306
  throw new Error("the permission-rule store did not survive its own write; the change was not committed");
293
307
  }
@@ -15,7 +15,7 @@ import { type StoredStrategy, type StrategyStore, type StrategyStoreIncident } f
15
15
  * Layout: `<root>/<scopeDir>/<id>.json`, one strategy per file, whole-file atomic replace on write.
16
16
  * `scopeDir = slug(scope) + "-" + sha256(scope).slice(0,24)` — the slug is a strict `[a-z0-9-]`
17
17
  * whitelist purely for readability; IDENTITY lives in the 96-bit hash, so no scope value, however
18
- * hostile, can traverse out of the root or collide another scope's directory. Ids are gated to
18
+ * adversarial, can traverse out of the root or collide another scope's directory. Ids are gated to
19
19
  * `[A-Za-z0-9_-]{1,64}` at save (the escalation loop mints UUIDs; the interface is public, so a
20
20
  * hand-rolled id must not be able to name a path).
21
21
  *
@@ -52,8 +52,8 @@ export declare class FileStrategyStore implements StrategyStore {
52
52
  private scopeDirPath;
53
53
  /** The scope dir must be a REAL directory — a symlink here means the store's namespace was tampered
54
54
  * with (a link could point retrieval at another scope's data, or writes out of the root). The
55
- * check-then-use window against a same-uid local attacker is out of the threat model: such an
56
- * attacker already holds the same rights as the store itself. */
55
+ * check-then-use window against a same-uid untrusted local process is out of the threat model: such a
56
+ * process already holds the same rights as the store itself. */
57
57
  private assertScopeDirSafe;
58
58
  private incident;
59
59
  /** Sweep crashed writers' stale temps (once per scope per instance). Fresh temps are left alone —
@@ -126,6 +126,18 @@ export declare function parseLeadingCommandName(command: string, options?: Leadi
126
126
  * design/154: this `effect:"read"` DECLARATION face deliberately stays strict-single-command; only the
127
127
  * classify face ({@link classifyCompoundReadonly} via {@link import("./fs-bash.js").bashReversibilityProbe}) segments compounds. */
128
128
  export declare function coarseReadonlyCheck(command: string, allow: ReadonlySet<string>, options?: LeadingCommandNameOptions): string | undefined;
129
+ /**
130
+ * Does this text carry a bare redirection operator — the ONE metacharacter family
131
+ * {@link splitShellCompoundSegments} can be asked to tolerate at its whole-string gate (#490 修①,
132
+ * `redirection: "tolerate"`)?
133
+ *
134
+ * Exported because the tolerating caller owes a PER-SEGMENT judgement in its place, and that judgement
135
+ * must read the same character class this module tolerated — two hand-kept lists would drift, and a
136
+ * drift here is a segment nobody judged. Says nothing about what a caller should DO about a hit: the
137
+ * rule lane's offer face contributes no member for such a segment; the read-only classify face removes
138
+ * its exempt spellings before splitting at all.
139
+ */
140
+ export declare function carriesShellRedirection(text: string): boolean;
129
141
  /** What {@link splitShellCompoundSegments} produces: the connector-delimited segments, plus which of
130
142
  * them a PIPE fed (the read-only classifier's stdin arm needs that distinction; a rule lane does not). */
131
143
  export interface ShellCompoundSegments {
@@ -149,7 +161,10 @@ export interface ShellCompoundSegments {
149
161
  * characters gone, `'`/`"` pairing is the entirety of quoting, so the mask's regions are exactly
150
162
  * bash's quoted regions and the unquoted connectors are exactly bash's command boundaries. Callers
151
163
  * that tolerate a narrow redirection subset (the read-only classify face and its two data-free
152
- * spellings) remove those words BEFORE calling; nothing here restores them.
164
+ * spellings) remove those words BEFORE calling; nothing here restores them. `redirection:
165
+ * "tolerate"` (#490 修①) narrows this step to {@link rejectedSansRedirection} — the mask argument
166
+ * is untouched (`<`/`>` neither quote nor bound) and the refusal moves to the caller's per-segment
167
+ * vetting via {@link carriesShellRedirection}. The default refuses the whole string as it always did.
153
168
  * 2. one trailing `;` is stripped — a no-op terminator, not an empty command. OPTIONAL, because it is
154
169
  * the one step whose answer depends on what the caller is asking. A face that asks "which programs
155
170
  * does this run" wants it stripped (`ls;` runs `ls`, nothing else). A face that asks "is this ONE
@@ -169,6 +184,7 @@ export interface ShellCompoundSegments {
169
184
  */
170
185
  export declare function splitShellCompoundSegments(source: string, options?: {
171
186
  trailingTerminator?: "strip" | "keep";
187
+ redirection?: "reject" | "tolerate";
172
188
  }): ShellCompoundSegments | {
173
189
  reject: string;
174
190
  };
@@ -179,7 +195,7 @@ export declare function splitShellCompoundSegments(source: string, options?: {
179
195
  * path: `ls /etc` and `cat /etc/passwd` classified read-only, and a deployment whose manual-mode shell
180
196
  * gate auto-allows the read-only verdict served the whole filesystem with no approval prompt. CC 2.1.220
181
197
  * demotes exactly these shapes (its per-command path extractor + working-directory containment check,
182
- * pretty220 @457568) and renders an approval option naming the directory (@905642).
198
+ * pretty220 @457568) and renders an approval option naming the directory (@905642 (cli250.js:66007)).
183
199
  *
184
200
  * Supplying this object turns the check ON; omitting it leaves the classifier byte-identical to its
185
201
  * pre-RB-412 behaviour, so a consumer pinned to an older calling convention is unaffected.
@@ -313,7 +329,7 @@ export interface CompoundReadonlyVerdict {
313
329
  }
314
330
  /**
315
331
  * RB-412 — the single minting point for the out-of-root-read approval option text, so a gate rendering
316
- * the prompt and any other consumer cannot drift apart. Shape follows CC 2.1.220 (@905642): the option
332
+ * the prompt and any other consumer cannot drift apart. Shape follows CC 2.1.220 (@905642 (cli250.js:66007)): the option
317
333
  * shows the LAST segment of the grantable directory followed by a separator, not the full path.
318
334
  *
319
335
  * `directory` is the directory to be granted — a caller holding a filesystem (this classifier does not)
@@ -366,6 +382,74 @@ opts?: {
366
382
  * `createBashReadonlyTool` is that caller for the `bash_readonly` leg and does it on a local env.
367
383
  */
368
384
  export declare function classifySimpleCommandReadBoundary(command: string, boundary: BashReadonlyRootBoundary): CompoundReadonlyVerdict;
385
+ /**
386
+ * #502 — what {@link classifyOutOfRootReadGate} answers. Two members and one rule between them:
387
+ * `paths` is EVIDENCE for a `gated` answer, never the answer itself. A caller decides on `gated`.
388
+ */
389
+ export interface OutOfRootReadGateVerdict {
390
+ /** True ⇔ this command is structurally gated on the read boundary. */
391
+ readonly gated: boolean;
392
+ /** The RESOLVED out-of-root paths behind a `gated` answer. EMPTY when the gate fired for a reason
393
+ * that resolves none — a working-directory move this walk cannot follow, after which every relative
394
+ * operand is placed against a base bash has already left, so no path can be named honestly. Read it
395
+ * as display/triage evidence; emptiness is not "not gated". */
396
+ readonly paths: readonly string[];
397
+ }
398
+ /** Test seam for the lockstep pin that this mirror stays a SUBSET of the rule lane's curated table.
399
+ * Exported for that one assertion — production code reads the constant directly. */
400
+ export declare const COMMAND_LAUNCHERS_FOR_TEST: ReadonlySet<string>;
401
+ /**
402
+ * #502 — the OUT-OF-ROOT READ GATE asked as its OWN question: does any listed reader in this command
403
+ * name a path outside the allowed directories?
404
+ *
405
+ * {@link classifyCompoundReadonlyDetailed} answers a different question — "is this whole command
406
+ * auto-allowable?" — and it stops at the FIRST obstacle. That makes its `outOfRootRead` signal precise
407
+ * for the shape it was built for and silent for three shapes where the containment fact is just as
408
+ * true: a compound whose OTHER segment is not a listed reader (`cat <outside> | curl …` refuses on the
409
+ * unlisted name and never reaches the boundary walk at all), an operand that ALSO matches the
410
+ * sensitive-path deny judge (whose more-specific reason short-circuits the walk), and a recursive form
411
+ * whose traversal root sits outside. This face answers the containment question in all of them, so a
412
+ * consumer that must treat "reads outside the roots" as a STRUCTURAL gate rather than as one possible
413
+ * reason for a demotion has one predicate to read.
414
+ *
415
+ * What it judges and what it does not:
416
+ * · SEGMENTS whose PROGRAM is in `allow` — the deployment's own read-command table, the analogue of
417
+ * the reference implementation's path-restricted command list. The program is read after the two
418
+ * decorations the shell itself strips before choosing it (leading `NAME=value` assignments, a path
419
+ * prefix), so `/bin/cat` is judged as `cat`: recognizing readers by their literal spelling alone let
420
+ * a rule the persisted grammar admits (`Bash(/bin/cat:*)`) carry the exact read this gate exists for.
421
+ * Every other segment is skipped, so this face never invents a path judgement for a verb the
422
+ * deployment never declared readable.
423
+ * · `cd` is judged whether or not the deployment listed it, because the walk MODELS it (it threads the
424
+ * base across segments) and dropping it would place every later relative operand against a base bash
425
+ * has already left. A move the walk cannot model (`pushd`/`popd`) is not silently tolerated either —
426
+ * see the `paths`-less gated answer below.
427
+ * · The CONTAINMENT half only: {@link BashReadonlyRootBoundary.denyMatch} is deliberately NOT passed
428
+ * on. The deny judge outranks containment inside the walk and returns early, which would let a
429
+ * deny-listed operand HIDE the fact that it also sits outside the roots — the deny demotion is
430
+ * unchanged on the full face, and this one keeps answering its own question underneath it.
431
+ * · `face: "open"` is honoured: under the open read face containment is structurally satisfied, an
432
+ * out-of-roots operand is not a finding, and this gate is silent — a full-read deployment sees no
433
+ * gate here, by the same one mechanism §2.0 already defines.
434
+ *
435
+ * Answers `gated` with the resolved paths behind it, and NOT gated for every whole-string refusal (an
436
+ * unparseable grammar, a substitution, a redirection) and for an operand that cannot be resolved
437
+ * statically. That silence is the honest answer, not a miss: those shapes name no concrete directory,
438
+ * so there is nothing for a path gate to be about.
439
+ *
440
+ * It deliberately does NOT reuse {@link CompoundReadonlyVerdict}: one arm of this face is gated while
441
+ * resolving no path at all (the unmodelled working-directory move), and that arm cannot fill an
442
+ * `outOfRootPaths` whose contract says the entries ARE resolved out-of-root paths. A result type that
443
+ * can say "gated, and here is nothing" is the one that lets the arm exist without lying.
444
+ *
445
+ * The bounded poll-loop grammar is judged through its BODY, the same text that face re-classifies —
446
+ * without it, the one control structure this file auto-allows would be the one shape whose reads no
447
+ * gate looks at. The body is judged in a single pass (the per-iteration `cd` accumulation the
448
+ * auto-allow face models is not replicated here): a loop that climbs out of the roots over its beats
449
+ * is refused by that face on its own terms, and under-reporting here can only cost a gate, never grant
450
+ * one.
451
+ */
452
+ export declare function classifyOutOfRootReadGate(command: string, allow: ReadonlySet<string>, boundary: BashReadonlyRootBoundary): OutOfRootReadGateVerdict;
369
453
  /**
370
454
  * design/154 — compound read-only classification, reason-only face. Returns the demotion reason, or
371
455
  * undefined when the command classifies read-only. ⚠️ `undefined` is NOT "safe to auto-execute":
@@ -66,8 +66,45 @@ export function coarseReadonlyCheck(command, allow, options) {
66
66
  return undefined;
67
67
  }
68
68
  const SHELL_SEGMENT_HARD_REJECT = /[<>$()`\n\r\\]/;
69
+ export function carriesShellRedirection(text) {
70
+ return SHELL_REDIRECTION_OPERATORS.test(text);
71
+ }
72
+ const SHELL_REDIRECTION_OPERATORS = /[<>]/;
73
+ const SHELL_CONNECTOR_CHARS = /[|&;]/;
74
+ function redirectionPrecedesConnector(source) {
75
+ const mask = quoteMask(source);
76
+ const quoted = (i) => mask.balanced && mask.quoted[i] === true;
77
+ const isSpace = (c) => c === " " || c === "\t";
78
+ for (let i = 0; i < source.length; i++) {
79
+ if (quoted(i) || !SHELL_REDIRECTION_OPERATORS.test(source[i]))
80
+ continue;
81
+ let j = i + 1;
82
+ while (j < source.length && !quoted(j) && (SHELL_REDIRECTION_OPERATORS.test(source[j]) || isSpace(source[j])))
83
+ j++;
84
+ if (j < source.length && !quoted(j) && SHELL_CONNECTOR_CHARS.test(source[j]))
85
+ return true;
86
+ i = j - 1;
87
+ }
88
+ return false;
89
+ }
90
+ function rejectedSansRedirection(source) {
91
+ if (redirectionPrecedesConnector(source)) {
92
+ return "a redirection followed by `|`, `&` or `;` is not allowed — the operator's own characters cannot be told apart from a command boundary";
93
+ }
94
+ for (const ch of source) {
95
+ if (SHELL_SEGMENT_HARD_REJECT.test(ch) && !SHELL_REDIRECTION_OPERATORS.test(ch)) {
96
+ return "command/variable substitution, subshells, escapes, and line breaks are not allowed";
97
+ }
98
+ }
99
+ return undefined;
100
+ }
69
101
  export function splitShellCompoundSegments(source, options) {
70
- if (SHELL_SEGMENT_HARD_REJECT.test(source)) {
102
+ if (options?.redirection === "tolerate") {
103
+ const refused = rejectedSansRedirection(source);
104
+ if (refused !== undefined)
105
+ return { reject: refused };
106
+ }
107
+ else if (SHELL_SEGMENT_HARD_REJECT.test(source)) {
71
108
  return { reject: "redirection, command/variable substitution, subshells, escapes, and line breaks are not allowed" };
72
109
  }
73
110
  const scanned = options?.trailingTerminator !== "keep" ? source.replace(/;\s*$/, "") : source;
@@ -677,15 +714,18 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
677
714
  }
678
715
  return findings;
679
716
  }
680
- export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts) {
717
+ function segmentCompoundForReadonly(command) {
681
718
  const trimmed = command.trim();
682
719
  if (!trimmed)
683
- return { reason: "empty command" };
720
+ return { reject: "empty command" };
684
721
  const redirectionStripped = stripExemptRedirections(trimmed);
685
722
  const terminatorStripped = redirectionStripped.endsWith(";")
686
723
  ? redirectionStripped.slice(0, -1)
687
724
  : redirectionStripped;
688
- const split = splitShellCompoundSegments(terminatorStripped, { trailingTerminator: "keep" });
725
+ return splitShellCompoundSegments(terminatorStripped, { trailingTerminator: "keep" });
726
+ }
727
+ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts) {
728
+ const split = segmentCompoundForReadonly(command);
689
729
  if ("reject" in split)
690
730
  return { reason: split.reject };
691
731
  const { segments, pipeFed } = split;
@@ -884,6 +924,68 @@ export function classifySimpleCommandReadBoundary(command, boundary) {
884
924
  return {};
885
925
  return evaluateReadBoundary([toks], boundary);
886
926
  }
927
+ const NOT_GATED = { gated: false, paths: [] };
928
+ const ASSIGNMENT_WORD = /^[A-Za-z_][A-Za-z0-9_]*=/;
929
+ const UNMODELLED_BASE_MOVERS = new Set(["pushd", "popd"]);
930
+ const COMMAND_LAUNCHERS = new Set([
931
+ "env", "command", "builtin", "exec", "noglob", "xargs",
932
+ "sudo", "doas", "pkexec", "su", "runuser", "chroot", "setpriv",
933
+ "nohup", "nice", "ionice", "chrt", "taskset", "stdbuf", "timeout", "time", "watch",
934
+ "setsid", "flock", "unshare", "nsenter", "script", "numactl", "prlimit", "systemd-run", "parallel",
935
+ "strace", "ltrace", "valgrind", "firejail", "bwrap",
936
+ ]);
937
+ export const COMMAND_LAUNCHERS_FOR_TEST = COMMAND_LAUNCHERS;
938
+ const LAUNCHER_SCAN_HEAD = "cat";
939
+ function programBasename(word) {
940
+ return word.includes("/") ? (word.split("/").pop() ?? word) : word;
941
+ }
942
+ export function classifyOutOfRootReadGate(command, allow, boundary) {
943
+ const containmentOnly = {
944
+ roots: boundary.roots,
945
+ ...(boundary.cwd !== undefined ? { cwd: boundary.cwd } : {}),
946
+ ...(boundary.homeDir !== undefined ? { homeDir: boundary.homeDir } : {}),
947
+ ...(boundary.face !== undefined ? { face: boundary.face } : {}),
948
+ };
949
+ const judge = (text) => {
950
+ const split = segmentCompoundForReadonly(text);
951
+ if ("reject" in split)
952
+ return NOT_GATED;
953
+ const readers = [];
954
+ let baseMoved = false;
955
+ for (const segment of split.segments) {
956
+ const toks = tokenizeSegment(segment);
957
+ if (toks.folded.length === 0)
958
+ continue;
959
+ let i = 0;
960
+ while (i < toks.folded.length && ASSIGNMENT_WORD.test(toks.folded[i]))
961
+ i++;
962
+ const written = toks.folded[i];
963
+ if (written === undefined)
964
+ continue;
965
+ const name = programBasename(written);
966
+ if (!allow.has(name) && COMMAND_LAUNCHERS.has(name)) {
967
+ readers.push({ folded: [LAUNCHER_SCAN_HEAD, ...toks.folded.slice(i)], raw: [LAUNCHER_SCAN_HEAD, ...toks.raw.slice(i)] });
968
+ continue;
969
+ }
970
+ if (UNMODELLED_BASE_MOVERS.has(name))
971
+ baseMoved = true;
972
+ if (!allow.has(name) && name !== "cd")
973
+ continue;
974
+ readers.push(i === 0 && name === written ? toks : { folded: [name, ...toks.folded.slice(i + 1)], raw: [toks.raw[i] ?? name, ...toks.raw.slice(i + 1)] });
975
+ }
976
+ if (readers.length === 0)
977
+ return NOT_GATED;
978
+ if (baseMoved)
979
+ return { gated: true, paths: [] };
980
+ const verdict = evaluateReadBoundary(readers, containmentOnly);
981
+ return verdict.outOfRootRead === true ? { gated: true, paths: verdict.outOfRootPaths ?? [] } : NOT_GATED;
982
+ };
983
+ const direct = judge(command);
984
+ if (direct.gated)
985
+ return direct;
986
+ const shape = POLL_LOOP_SHAPE.exec(command.trim());
987
+ return shape !== null ? judge(shape[3]) : NOT_GATED;
988
+ }
887
989
  export function classifyCompoundReadonly(command, allow, boundary) {
888
990
  return classifyCompoundReadonlyDetailed(command, allow, boundary).reason;
889
991
  }
@@ -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, classifySimpleCommandReadBoundary, NOT_AUTO_ALLOWED, } from "./bash-readonly-classifier.js";
14
+ import { BASH_CLASSIFY_DEFAULT_ALLOW, BASH_READONLY_DEFAULT_ALLOW, coarseReadonlyCheck, classifyBoundedReadonlyPollLoop, classifyCompoundReadonlyDetailed, classifyOutOfRootReadGate, classifySimpleCommandReadBoundary, NOT_AUTO_ALLOWED, } from "./bash-readonly-classifier.js";
15
15
  const RECURSIVE_CAUSE_MAX_PATHS = 3;
16
16
  const RECURSIVE_READ_CAUSE_CODE = "shell.recursive_read_unbounded";
17
17
  function operandFamily(paths) {
@@ -28,9 +28,10 @@ export function bashReversibilityProbe(allow, boundary) {
28
28
  const command = a?.command;
29
29
  if (typeof command !== "string")
30
30
  return { reversible: false };
31
- if (a?.run_in_background === true)
32
- return { reversible: false };
33
31
  const resolved = typeof boundary === "function" ? boundary() : boundary;
32
+ const outOfRootGate = () => resolved !== undefined && classifyOutOfRootReadGate(command, allowSet, resolved).gated ? { mandated: true } : {};
33
+ if (a?.run_in_background === true)
34
+ return { reversible: false, ...outOfRootGate() };
34
35
  const detailed = classifyCompoundReadonlyDetailed(command, allowSet, resolved);
35
36
  if (detailed.reason === undefined) {
36
37
  if (detailed.undecidedPaths !== undefined && detailed.undecidedPaths.length > 0) {
@@ -40,6 +41,7 @@ export function bashReversibilityProbe(allow, boundary) {
40
41
  const others = detailed.undecidedPaths.filter((p) => !recursiveSet.has(p));
41
42
  return {
42
43
  reversible: false,
44
+ ...outOfRootGate(),
43
45
  cause: {
44
46
  code: RECURSIVE_READ_CAUSE_CODE,
45
47
  roots: operandFamily(recursive),
@@ -47,11 +49,13 @@ export function bashReversibilityProbe(allow, boundary) {
47
49
  },
48
50
  };
49
51
  }
50
- return { reversible: false };
52
+ return { reversible: false, ...outOfRootGate() };
51
53
  }
52
54
  return { reversible: true };
53
55
  }
54
- return { reversible: classifyBoundedReadonlyPollLoop(command, allowSet, resolved) === undefined };
56
+ return classifyBoundedReadonlyPollLoop(command, allowSet, resolved) === undefined
57
+ ? { reversible: true }
58
+ : { reversible: false, ...outOfRootGate() };
55
59
  };
56
60
  }
57
61
  export const FULL_SHELL_CONTRACT_ID = "core.bash@1";