@sema-agent/core 7.31.0 → 7.32.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 (99) hide show
  1. package/CHANGELOG.md +106 -0
  2. package/dist/agents/inherited-tool-pool.d.ts +28 -0
  3. package/dist/agents/inherited-tool-pool.js +15 -0
  4. package/dist/agents/list-agents-tool.d.ts +7 -0
  5. package/dist/agents/list-agents-tool.js +9 -2
  6. package/dist/agents/peer-session-drain.d.ts +2 -0
  7. package/dist/agents/peer-session-drain.js +1 -0
  8. package/dist/agents/send-message-tool.d.ts +17 -0
  9. package/dist/agents/send-message-tool.js +11 -4
  10. package/dist/agents/subagent.d.ts +10 -1
  11. package/dist/agents/subagent.js +24 -8
  12. package/dist/core/ask-origin.d.ts +15 -6
  13. package/dist/core/ask-origin.js +15 -9
  14. package/dist/core/background-shell.d.ts +45 -0
  15. package/dist/core/background-shell.js +13 -0
  16. package/dist/core/checkpoint-store.d.ts +5 -0
  17. package/dist/core/checkpoint-store.js +3 -2
  18. package/dist/core/engine-notice.d.ts +9 -0
  19. package/dist/core/gate-lanes.js +1 -1
  20. package/dist/core/governance-codes.d.ts +1 -1
  21. package/dist/core/governance-codes.js +5 -0
  22. package/dist/core/hooks.d.ts +2 -0
  23. package/dist/core/memory-engine/engine.d.ts +10 -1
  24. package/dist/core/memory-engine/engine.js +30 -6
  25. package/dist/core/memory-engine/file-backend.d.ts +15 -0
  26. package/dist/core/memory-engine/file-backend.js +53 -2
  27. package/dist/core/memory-engine/layout.d.ts +29 -4
  28. package/dist/core/memory-engine/layout.js +24 -4
  29. package/dist/core/read-root-grant-screen.d.ts +23 -0
  30. package/dist/core/read-root-grant-screen.js +17 -0
  31. package/dist/core/runner/contracts.d.ts +1 -1
  32. package/dist/core/runner/prepare-artifact.d.ts +12 -7
  33. package/dist/core/runner/prepare-artifact.js +10 -8
  34. package/dist/core/runner/prepare-ask-lane.d.ts +3 -0
  35. package/dist/core/runner/prepare-ask-lane.js +5 -1
  36. package/dist/core/runner/prepare-boundary-parks.js +5 -5
  37. package/dist/core/runner/prepare-delegation-surface.d.ts +18 -0
  38. package/dist/core/runner/prepare-delegation-surface.js +6 -25
  39. package/dist/core/runner/prepare-hands-readface.d.ts +12 -89
  40. package/dist/core/runner/prepare-hands-readface.js +14 -48
  41. package/dist/core/runner/prepare-inherited-gate.d.ts +3 -1
  42. package/dist/core/runner/prepare-inherited-gate.js +2 -1
  43. package/dist/core/runner/prepare-park-ask.js +6 -4
  44. package/dist/core/runner/prepare-policy-chain.js +4 -0
  45. package/dist/core/runner/prepare-task.js +5 -3
  46. package/dist/core/runner/read-root-grant.d.ts +66 -0
  47. package/dist/core/runner/read-root-grant.js +77 -0
  48. package/dist/core/runner/run-notification-lane.d.ts +1 -1
  49. package/dist/core/runner/run-settle-and-teardown.js +3 -9
  50. package/dist/core/runner/runtask.js +1 -1
  51. package/dist/core/runner/send-message-continuation.d.ts +45 -0
  52. package/dist/core/runner/send-message-continuation.js +27 -0
  53. package/dist/core/runner/session-read-file-states.d.ts +91 -0
  54. package/dist/core/runner/session-read-file-states.js +43 -0
  55. package/dist/core/secret-env.d.ts +1 -1
  56. package/dist/core/secret-env.js +7 -2
  57. package/dist/core/store-contracts/checkpoint-store-contract.d.ts +2 -0
  58. package/dist/core/store-contracts/checkpoint-store-contract.js +19 -0
  59. package/dist/core/task-registry-monitor.js +3 -0
  60. package/dist/core/task-registry-shared.d.ts +16 -3
  61. package/dist/core/task-registry.d.ts +65 -29
  62. package/dist/core/task-registry.js +78 -28
  63. package/dist/core/task-spec.d.ts +5 -3
  64. package/dist/core/tool-policy.d.ts +23 -0
  65. package/dist/core/tool-policy.js +15 -4
  66. package/dist/core/tool-spec.d.ts +2 -0
  67. package/dist/engine/execution-env/node-execution-env.d.ts +13 -3
  68. package/dist/engine/execution-env/node-execution-env.js +26 -0
  69. package/dist/index.d.ts +4 -3
  70. package/dist/index.js +2 -1
  71. package/dist/scenarios/full-body.d.ts +5 -2
  72. package/dist/scenarios/full-body.js +14 -7
  73. package/dist/tools/fs/fs-bash-background-text.d.ts +16 -0
  74. package/dist/tools/fs/fs-bash-background-text.js +22 -0
  75. package/dist/tools/fs/fs-bash.d.ts +6 -0
  76. package/dist/tools/fs/fs-bash.js +9 -27
  77. package/dist/tools/fs/fs-read.d.ts +2 -2
  78. package/dist/tools/fs/fs-search-tools.d.ts +3 -2
  79. package/dist/tools/fs/index.d.ts +8 -1
  80. package/dist/tools/fs/index.js +2 -3
  81. package/dist/tools/fs/repo-map.d.ts +2 -1
  82. package/dist/tools/fs/safety.d.ts +3 -1
  83. package/dist/tools/fs/safety.js +2 -1
  84. package/dist/tools/fs/shell-read-check.d.ts +3 -0
  85. package/dist/tools/fs/shell-read-check.js +1 -1
  86. package/dist/tools/fs/shell-read-establish.d.ts +3 -1
  87. package/dist/tools/fs/shell-read-establish.js +2 -2
  88. package/dist/tools/fs/shell-read-plan.d.ts +2 -2
  89. package/dist/tools/fs/shell-read-plan.js +15 -10
  90. package/dist/tools/fs/shell-read-word.d.ts +5 -4
  91. package/dist/tools/fs/shell-read-word.js +3 -2
  92. package/dist/tools/monitor.d.ts +5 -4
  93. package/dist/tools/monitor.js +5 -4
  94. package/dist/tools/todo.d.ts +3 -3
  95. package/dist/tools/todo.js +5 -4
  96. package/package.json +1 -1
  97. package/test/export-surface.snapshot.json +13 -1
  98. package/wire-changes/core@7.31.1.json +189 -0
  99. package/wire-changes/core@7.32.0.json +850 -0
@@ -111,6 +111,19 @@ export interface BackgroundShellCapability {
111
111
  pollBackground(shellId: BackgroundShellId): Promise<Result<BackgroundPoll, BackgroundShellError>>;
112
112
  /** 杀一个后台进程(幂等:杀已死的是 no-op,返回 ok)。`shellId` 同 {@link pollBackground} 的越界校验。 */
113
113
  killBackground(shellId: BackgroundShellId): Promise<Result<void, BackgroundShellError>>;
114
+ /**
115
+ * OPTIONAL — the SYNCHRONOUS hard kill for an exit boundary with no later tick, where a ladder-style
116
+ * {@link killBackground} (TERM, then a timer-driven KILL) never finishes a shell that ignores SIGTERM.
117
+ * Contract (docs/background-process-lifetime.md): complete the kill before returning — POSIX one
118
+ * `kill(-pgid, SIGKILL)` over the shell's process group (an env with no synchronous hard kill, e.g. win32,
119
+ * answers `no_pgid` and the registry falls back to the ordinary kill); no TERM, no timer,
120
+ * no await; never signal a pid the env cannot prove is still its own; never throw; idempotent; the pid never
121
+ * leaves the env. Outcomes: `killed` (delivered now), `already_gone` (exited, or this seat already delivered
122
+ * its kill, or the id is not its shell any more), `no_pgid` (no synchronous kill landed — nothing host-local
123
+ * to signal, or the signal was refused; nothing claimed, retryable). Without this seat the registry's hard
124
+ * face reads `no_pgid` for every shell.
125
+ */
126
+ killBackgroundNow?(shellId: BackgroundShellId): BackgroundHardKillOutcome;
114
127
  /**
115
128
  * 杀掉并清理**本 env 的所有**后台进程。Runner 在每条退出路径调:finish/abort/throw 在 run-loop tail finally,**suspend/
116
129
  * review 必须在 `suspendVM` 之前**(design/103 §3.7;detached job 不在 suspendVM in-flight 契约内,不能指望 adapter 隐式处理)。
@@ -125,6 +138,28 @@ export interface BackgroundShellCapability {
125
138
  except?: readonly BackgroundShellId[];
126
139
  }): Promise<void>;
127
140
  }
141
+ /** The closed outcome set of {@link BackgroundShellCapability.killBackgroundNow}. */
142
+ export type BackgroundHardKillOutcome = "killed" | "already_gone" | "no_pgid";
143
+ /** The retain lanes a face can see (absent = not declared). */
144
+ export interface BackgroundRetainLanes {
145
+ /** The env's own declaration: `backgroundCapabilities.retainBackgroundProcesses`. */
146
+ readonly env?: boolean;
147
+ /** The task's declaration: `TaskSpec.retainBackgroundProcesses` of the run whose face is asking. */
148
+ readonly task?: boolean;
149
+ /** The row's `retained` mark (stamped at registration; see the registry row). */
150
+ readonly marked?: boolean;
151
+ /** The owning ISOLATED env is destroyed with this run: retention there is illusory; overrides every lane. */
152
+ readonly envDying?: boolean;
153
+ }
154
+ /**
155
+ * THE retain predicate — "is this background process kept alive past the automatic reaps?" Every face that
156
+ * answers it calls this function (Bash description + receipts, Monitor receipt, the row stamp, teardown settle
157
+ * and dispose, park-only sweeps, session reap, the exit hard face, the listing). Any declared lane retains,
158
+ * unless the owning isolated env is being destroyed. Contract: docs/background-process-lifetime.md.
159
+ */
160
+ export declare function retainsBackgroundProcesses(lanes: BackgroundRetainLanes): boolean;
161
+ /** The env lane of {@link BackgroundRetainLanes}, read off an env (false for an env without the capability). */
162
+ export declare function envDeclaresRetain(env: ExecutionEnv): boolean;
128
163
  /**
129
164
  * 结构 + 语义检测:`env` 是否暴露后台 shell 能力且声明 `supported:true`。挂载门控用此(对齐 `hasDestroy`)。
130
165
  *
@@ -156,3 +191,13 @@ export interface EnvSweepAttribution {
156
191
  export declare function sweepBackgroundShells(env: ExecutionEnv & BackgroundShellCapability, attribution: EnvSweepAttribution | undefined, opts?: {
157
192
  except?: readonly string[];
158
193
  }): Promise<void>;
194
+ /**
195
+ * The park-only sweep (a durable park that does not pause the env), one entry point for every park lane:
196
+ * nothing is swept when this run's task or the env declares retain; otherwise every row already retained on
197
+ * this env (an earlier run's delivered service) is spared.
198
+ */
199
+ export declare function sweepParkOnlyBackgroundShells(env: ExecutionEnv & BackgroundShellCapability, registry: EnvSweepAttribution & {
200
+ retainedShellIds(env: unknown): readonly string[];
201
+ }, lanes: {
202
+ task: boolean;
203
+ }): Promise<void>;
@@ -8,6 +8,13 @@ export class BackgroundShellError extends Error {
8
8
  this.name = "BackgroundShellError";
9
9
  }
10
10
  }
11
+ export function retainsBackgroundProcesses(lanes) {
12
+ return (lanes.env === true || lanes.task === true || lanes.marked === true) && lanes.envDying !== true;
13
+ }
14
+ export function envDeclaresRetain(env) {
15
+ const caps = env.backgroundCapabilities;
16
+ return typeof caps === "object" && caps !== null && caps.retainBackgroundProcesses === true;
17
+ }
11
18
  export function hasBackgroundShell(env) {
12
19
  const e = env;
13
20
  return (typeof e.spawnBackground === "function" &&
@@ -26,3 +33,9 @@ export async function sweepBackgroundShells(env, attribution, opts) {
26
33
  }
27
34
  await env.disposeBackgroundShells(opts?.except !== undefined ? { except: opts.except } : undefined);
28
35
  }
36
+ export async function sweepParkOnlyBackgroundShells(env, registry, lanes) {
37
+ if (retainsBackgroundProcesses({ env: envDeclaresRetain(env), task: lanes.task }))
38
+ return;
39
+ const except = registry.retainedShellIds(env);
40
+ await sweepBackgroundShells(env, registry, except.length > 0 ? { except } : undefined);
41
+ }
@@ -816,6 +816,9 @@ export type PendingAction = {
816
816
  * when a person's rule also speaks — never a substitute for this one). Display metadata: no gate, CAS or
817
817
  * resume path reads it. */
818
818
  mandated?: true;
819
+ /** The PARK twin of `AskRequest.mandate` (same derivation, same precedent): the table word the bit stands on,
820
+ * present only when it is the whole reason. Written only as a member of the closed set. */
821
+ mandate?: import("./ask-origin.js").PersistedRuleMandate;
819
822
  /** The PARK twin of `AskRequest.denialLimitFallback`: present
820
823
  * ⇔ this parked ask is the classifier DENIAL-LIMIT fallback, carrying the counts that tripped
821
824
  * the bound. Its `autoDenyAfterMs` is ALWAYS `0` on a row: the window is a fact of the ask's
@@ -1947,6 +1950,8 @@ export interface CheckpointSummary {
1947
1950
  * OMITTED (the {@link toolCallId} absence-shape contract), screened to the literal `true` — a pre-#1079 row
1948
1951
  * reads absent (unreported). ECHO-ONLY; nothing gates on it. */
1949
1952
  mandated?: true;
1953
+ /** The row's `PendingAction.tool_approval.mandate`, echoed ONLY as a member of the closed set. ECHO-ONLY. */
1954
+ mandate?: import("./ask-origin.js").PersistedRuleMandate;
1950
1955
  /** The row's `PendingAction.tool_approval.denialLimitFallback`, projected BESIDE the preview
1951
1956
  * for the same one-call reason: a card that shows the fallback's counts and limit must not re-open
1952
1957
  * the row to learn them (`autoDenyAfterMs` is `0` on every row — see the row member). Pure echo;
@@ -3,7 +3,7 @@ import { uuidv7 } from "../internal/harness.js";
3
3
  import { PROBE_CAUSE_PATH_MAX, PROBE_REASON_MAX, inlineUntrusted, sanitizeUntrustedText } from "./untrusted-text.js";
4
4
  import { carriesBidiControls } from "./tool-policy.js";
5
5
  import { readDenialLimitFallback } from "./auto-mode.js";
6
- import { isAskOrigin, isRuleStoreUnreadable } from "./ask-origin.js";
6
+ import { isAskOrigin, isPersistedRuleMandate, isRuleStoreUnreadable } from "./ask-origin.js";
7
7
  import { checkpointExecutionRecorded, executionRecordDisposition, ExecutionOutcomeConflictError, } from "./checkpoint-execution-record.js";
8
8
  import { renderUntrustedCommandText, stripFormatCharacters } from "./permission-rule-model.js";
9
9
  import { redactSecrets } from "./untrusted-egress.js";
@@ -305,7 +305,8 @@ export function summarizeCheckpoint(cp) {
305
305
  ...(() => {
306
306
  const origin = tool?.origin;
307
307
  const ruleStoreUnreadable = tool?.ruleStoreUnreadable;
308
- return { ...(isAskOrigin(origin) ? { origin } : {}), ...(isRuleStoreUnreadable(ruleStoreUnreadable) ? { ruleStoreUnreadable } : {}) };
308
+ const mandate = tool?.mandate;
309
+ return { ...(isAskOrigin(origin) ? { origin } : {}), ...(isRuleStoreUnreadable(ruleStoreUnreadable) ? { ruleStoreUnreadable } : {}), ...(isPersistedRuleMandate(mandate) ? { mandate } : {}) };
309
310
  })(),
310
311
  ...(hasBidi ? { hasBidiControls: true } : {}),
311
312
  };
@@ -400,6 +400,15 @@ export interface EngineNotice {
400
400
  * AFTER the loop's end is refused typed (`steering.not_running`, every verb alike) and mints
401
401
  * nothing: the refusal itself is the loud answer.
402
402
  *
403
+ * - `"approval.read_root_granted"` / `"approval.read_root_grant_rejected"` — an approver's `readRootGrant`
404
+ * (`AskOutcome`). GRANTED: it equalled the card's `readRootCandidate.dir` beside `allow: true`; this run's READ
405
+ * directories now include it (never the write roots; this run only); `detail: { dir, covers?, root, toolName,
406
+ * toolCallId, sessionId, runId, taskId? }`, `root` = the canonical spelling held. REJECTED: nothing changed,
407
+ * the verdict stands; `detail: { reason, offered?, requested?, …same ids }`, `reason` ∈ `on_refusal` |
408
+ * `not_a_string` | `no_candidate` | `mismatch` | `unresolvable` | `resolves_elsewhere` | `admits_artifact_store`
409
+ * (the directory contains the wired local artifact store, lies inside it or is it) | `no_read_roots`. Once per
410
+ * answered ask. Audience `"user"`.
411
+ *
403
412
  * - `"checkpoint.execution_outcome_unrecorded"` — the resumed leg's pending-call resolver disposed
404
413
  * the decided action (executed it, delivered the refusal, or vetoed the person's yes) and filed
405
414
  * the frame's record through `CheckpointStore.recordExecutionOutcome`, and the store answered
@@ -467,7 +467,7 @@ export async function runGateLanes(pass) {
467
467
  ...(d.action === "ask" && d.denialLimitFallback !== undefined ? { denialLimitFallback: unarmedWindow(d.denialLimitFallback) } : {}),
468
468
  ...(d.action === "ask" && d.origin !== undefined ? { origin: d.origin } : {}),
469
469
  ...(d.action === "ask" && d.ruleStoreUnreadable !== undefined ? { ruleStoreUnreadable: d.ruleStoreUnreadable } : {}),
470
- ...(d.action === "ask" && d.mandated === true ? { mandated: true } : {}),
470
+ ...(d.action === "ask" && d.mandated === true ? { mandated: true, ...(d.mandate !== undefined ? { mandate: d.mandate } : {}) } : {}),
471
471
  });
472
472
  if (suspendAsk && pass.decision.action === "ask") {
473
473
  const parkArgs = [pass.req, pass.currentInput, safety, undefined, realApprovalOf(pass.decision), pass.decision.action === "ask" ? pass.decision.persistedRuleShadowed : undefined, pass.decision.action === "ask" ? pass.decision.decisionReason : undefined, pass.decision.action === "ask" ? pass.decision.probeReason : undefined, pass.decision.action === "ask" ? pass.decision.probeCause : undefined, pass.decision.action === "ask" ? pass.decision.segmentCoverage : undefined, pass.decision.action === "ask" ? pass.decision.matchedAskRule : undefined, pass.decision.action === "ask" ? pass.decision.probeMandated : undefined];
@@ -119,7 +119,7 @@ export type NoticeAudience = "user" | "operator";
119
119
  * the union's catalog ({@link EngineNotice.code}). The groups below say only which family a row joins,
120
120
  * so the catalog is not written a second time here and the two cannot drift.
121
121
  */
122
- export declare const ENGINE_NOTICE_CODES: readonly ["config.autocompact_window_clamped", "config.env_timeout_discarded", "config.materialize_env_discarded", "config.models_swapped", "config.context_settings_swapped", "config.read_face_swapped", "config.read_face_deployment_clamped", "config.write_face_swapped", "config.write_face_deployment_clamped", "config.tool_model_gate_removed", "config.tool_model_gate_unknown_class", "config.tool_model_gate_env_invalid", "config.artifact_host_invalid", "config.task_root_not_canonical", "config.write_protection_target_view_absent", "config.write_protection_unresolved", "config.execution_env_capability_invalid", "config.tool_card_undeclared", "config.tool_face_undeclared", "config.tool_face_invalid", "config.forward_sink_failed", "config.durable_gate_unavailable", "config.peer_admission_out_of_range", "config.peer_lane_unmounted", "config.secret_env_scrubbed", "peer.inbound_disposition", "peer.held_settled", "peer.idle_subscription", "classifier.denial_limit", "classifier.fallback", "classifier.role_defaulted", "classifier.contract_broken", "checkpoint.execution_outcome_unrecorded", "delegation.transcript_integrity", "delegation.ask_unresolvable", "mcp.injection_dropped", "mcp.revocation_probe_failed", "mcp.server_redialed", "workflow.governance_key_stripped", "workflow.agent_option_ignored", "memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.content_class_declared", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "memory.consolidation_recommended", "memory.consolidation_committed", "memory.consolidation_conflict", "memory.consolidation_incomplete", "memory.consolidation_refused", "memory.consolidation_withheld", "memory.restore_withheld", "memory.project_scope_absent", "route.fallback_to_primary", "route.classifier_unroutable", "route.base_url_changed_key_unchanged", "route.response_model_mismatch", "task.user_steer_undrained", "task.user_followup_undrained", "steering.parked_input_blocked", "task.turn_interrupted", "task.halt_unconsumed", "task.late_approval", "task.interrupt_unconsumed", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "skills.listing_truncated", "agents.listing_truncated", "tool_result.offload_put_failed"];
122
+ export declare const ENGINE_NOTICE_CODES: readonly ["config.autocompact_window_clamped", "config.env_timeout_discarded", "config.materialize_env_discarded", "config.models_swapped", "config.context_settings_swapped", "config.read_face_swapped", "config.read_face_deployment_clamped", "config.write_face_swapped", "config.write_face_deployment_clamped", "config.tool_model_gate_removed", "config.tool_model_gate_unknown_class", "config.tool_model_gate_env_invalid", "config.artifact_host_invalid", "config.task_root_not_canonical", "config.write_protection_target_view_absent", "config.write_protection_unresolved", "config.execution_env_capability_invalid", "config.tool_card_undeclared", "config.tool_face_undeclared", "config.tool_face_invalid", "config.forward_sink_failed", "config.durable_gate_unavailable", "config.peer_admission_out_of_range", "config.peer_lane_unmounted", "config.secret_env_scrubbed", "peer.inbound_disposition", "peer.held_settled", "peer.idle_subscription", "classifier.denial_limit", "classifier.fallback", "classifier.role_defaulted", "classifier.contract_broken", "checkpoint.execution_outcome_unrecorded", "delegation.transcript_integrity", "delegation.ask_unresolvable", "mcp.injection_dropped", "mcp.revocation_probe_failed", "mcp.server_redialed", "workflow.governance_key_stripped", "workflow.agent_option_ignored", "memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.content_class_declared", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "memory.consolidation_recommended", "memory.consolidation_committed", "memory.consolidation_conflict", "memory.consolidation_incomplete", "memory.consolidation_refused", "memory.consolidation_withheld", "memory.restore_withheld", "memory.project_scope_absent", "route.fallback_to_primary", "route.classifier_unroutable", "route.base_url_changed_key_unchanged", "route.response_model_mismatch", "task.user_steer_undrained", "task.user_followup_undrained", "steering.parked_input_blocked", "task.turn_interrupted", "task.halt_unconsumed", "task.late_approval", "task.interrupt_unconsumed", "approval.read_root_granted", "approval.read_root_grant_rejected", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "skills.listing_truncated", "agents.listing_truncated", "tool_result.offload_put_failed"];
123
123
  /** A code this engine mints (see {@link ENGINE_NOTICE_CODES}). NOT the type of
124
124
  * `EngineNotice.code`, which stays `string` — a host forwarding its own notices through the same
125
125
  * sink is a supported shape, and narrowing that field would break it. */
@@ -83,6 +83,7 @@ export const NON_GOVERNANCE_MEMORY_CODES = new Set([
83
83
  "memory.capture_optout_unpersisted",
84
84
  "memory.capture_optout_unavailable",
85
85
  "memory.capture_optout_sweep_failed",
86
+ "memory.target_outside_store",
86
87
  ]);
87
88
  export function governanceRetryClass(code) {
88
89
  if (Object.prototype.hasOwnProperty.call(GOVERNANCE_CODES, code)) {
@@ -168,6 +169,8 @@ export const ENGINE_NOTICE_CODES = [
168
169
  "task.halt_unconsumed",
169
170
  "task.late_approval",
170
171
  "task.interrupt_unconsumed",
172
+ "approval.read_root_granted",
173
+ "approval.read_root_grant_rejected",
171
174
  "memory.capture_opted_out",
172
175
  "memory.capture_optout_unpersisted",
173
176
  "skills.listing_truncated",
@@ -192,6 +195,8 @@ const NOTICE_AUDIENCE_TABLE = {
192
195
  "task.halt_unconsumed": "user",
193
196
  "task.late_approval": "user",
194
197
  "task.interrupt_unconsumed": "user",
198
+ "approval.read_root_granted": "user",
199
+ "approval.read_root_grant_rejected": "user",
195
200
  "config.durable_gate_unavailable": "user",
196
201
  "memory.capture_opted_out": "user",
197
202
  "memory.capture_optout_unpersisted": "user",
@@ -1714,6 +1714,8 @@ export interface AskCarry {
1714
1714
  /** The stamped mandate ({@link import("./tool-policy.js").AskRequest.mandated}) — the park site's
1715
1715
  * rule-offer facts read it, so a parked mandated ask offers no rule, like the live card. */
1716
1716
  readonly mandated?: true;
1717
+ /** The table word stamped beside it ({@link import("./tool-policy.js").AskRequest.mandate}), for the row's twin. */
1718
+ readonly mandate?: import("./ask-origin.js").PersistedRuleMandate;
1717
1719
  }
1718
1720
  /** What a durable row persists of an {@link AskCarry}: the fallback member COPIED (the row never aliases
1719
1721
  * the gate's object; its window is already `0` — the gate's carry mint is the one writer of that
@@ -833,6 +833,8 @@ export interface SessionMemoryStatus {
833
833
  export declare class MemoryEngine {
834
834
  private readonly backend;
835
835
  private readonly memoryDir;
836
+ /** {@link memoryDir}'s physical form as this engine first mounted it; every later mount compares, never re-resolves. */
837
+ private mountedRoot;
836
838
  private readonly controlDir;
837
839
  private readonly now;
838
840
  private readonly perScopeBudgetBytes;
@@ -1776,10 +1778,17 @@ export declare class MemoryEngine {
1776
1778
  * @returns the rejection to report, or `undefined` when the index is clean/absent.
1777
1779
  */
1778
1780
  private gateDerivedIndex;
1781
+ /** The file backend's verdict at an act the engine itself performs on a store path ({@link
1782
+ * storeTargetEscape}): throws `memory.target_outside_store` when `path` would land outside `root`;
1783
+ * each caller's existing failure lane reports it. `root` is the mount's canonical root where a
1784
+ * handle exists, so a root re-pointed after the mount is judged against the one it mounted. */
1785
+ private refuseOutsideStore;
1779
1786
  /** Projection write: reads the target to skip a no-op, then writes — the same check-then-use shape
1780
1787
  * as the containment restores, so it takes the same no-follow write. A link at a projection path
1781
1788
  * is never a supported layout (the harvest scan refuses symlinked entry files outright), and this
1782
- * site already reports write failures by throwing, so ELOOP joins the failures it already had. */
1789
+ * site already reports write failures by throwing, so ELOOP joins the failures it already had. A
1790
+ * target whose directory resolves outside `root` (a directory on the path is a link) is refused
1791
+ * the same way, before anything is read or written ({@link storeTargetEscape}). */
1783
1792
  private writeIfChanged;
1784
1793
  /** Persist the canonical projection back to the session file (id minting / frontmatter completion).
1785
1794
  * C-F6 (S2-0): called ONLY after the backend transaction committed — never ahead of the journal
@@ -17,7 +17,7 @@ import { landedUndecided, layerDigest, layerObservationLookup, readOnlyLayerCand
17
17
  import { markOriginClearanceTombstoned, openOriginClearance, readOriginClearances, settleOriginClearance } from "./origin-clearance.js";
18
18
  import { DEFAULT_MAX_ENTRY_DEPTH, MEMORY_INDEX_FILENAME, canonicalJsonStringify, captureErasureInput, erasureRequestInvalid, erasureSelectHash, scanEntryFiles, } from "./file-backend.js";
19
19
  import { assembleMemoryExportBundle, computeMemoryBundleHash, memoryBundleInvalid, } from "./export-bundle.js";
20
- import { QUARANTINE_DIR, SCAN_FUSE_THRESHOLD, quarantineAndTombstone, restoreContainedBaseline, readIndexRevs, writeIndexRevs, bumpScanFuse, scanFuseKey, canonicalize, adoptRootScopeLayout, clearScanFuse, adoptCanonicalKeyedControlDir, deriveControlPlaneDir, drainMemoryAnnouncements, enqueueMemoryAnnouncement, ensureDirExists, markSessionPolluted, readSessionPollution, fileSessionCaptureRecordStore, recordRetrievedAccount, writeFileNoFollow, withPermissionRemedy, permissionRemedyOf, readRetrievedAccount, registerScope, registeredScopes, resolveMemoryEngineRoot, scopeDirFor, appendChallengeEvents, appendLineageAudit, rebuildStrictControlPlaneLedger, isStrictControlPlaneLedgerCorrupt, CHALLENGE_LEDGER_MAX_EVENTS, adjudicateLineagePending, challengedEntryIds, clearLineageForEntries, discardLineagePending, lineageAccountOfEntry, lineageContributionsOfSession, lineageLatchedIds, promoteLineagePending, readChallengeEvents, readChallengedHistory, readLineageRecord, recordChallengedHistory, recordLineageCredential, reconcileLineage, readProjectionDebts, resolveChallengeEvent, settleProjectionDebts, stageLineagePending, stageProjectionDebts, } from "./layout.js";
20
+ import { QUARANTINE_DIR, SCAN_FUSE_THRESHOLD, quarantineAndTombstone, restoreContainedBaseline, readIndexRevs, writeIndexRevs, bumpScanFuse, scanFuseKey, canonicalize, landingPathOf, storeTargetEscape, TARGET_OUTSIDE_STORE, adoptRootScopeLayout, clearScanFuse, adoptCanonicalKeyedControlDir, deriveControlPlaneDir, drainMemoryAnnouncements, enqueueMemoryAnnouncement, ensureDirExists, markSessionPolluted, readSessionPollution, fileSessionCaptureRecordStore, recordRetrievedAccount, writeFileNoFollow, withPermissionRemedy, permissionRemedyOf, readRetrievedAccount, registerScope, registeredScopes, resolveMemoryEngineRoot, scopeDirFor, appendChallengeEvents, appendLineageAudit, rebuildStrictControlPlaneLedger, isStrictControlPlaneLedgerCorrupt, CHALLENGE_LEDGER_MAX_EVENTS, adjudicateLineagePending, challengedEntryIds, clearLineageForEntries, discardLineagePending, lineageAccountOfEntry, lineageContributionsOfSession, lineageLatchedIds, promoteLineagePending, readChallengeEvents, readChallengedHistory, readLineageRecord, recordChallengedHistory, recordLineageCredential, reconcileLineage, readProjectionDebts, resolveChallengeEvent, settleProjectionDebts, stageLineagePending, stageProjectionDebts, } from "./layout.js";
21
21
  import { createSessionIncarnationSeat, recalledForAnyIdentity, recalledForIdentity, rememberForIdentity } from "./session-incarnation.js";
22
22
  import { scanMemoryFileName, scanMemoryWrite, scanRemediation } from "./scan.js";
23
23
  import { writableRootOf } from "./types.js";
@@ -310,6 +310,7 @@ const STALE_LEG_NOTE = "memory session account: this harvest belongs to an earli
310
310
  export class MemoryEngine {
311
311
  backend;
312
312
  memoryDir;
313
+ mountedRoot;
313
314
  controlDir;
314
315
  now;
315
316
  perScopeBudgetBytes;
@@ -427,7 +428,7 @@ export class MemoryEngine {
427
428
  }
428
429
  return this.backend.readCommittedShadow?.(id) ?? readSafe(join(this.controlDir, "shadow", `${id}.md`));
429
430
  }
430
- async restoreReadOnlyLayers(candidates, report, selfLeg) {
431
+ async restoreReadOnlyLayers(candidates, report, selfLeg, root) {
431
432
  let writers;
432
433
  for (const c of candidates) {
433
434
  const text = readSafe(c.path);
@@ -450,6 +451,7 @@ export class MemoryEngine {
450
451
  continue;
451
452
  }
452
453
  try {
454
+ this.refuseOutsideStore(root, c.path, "restoring the read-only layer file");
453
455
  writeFileNoFollow(c.path, committed);
454
456
  report.restored.push(c.relPath);
455
457
  }
@@ -755,6 +757,7 @@ export class MemoryEngine {
755
757
  const indexPath = join(writableRoot, MEMORY_INDEX_FILENAME);
756
758
  const baseline = handle.planeBaseline !== undefined ? handle.planeBaseline.index : handle.indexText;
757
759
  try {
760
+ this.refuseOutsideStore(handle.memoryDir, indexPath, "sweeping the index");
758
761
  const first = existsSync(indexPath) ? readSafe(indexPath) : undefined;
759
762
  if (first !== undefined && first !== baseline) {
760
763
  const onDisk = readSafe(indexPath);
@@ -1088,6 +1091,7 @@ export class MemoryEngine {
1088
1091
  if (kept.length === lines.length)
1089
1092
  continue;
1090
1093
  try {
1094
+ this.refuseOutsideStore(landingPathOf(this.memoryDir), indexPath, "rewriting the index");
1091
1095
  writeFileNoFollow(indexPath, kept.join("\n"));
1092
1096
  }
1093
1097
  catch {
@@ -1409,6 +1413,13 @@ export class MemoryEngine {
1409
1413
  }
1410
1414
  async materializeCore(scopes, writeScope, opts, legSeat) {
1411
1415
  ensureDirExists(this.memoryDir);
1416
+ const physicalRoot = landingPathOf(this.memoryDir);
1417
+ this.mountedRoot ??= physicalRoot;
1418
+ if (physicalRoot !== this.mountedRoot) {
1419
+ const e = new Error(`${TARGET_OUTSIDE_STORE}: the memory dir ${this.memoryDir} now resolves to ${physicalRoot}, not ${this.mountedRoot} where this engine first mounted it — refused; open a new engine on the directory you mean`);
1420
+ e.code = TARGET_OUTSIDE_STORE;
1421
+ throw e;
1422
+ }
1412
1423
  ensureDirExists(this.controlDir);
1413
1424
  await this.adoptLayoutBeforeMount(writeScope);
1414
1425
  const restricted = opts?.adoptionRestricted === true;
@@ -1593,6 +1604,7 @@ export class MemoryEngine {
1593
1604
  if (handle.writableRoot !== null)
1594
1605
  ensureDirExists(handle.writableRoot);
1595
1606
  const zeroCopy = this.backendPinnedRoot !== undefined && canonicalize(this.backendPinnedRoot) === canonicalize(this.memoryDir);
1607
+ const projectionRoot = zeroCopy ? undefined : this.mountedRoot;
1596
1608
  for (const [scope, dir] of scopeDirs) {
1597
1609
  const scopeEntries = (byScope.get(scope) ?? []).slice();
1598
1610
  scopeEntries.sort((a, b) => (mtimeById.get(b.id) ?? 0) - (mtimeById.get(a.id) ?? 0));
@@ -1614,8 +1626,8 @@ export class MemoryEngine {
1614
1626
  const { deleted: _stubDrop, ...stubFm } = entry.frontmatter;
1615
1627
  const text = stub ? serializeEntryFile({ id: entry.id, frontmatter: stubFm, body: STUB_ARCHIVED_LINE }) : fullText;
1616
1628
  const target = join(dir, `${entry.slug}.md`);
1617
- if (!zeroCopy)
1618
- this.writeIfChanged(target, text);
1629
+ if (projectionRoot !== undefined)
1630
+ this.writeIfChanged(target, text, projectionRoot);
1619
1631
  const onDiskRev = stub ? revOfText(text, entry.id) : entry.rev;
1620
1632
  const path = canonicalize(target);
1621
1633
  handle.materialized.push({
@@ -2176,7 +2188,7 @@ export class MemoryEngine {
2176
2188
  signalContainment((c) => (c.indexRolledBack = true));
2177
2189
  report.warnings.push(`memory harvest refused by an incident while this session is marked externally exposed — no entries were committed; instruction-form files were withheld and the derived index rolled back: ${inlineUntrusted(pollutedReason, 200)}`);
2178
2190
  };
2179
- await this.restoreReadOnlyLayers(readOnlyRestorable, report, handle.accountLeg);
2191
+ await this.restoreReadOnlyLayers(readOnlyRestorable, report, handle.accountLeg, handle.memoryDir);
2180
2192
  const missingWritable = writable.filter((m) => !recordByPath.has(m.path) && !existsSync(m.path) && !presentIds.has(m.id));
2181
2193
  report.missing.push(...missingWritable.map((m) => m.relPath));
2182
2194
  if (writable.length > 0 && missingWritable.length / writable.length > MASS_DELETION_FUSE_RATIO) {
@@ -2203,6 +2215,7 @@ export class MemoryEngine {
2203
2215
  if (content === undefined)
2204
2216
  continue;
2205
2217
  try {
2218
+ this.refuseOutsideStore(handle.memoryDir, m.path, "restoring");
2206
2219
  mkdirSync(dirname(m.path), { recursive: true });
2207
2220
  writeFileNoFollow(m.path, content);
2208
2221
  report.restored.push(m.relPath);
@@ -4818,6 +4831,7 @@ export class MemoryEngine {
4818
4831
  if (opts.write) {
4819
4832
  let wrote = false;
4820
4833
  try {
4834
+ this.refuseOutsideStore(handle.memoryDir, indexPath, "writing the index");
4821
4835
  mkdirSync(dirname(indexPath), { recursive: true });
4822
4836
  if (readNoFollowSafe(indexPath) !== text)
4823
4837
  writeFileNoFollow(indexPath, text);
@@ -4908,6 +4922,7 @@ export class MemoryEngine {
4908
4922
  }
4909
4923
  let cleared = false;
4910
4924
  try {
4925
+ this.refuseOutsideStore(handle.memoryDir, indexPath, "clearing the index");
4911
4926
  writeFileNoFollow(indexPath, "");
4912
4927
  cleared = true;
4913
4928
  }
@@ -4927,7 +4942,16 @@ export class MemoryEngine {
4927
4942
  contained: cleared,
4928
4943
  };
4929
4944
  }
4930
- writeIfChanged(target, text) {
4945
+ refuseOutsideStore(root, path, act) {
4946
+ const escape = storeTargetEscape(root, path);
4947
+ if (escape === undefined)
4948
+ return;
4949
+ const e = new Error(`${TARGET_OUTSIDE_STORE}: ${act} ${path} was refused — ${escape}; not performed`);
4950
+ e.code = TARGET_OUTSIDE_STORE;
4951
+ throw e;
4952
+ }
4953
+ writeIfChanged(target, text, root) {
4954
+ this.refuseOutsideStore(root, target, "the projection write of");
4931
4955
  const current = readNoFollowSafe(target);
4932
4956
  if (current === text)
4933
4957
  return;
@@ -353,6 +353,8 @@ export declare class FileMemoryEngineBackend implements MemoryBackend {
353
353
  * NEVER under {@link directoryRoot}, always on the config-root side even for an in-repo memory dir.
354
354
  * The engine picks this up (like `directoryRoot`) so both sides share ONE control plane. */
355
355
  readonly controlPlaneRoot: string;
356
+ /** {@link directoryRoot}'s physical form at construction — the root every act on a store path is judged against. */
357
+ private readonly physicalRoot;
356
358
  /** Injected clock (opus 1.258 LOW: match the engine's `this.now` determinism discipline). */
357
359
  private readonly now;
358
360
  /** The construction-time incident sink (partition adoption, layout adoption on the standalone faces). */
@@ -679,6 +681,19 @@ export declare class FileMemoryEngineBackend implements MemoryBackend {
679
681
  * §3.1 ⓪ (r5-①): "no lock" is ENOENT-only — a stat FAILURE folded into "nobody is committing"
680
682
  * let a transient EIO run the unlocked recovery (and its staged sweep) over a live transaction. */
681
683
  private txnInFlight;
684
+ /**
685
+ * The act gate for store paths ({@link storeTargetEscape}) over `ops`' writes and deletes — shadow
686
+ * ops name control-plane files, not store paths. Refuses with `memory.target_outside_store` at the
687
+ * first op that would land outside the store, naming the op and where it resolves. Runs over a
688
+ * whole batch before its commit marker (no journal is written; a caller that staged files removes
689
+ * them) and per op at the act itself; past the marker a refusal keeps the journal, which replays
690
+ * once the path resolves inside the store again.
691
+ */
692
+ private refuseEscapingTargets;
693
+ /** At the head of every face (both recovery legs, the lock-free faces' layout seat, the adoption face):
694
+ * the store root still resolves to the directory this backend opened. A root re-pointed since would
695
+ * carry every read, adoption, sweep and restore — not only the journal's acts — into another directory. */
696
+ private assertRootIntact;
682
697
  /** Remove orphaned `.stage-*` files (a crash BEFORE the journal rename = clean rollback: nothing
683
698
  * was committed, the staged tmps are litter). Dotfiles are invisible to entry scans regardless. */
684
699
  private sweepStagedFiles;
@@ -1,4 +1,4 @@
1
- import { existsSync, linkSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, openSync, fsyncSync, closeSync, utimesSync } from "node:fs";
1
+ import { existsSync, fstatSync, linkSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, openSync, fsyncSync, closeSync, utimesSync } from "node:fs";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
3
  import { dirname, join, relative } from "node:path";
4
4
  import { jaccardDistance, termSet } from "../memory-vector.js";
@@ -8,7 +8,7 @@ import { ambiguousOriginRepresentation, committedDistilledOf, committedOriginOf,
8
8
  import { convertV1SidecarKeys } from "./delegation-settlement.js";
9
9
  import { layerObservationLookup, observedDivergenceNotice } from "./layer-observations.js";
10
10
  import { DEFAULT_MAX_ENTRY_DEPTH, MEMORY_INDEX_FILENAME, scanEntryFiles } from "./entry-scan.js";
11
- import { ControlPlaneCorruptError, CURSORS_FILE, QUARANTINE_DIR, CHALLENGES_FILE, IMPORT_RECEIPTS_DIR, LINEAGE_FILE, SESSION_POLLUTION_DIR, appendChallengeEvents, atomicWriteFileSync, quarantineAndTombstone, captureAndClearLineageForEntries, adoptRootScopeLayout, adoptCanonicalKeyedControlDir, deriveControlPlaneDir, ensureDirExists, enqueueMemoryAnnouncement, importLatchTxnId, importLineageCommitted, importSessionPollution, isContainedIn, permissionRemedyOf, withPermissionRemedy, lineageContributionsOfSession, listSessionPollution, readChallengeEvents, readLineageRecord, registerScope, registeredScopes, releaseImportLatch, layoutNeedsAdoption, resolveMemoryEngineRoot, scopeDirFor, scopeDirName, scopeRegistryShape, stageImportLatch, writeAllSync, } from "./layout.js";
11
+ import { ControlPlaneCorruptError, CURSORS_FILE, QUARANTINE_DIR, CHALLENGES_FILE, IMPORT_RECEIPTS_DIR, LINEAGE_FILE, SESSION_POLLUTION_DIR, appendChallengeEvents, atomicWriteFileSync, quarantineAndTombstone, captureAndClearLineageForEntries, adoptRootScopeLayout, adoptCanonicalKeyedControlDir, deriveControlPlaneDir, ensureDirExists, enqueueMemoryAnnouncement, importLatchTxnId, importLineageCommitted, importSessionPollution, isContainedIn, permissionRemedyOf, withPermissionRemedy, lineageContributionsOfSession, listSessionPollution, readChallengeEvents, readLineageRecord, registerScope, registeredScopes, releaseImportLatch, layoutNeedsAdoption, resolveMemoryEngineRoot, scopeDirFor, scopeDirName, scopeRegistryShape, stageImportLatch, landingPathOf, storeTargetEscape, TARGET_OUTSIDE_STORE, writeAllSync, } from "./layout.js";
12
12
  import { screenInboundEntries } from "./data-plane.js";
13
13
  import { scanMemoryFileName, scanMemoryWrite } from "./scan.js";
14
14
  import { thrownText } from "../thrown-value.js";
@@ -522,6 +522,7 @@ function probeProjection(absPath, id) {
522
522
  export class FileMemoryEngineBackend {
523
523
  directoryRoot;
524
524
  controlPlaneRoot;
525
+ physicalRoot;
525
526
  now;
526
527
  onIncident;
527
528
  ledger;
@@ -545,6 +546,7 @@ export class FileMemoryEngineBackend {
545
546
  adoptCanonicalKeyedControlDir(resolveMemoryEngineRoot(), dir, opts.onIncident);
546
547
  this.controlPlaneRoot = opts.controlDir ?? deriveControlPlaneDir(resolveMemoryEngineRoot(), dir);
547
548
  ensureDirExists(dir);
549
+ this.physicalRoot = landingPathOf(dir);
548
550
  ensureDirExists(this.controlPlaneRoot);
549
551
  this.recoverJournal({ unlocked: true });
550
552
  try {
@@ -577,6 +579,7 @@ export class FileMemoryEngineBackend {
577
579
  });
578
580
  }
579
581
  ensureLayout(ownerCandidate) {
582
+ this.assertRootIntact();
580
583
  if (!layoutNeedsAdoption(this.directoryRoot, this.controlPlaneRoot))
581
584
  return;
582
585
  const lock = this.tryAcquireTxnLockSync();
@@ -591,6 +594,7 @@ export class FileMemoryEngineBackend {
591
594
  }
592
595
  }
593
596
  async adoptRootScopeLayout(opts = {}) {
597
+ this.assertRootIntact();
594
598
  if (!layoutNeedsAdoption(this.directoryRoot, this.controlPlaneRoot))
595
599
  return;
596
600
  const lock = await this.acquireTxnLock();
@@ -1211,6 +1215,7 @@ export class FileMemoryEngineBackend {
1211
1215
  }
1212
1216
  }
1213
1217
  recoverJournal(fence) {
1218
+ this.assertRootIntact();
1214
1219
  if (fence.token === undefined) {
1215
1220
  if (this.txnInFlight())
1216
1221
  return;
@@ -1399,7 +1404,10 @@ export class FileMemoryEngineBackend {
1399
1404
  }
1400
1405
  }
1401
1406
  this.assertTxnLockOwnership(fenceToken);
1407
+ const replayAt = `the journal replay (journal kept at ${jp})`;
1408
+ this.refuseEscapingTargets(journal.ops, replayAt);
1402
1409
  for (const op of journal.ops) {
1410
+ this.refuseEscapingTargets([op], replayAt);
1403
1411
  try {
1404
1412
  if (op.kind === "write" && op.content !== undefined) {
1405
1413
  atomicWriteFileSync(op.target, op.content);
@@ -1457,6 +1465,24 @@ export class FileMemoryEngineBackend {
1457
1465
  throw new ControlPlaneCorruptError(`txn lock state could not be probed (${code ?? "io error"}) at ${this.txnLockDir()} — a probe failure is not "no writer" (fail-closed)`);
1458
1466
  }
1459
1467
  }
1468
+ refuseEscapingTargets(ops, at) {
1469
+ for (const op of ops) {
1470
+ if (op.kind !== "write" && op.kind !== "delete")
1471
+ continue;
1472
+ for (const path of op.staged === undefined ? [op.target] : [op.target, op.staged]) {
1473
+ const escape = storeTargetEscape(this.physicalRoot, path);
1474
+ if (escape === undefined)
1475
+ continue;
1476
+ throw erasureCodedError(`${TARGET_OUTSIDE_STORE}: ${at} refused a ${op.kind} of ${op.target}${path === op.target ? "" : ` (its staged file ${path})`} — ${escape}; not performed`, TARGET_OUTSIDE_STORE);
1477
+ }
1478
+ }
1479
+ }
1480
+ assertRootIntact() {
1481
+ const now = landingPathOf(this.directoryRoot);
1482
+ if (now === this.physicalRoot)
1483
+ return;
1484
+ throw erasureCodedError(`${TARGET_OUTSIDE_STORE}: the store root ${this.directoryRoot} now resolves to ${now}, not ${this.physicalRoot} where this backend opened it — refused; open a new backend on the directory you mean`, TARGET_OUTSIDE_STORE);
1485
+ }
1460
1486
  sweepStagedFiles() {
1461
1487
  const walk = (d, depth) => {
1462
1488
  let names;
@@ -2366,16 +2392,37 @@ export class FileMemoryEngineBackend {
2366
2392
  return report;
2367
2393
  }
2368
2394
  const txn = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
2395
+ const stagedFiles = [];
2396
+ const refuseUnstaging = (batch, at) => {
2397
+ try {
2398
+ this.refuseEscapingTargets(batch, at);
2399
+ }
2400
+ catch (err) {
2401
+ for (const f of stagedFiles) {
2402
+ try {
2403
+ const st = lstatSync(f.path);
2404
+ if (st.dev === f.dev && st.ino === f.ino)
2405
+ rmSync(f.path, { force: true });
2406
+ }
2407
+ catch {
2408
+ }
2409
+ }
2410
+ throw err;
2411
+ }
2412
+ };
2369
2413
  let n = 0;
2370
2414
  for (const op of ops) {
2371
2415
  if (op.kind !== "write" || op.content === undefined)
2372
2416
  continue;
2417
+ refuseUnstaging([op], "applyPatches (staging)");
2373
2418
  const staged = join(dirname(op.target), `.stage-${txn}-${n++}`);
2374
2419
  mkdirSync(dirname(op.target), { recursive: true });
2375
2420
  const fd = openSync(staged, "w", 0o600);
2376
2421
  try {
2377
2422
  writeAllSync(fd, op.content);
2378
2423
  fsyncSync(fd);
2424
+ const st = fstatSync(fd);
2425
+ stagedFiles.push({ path: staged, dev: st.dev, ino: st.ino });
2379
2426
  }
2380
2427
  finally {
2381
2428
  closeSync(fd);
@@ -2393,8 +2440,10 @@ export class FileMemoryEngineBackend {
2393
2440
  if (coherentTransfers.length > 0)
2394
2441
  this.precheckTransfersAppendable();
2395
2442
  const journal = { txn, ops, ledger2: ledgerEnvelope(rows, this.envelopeExtras), ...(coherentTransfers.length > 0 ? { transfers: coherentTransfers } : {}) };
2443
+ refuseUnstaging(ops, "applyPatches (commit point)");
2396
2444
  atomicWriteFileSync(join(this.controlPlaneRoot, JOURNAL_FILE), JSON.stringify(journal));
2397
2445
  for (const op of ops) {
2446
+ this.refuseEscapingTargets([op], "applyPatches (after the commit point; the journal is kept)");
2398
2447
  if (op.kind === "write" && op.staged) {
2399
2448
  renameSync(op.staged, op.target);
2400
2449
  }
@@ -2819,8 +2868,10 @@ export class FileMemoryEngineBackend {
2819
2868
  this.assertTxnLockOwnership(lock.token);
2820
2869
  this.precheckTransfersAppendable();
2821
2870
  const journal = { txn, ops, ledger2: ledgerEnvelope(rows, this.envelopeExtras), transfers };
2871
+ this.refuseEscapingTargets(ops, "eraseWithEvidence (commit point)");
2822
2872
  atomicWriteFileSync(join(this.controlPlaneRoot, JOURNAL_FILE), JSON.stringify(journal));
2823
2873
  for (const op of ops) {
2874
+ this.refuseEscapingTargets([op], "eraseWithEvidence (after the commit point; the journal is kept)");
2824
2875
  if (op.kind === "delete") {
2825
2876
  rmSync(op.target, { force: true });
2826
2877
  }
@@ -259,6 +259,28 @@ export declare function adoptRootScopeLayout(rootPath: string, controlDir: strin
259
259
  export declare const canonicalize: typeof physicalPathOfExisting;
260
260
  /** True iff `child` (canonicalized) is `root` or inside it. */
261
261
  export declare function isContainedIn(root: string, child: string): boolean;
262
+ /** The code an act on a store path is refused with when it would land outside the store ({@link storeTargetEscape}). */
263
+ export declare const TARGET_OUTSIDE_STORE = "memory.target_outside_store";
264
+ /** Where an act at `p` lands: the physical path of its normalized spelling (the spelling a child path
265
+ * joined under it carries), a MISSING tail rejoined under the deepest existing directory (what the act
266
+ * creates). Any other resolution fault (EACCES, ELOOP, ENOTDIR) is THROWN as itself: an act that
267
+ * cannot be placed is not performed — a failed resolution never implies the act would fail too
268
+ * (a link whose own mode refuses `realpath` is still followed by `unlink` and `rename`). */
269
+ export declare function landingPathOf(p: string): string;
270
+ /**
271
+ * Where a write, rename or delete at `target` would physically LAND, judged against the store's
272
+ * `physicalRoot`: `undefined` = inside, otherwise the reason. The target's directory, resolved through
273
+ * every link on its existing prefix (a missing tail is what the act creates, under the deepest existing
274
+ * directory), must be the root or inside it. `physicalRoot` is the root's physical form as the store
275
+ * was opened and is not re-resolved here: a store mounted through a link is judged by what the link
276
+ * resolved to, and a root spelling re-pointed after opening moves the acts, not the store. The final
277
+ * component is not judged: `rename` and `unlink` act on it and never through it, and the engine's
278
+ * writes open it no-follow — a link there is replaced, removed or refused, and what it points at is
279
+ * untouched. A lexical spelling under the root proves nothing: a directory under it replaced by a link
280
+ * keeps the spelling while the act follows the link out. A directory that cannot be resolved throws its
281
+ * fault ({@link landingPathOf}).
282
+ */
283
+ export declare function storeTargetEscape(physicalRoot: string, target: string): string | undefined;
262
284
  /** mkdir -p convenience used by the engine/backend (0o700 like the file stores). */
263
285
  export declare function ensureDirExists(dir: string): void;
264
286
  /** The one remedy every surfaced permission refusal carries — `undefined` unless `err` is a write
@@ -324,10 +346,13 @@ export interface QuarantineOutcome {
324
346
  * without native bindings. What this buys is the leaf variant, which is the cheap one; the ancestor
325
347
  * variant stays open and is recorded with the rest of the family rather than papered over.
326
348
  *
327
- * Throws like `writeFileSync`, including ELOOP when the final component IS a symlink. The write
328
- * itself goes through `writeFileSync` on the open descriptor, which writes every byte or throws —
329
- * a bare `writeSync` may short-write, and reporting a truncated restore as contained (after
330
- * `O_TRUNC` already destroyed the destination) is the failure this guard exists to prevent.
349
+ * Throws like `writeFileSync`, including ELOOP when the final component IS a symlink. A HARD link is the
350
+ * same tamper by other means — the file's bytes live under a second name, possibly outside the store, and
351
+ * an in-place write would change them there too — so an opened file with more than one name is refused
352
+ * the same way, before anything is truncated (the check reads the opened descriptor: no window). The
353
+ * write itself goes through `writeFileSync` on the open descriptor, which writes every byte or throws —
354
+ * a bare `writeSync` may short-write, and reporting a truncated restore as contained (after the
355
+ * truncation already destroyed the destination) is the failure this guard exists to prevent.
331
356
  */
332
357
  export declare function writeFileNoFollow(path: string, content: string): void;
333
358
  /** {@link writeFileNoFollow}'s NO-REPLACE twin: write `content` only if nothing is at `path`. `"written"`