@sema-agent/core 7.20.0 → 7.21.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 (58) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/dist/core/ask-origin.d.ts +95 -8
  3. package/dist/core/ask-origin.js +37 -5
  4. package/dist/core/engine-notice.d.ts +9 -0
  5. package/dist/core/gate-bounded-await.d.ts +6 -1
  6. package/dist/core/gate-bounded-await.js +13 -3
  7. package/dist/core/gate-fold.js +18 -5
  8. package/dist/core/gate-lanes.js +6 -12
  9. package/dist/core/governance-codes.d.ts +1 -1
  10. package/dist/core/governance-codes.js +2 -0
  11. package/dist/core/memory-engine/consolidation-driver.d.ts +3 -0
  12. package/dist/core/memory-engine/consolidation-driver.js +20 -1
  13. package/dist/core/memory-engine/consolidation.d.ts +7 -0
  14. package/dist/core/memory-engine/consolidation.js +4 -1
  15. package/dist/core/memory-engine/distiller.d.ts +26 -1
  16. package/dist/core/memory-engine/distiller.js +9 -2
  17. package/dist/core/memory-engine/tools.js +6 -0
  18. package/dist/core/path-identity.d.ts +13 -0
  19. package/dist/core/path-identity.js +14 -0
  20. package/dist/core/runner/env-capability-census.d.ts +3 -1
  21. package/dist/core/runner/env-capability-census.js +24 -7
  22. package/dist/core/runner/gate-exit.d.ts +7 -0
  23. package/dist/core/runner/inherited-ask-grants.d.ts +1 -0
  24. package/dist/core/runner/inherited-ask-grants.js +5 -2
  25. package/dist/core/runner/memory-consolidation-driver.d.ts +72 -2
  26. package/dist/core/runner/memory-consolidation-driver.js +57 -0
  27. package/dist/core/runner/prepare-config-doors.js +2 -0
  28. package/dist/core/runner/prepare-gate-stations.d.ts +6 -8
  29. package/dist/core/runner/prepare-gate-stations.js +1 -7
  30. package/dist/core/runner/prepare-policy-chain.d.ts +1 -0
  31. package/dist/core/runner/prepare-policy-chain.js +52 -20
  32. package/dist/core/runner/prepare-task.js +2 -2
  33. package/dist/core/runner/prepare-wiring-manifest.d.ts +8 -1
  34. package/dist/core/runner/prepare-wiring-manifest.js +7 -2
  35. package/dist/core/runner/run-settle-and-teardown.js +5 -0
  36. package/dist/core/runner/write-protection-station.d.ts +59 -0
  37. package/dist/core/runner/write-protection-station.js +32 -0
  38. package/dist/core/runner-deps.d.ts +21 -0
  39. package/dist/core/sensitive-path-policy.js +13 -0
  40. package/dist/core/wiring-manifest.d.ts +47 -0
  41. package/dist/core/wiring-manifest.js +2 -0
  42. package/dist/core/write-protect.d.ts +41 -3
  43. package/dist/core/write-protect.js +40 -14
  44. package/dist/engine/execution-env/node-execution-env.d.ts +10 -0
  45. package/dist/engine/execution-env/node-execution-env.js +29 -1
  46. package/dist/engine/harness/types.d.ts +10 -0
  47. package/dist/index.d.ts +2 -2
  48. package/dist/index.js +1 -1
  49. package/dist/internal/harness-types.d.ts +1 -1
  50. package/dist/tools/fs/bash-readonly-classifier.js +36 -7
  51. package/dist/tools/fs/read-deny.d.ts +4 -2
  52. package/dist/tools/fs/read-deny.js +5 -0
  53. package/dist/tools/fs/safety.d.ts +12 -0
  54. package/dist/tools/fs/safety.js +43 -38
  55. package/package.json +2 -1
  56. package/test/export-surface.snapshot.json +9 -1
  57. package/wire-changes/core@7.20.1.json +183 -0
  58. package/wire-changes/core@7.21.0.json +452 -0
@@ -152,7 +152,7 @@ export async function preparePolicyChain(input) {
152
152
  }, "mandate_unreconstructible", creq);
153
153
  const markerChannelFullDeny = (creq) => attestAskUnresolvableCause({ action: "deny", message: `no approver is reachable for the inherited approval of "${creq.toolName}" and the marker channel is at capacity — denied fail-closed` }, "approver_unavailable", creq);
154
154
  const inheritedAskGrants = new Map();
155
- const recordInheritedAskGrant = (toolCallId, approver, presented, waitMs) => {
155
+ const recordInheritedAskGrant = (toolCallId, approver, presented, waitMs, settled) => {
156
156
  if (typeof approver !== "function")
157
157
  return;
158
158
  if (presented === undefined)
@@ -162,7 +162,7 @@ export async function preparePolicyChain(input) {
162
162
  const argsJson = askGrantShapeOf(presented);
163
163
  if (argsJson === undefined)
164
164
  return;
165
- inheritedAskGrants.set(toolCallId, { approver, argsJson, presented, waitMs });
165
+ inheritedAskGrants.set(toolCallId, { approver, argsJson, presented, waitMs, settled });
166
166
  };
167
167
  const sandboxDeclared = isIsolated(executionEnv);
168
168
  const sandboxAdmissionArmed = (() => {
@@ -277,37 +277,51 @@ export async function preparePolicyChain(input) {
277
277
  cwdRef: handsCwdRef,
278
278
  deniesDirectoryRead: readDenyMatcher !== undefined ? (d) => readDenyMatcher.matchPath(d) !== null : undefined,
279
279
  });
280
- const recheckApprovedEdit = async (pol, onAskOf, creq, edit, csignal, ancestorDecider, ancestorTracker) => {
280
+ const recheckApprovedEdit = async (pol, onAskOf, creq, edit, approved, csignal, ancestorDecider, ancestorTracker) => {
281
281
  let editArgs = edit;
282
+ let settledBy = approved;
283
+ const vetoed = (d) => {
284
+ const out = { ...d };
285
+ carryEngineAttestations(d, out);
286
+ carryPrecedingSettlement(settledBy, out);
287
+ return out;
288
+ };
282
289
  for (let round = 0;; round++) {
283
290
  if (round >= 3) {
284
- return {
291
+ return vetoed({
285
292
  action: "deny",
286
293
  message: `the approval-edit chain for "${creq.toolName}" exceeded the re-approval cap (inherited parent policy) — denied fail-closed; re-submit the edited action directly`,
287
- };
294
+ });
288
295
  }
289
296
  let re;
290
297
  try {
291
298
  re = await pol.check({ ...creq, args: editArgs }, csignal ?? abortController.signal);
292
299
  }
293
300
  catch (err) {
294
- return {
301
+ return vetoed({
295
302
  action: "deny",
296
303
  message: `inherited parent policy re-check of the approved edit errored for "${creq.toolName}": ${err instanceof Error ? err.message : String(err)}`,
297
- };
304
+ });
298
305
  }
299
306
  if (re.action === "deny")
300
- return re;
307
+ return vetoed(re);
301
308
  if (re.action === "allow") {
302
- return { action: "allow", updatedInput: re.updatedInput !== undefined ? re.updatedInput : editArgs };
309
+ const out = { action: "allow", updatedInput: re.updatedInput !== undefined ? re.updatedInput : editArgs };
310
+ carryEngineAttestations(re, out);
311
+ carryPrecedingSettlement(settledBy, out);
312
+ return out;
303
313
  }
304
314
  if (re.updatedInput !== undefined)
305
315
  editArgs = re.updatedInput;
306
316
  const judged = await judgeInheritedClassifier({ autoMode: ancestorDecider !== undefined ? { decider: ancestorDecider, denialTracking: ancestorTracker } : undefined, ask: re, req: { ...creq, args: editArgs }, signal: csignal ?? abortController.signal, subject: "the approved edit", allowRewrite: editArgs });
307
- if (judged.kind === "allow")
308
- return judged.result;
317
+ if (judged.kind === "allow") {
318
+ const cleared = { ...judged.result };
319
+ carryEngineAttestations(judged.result, cleared);
320
+ carryPrecedingSettlement(settledBy, cleared);
321
+ return cleared;
322
+ }
309
323
  if (judged.kind === "deny")
310
- return judged.result;
324
+ return vetoed(judged.result);
311
325
  const { ask: editAsk, fallback: editFallback, mintedHere: editMintedHere } = judged;
312
326
  re = editAsk;
313
327
  const rr = await resolveAsk({
@@ -326,8 +340,12 @@ export async function preparePolicyChain(input) {
326
340
  settleDenialLimitFallback({ fallback: editFallback, mintedHere: editMintedHere, tracker: ancestorTracker, resolved: rr, headless: headlessDenyAtRecheck, stop: stopForDenialLimit, toolName: creq.toolName, toolCallId: creq.toolCallId });
327
341
  if (rr.action !== "allow")
328
342
  return rr;
329
- if (rr.updatedInput === undefined)
330
- return { action: "allow", updatedInput: editArgs };
343
+ settledBy = rr;
344
+ if (rr.updatedInput === undefined) {
345
+ const out = { action: "allow", updatedInput: editArgs };
346
+ carryPrecedingSettlement(rr, out);
347
+ return out;
348
+ }
331
349
  editArgs = rr.updatedInput;
332
350
  }
333
351
  };
@@ -404,9 +422,9 @@ export async function preparePolicyChain(input) {
404
422
  if (resolved.action !== "allow")
405
423
  return resolved;
406
424
  if (resolved.updatedInput !== undefined) {
407
- return recheckApprovedEdit(pc.policy, pc.onAsk, creq, resolved.updatedInput, csignal, pc.autoMode?.decider, pc.autoMode?.denialTracking);
425
+ return recheckApprovedEdit(pc.policy, pc.onAsk, creq, resolved.updatedInput, resolved, csignal, pc.autoMode?.decider, pc.autoMode?.denialTracking);
408
426
  }
409
- recordInheritedAskGrant(creq.toolCallId, pc.onAsk, resolved.presentedInput, askWaitMs);
427
+ recordInheritedAskGrant(creq.toolCallId, pc.onAsk, resolved.presentedInput, askWaitMs, resolved);
410
428
  return { action: "allow" };
411
429
  },
412
430
  }
@@ -473,12 +491,16 @@ export async function preparePolicyChain(input) {
473
491
  if (resolved.action !== "allow")
474
492
  return resolved;
475
493
  if (resolved.updatedInput !== undefined) {
476
- return recheckApprovedEdit(pc.policy, pc.onAsk, creq, resolved.updatedInput, csignal, pc.autoMode?.decider, pc.autoMode?.denialTracking);
494
+ return recheckApprovedEdit(pc.policy, pc.onAsk, creq, resolved.updatedInput, resolved, csignal, pc.autoMode?.decider, pc.autoMode?.denialTracking);
477
495
  }
478
496
  if (pc.preToolUse === undefined) {
479
- recordInheritedAskGrant(creq.toolCallId, pc.onAsk, resolved.presentedInput, askWaitMs);
497
+ recordInheritedAskGrant(creq.toolCallId, pc.onAsk, resolved.presentedInput, askWaitMs, resolved);
480
498
  }
481
- return decision.updatedInput !== undefined ? { ...resolved, updatedInput: decision.updatedInput } : resolved;
499
+ if (decision.updatedInput === undefined)
500
+ return resolved;
501
+ const approvedWithRewrite = { ...resolved, updatedInput: decision.updatedInput };
502
+ carryEngineAttestations(resolved, approvedWithRewrite);
503
+ return approvedWithRewrite;
482
504
  },
483
505
  });
484
506
  const rewriteCapableLayers = policy !== undefined || parentConstraintWrappers.length > 0;
@@ -540,6 +562,7 @@ export async function preparePolicyChain(input) {
540
562
  const recheckLayers = [...recheckPrefix, ...postEmitterSameInstanceAdapters];
541
563
  const finalReq = { ...req, args: decision.updatedInput };
542
564
  let asked;
565
+ const revalidated = {};
543
566
  try {
544
567
  for (const layer of recheckLayers) {
545
568
  const d = await layer.check(finalReq, signal);
@@ -558,13 +581,22 @@ export async function preparePolicyChain(input) {
558
581
  }
559
582
  if (d.action === "ask" && asked === undefined)
560
583
  asked = d;
584
+ carryEngineAttestations(d, revalidated);
561
585
  }
562
586
  }
563
587
  finally {
564
588
  rewriteEmitterIndex.delete(req.toolCallId);
565
589
  }
566
590
  if (asked !== undefined && decision.action === "allow") {
567
- return { ...asked, updatedInput: decision.updatedInput };
591
+ const surfaced = { ...asked, updatedInput: decision.updatedInput };
592
+ carryEngineAttestations(asked, surfaced);
593
+ return surfaced;
594
+ }
595
+ if (decision.action === "allow") {
596
+ const owned = { ...decision };
597
+ carryEngineAttestations(decision, owned);
598
+ carryEngineAttestations(revalidated, owned);
599
+ return owned;
568
600
  }
569
601
  return decision;
570
602
  },
@@ -690,7 +690,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
690
690
  }
691
691
  }
692
692
  const { hooks, hookTimeoutMs, preToolUseObservational, ownGatePreToolUse, basePolicyForResumeEdit, denyNarrowingPolicy, policyLayers, effectivePolicy, inheritedUnavailableAsks, inheritedAskGrants, askSourceIdentity, riskAxesOf, ruleOffersOf, permissionRuleLane, permissionRuleOrgLane, sandboxAdmissionArmed, sandboxBoundaryCapable, emitSandboxAdmitted, foldAskClasses, ancestorSandboxAdmissions } = await preparePolicyChain({ spec, deps, internals, sessionId, runId, hostTaskId, taskRootFinal, executionEnv, lockedPreflight, tools, harnessTools, toolEffects, egressTools, irreversibilityTier, axisExplicitNegatives, shellGatedTools, envHandToolNames, handsCwdRef, readDenyMatcher, questionToolMounted, runtimeCaps, inheritedAncestorRules, inheritedParentConstraints, seedInheritedGate, hasSkillManifest, skillScope, frozenOnAsk, hookEnvSource, notifyOwnHookCrash, autoModeDecider, stopForDenialLimit, delegation, abortController, now, ownSessionRulesRef });
693
- const { onAsk, preToolContexts, blockedToolCalls, gateOutcomes, batchHaltRef, blockedTracked, incompleteSuspendAdapter, durableSuspendInfraReady, resourceSuspendEligible, usageGovernance, durableApproval, wiringManifest, parkLaneArmed, gateMachineryActive, hookIdentity, hookEnvFace } = prepareWiringManifest({ spec, deps, internals, resume, sessionId, runId, hostTaskId, taskScope, hooks, preToolUseObservational, policyLayers, effectivePolicy, handsEnabled, mcp, irreversibleTools, egressTools, toolEffects, ownedEnv, executionEnv, offloadStore, onceLedger, usageWindows, envLifetimeSuspendAt, runtimeCaps, resolvedInteractionPosture, durableQuestionFace, liveQuestionFace, checkpointStore, sessions, modelGateManifest, autoModeArmReason, lockedPreflight, peerLaneRefs, peerLaneActive, harness, pausedRef, frozenOnAsk, liveInheritedGate, delegation, toolRoster, lspManagerWired, lspDiagnosticsLive: lspDiagnostics !== undefined });
693
+ const { onAsk, preToolContexts, blockedToolCalls, gateOutcomes, batchHaltRef, blockedTracked, incompleteSuspendAdapter, durableSuspendInfraReady, resourceSuspendEligible, usageGovernance, durableApproval, wiringManifest, writeProtectionCheck, parkLaneArmed, gateMachineryActive, hookIdentity, hookEnvFace } = prepareWiringManifest({ spec, deps, internals, resume, sessionId, runId, hostTaskId, taskScope, hooks, preToolUseObservational, policyLayers, effectivePolicy, handsEnabled, taskRootFinal, mcp, irreversibleTools, egressTools, toolEffects, ownedEnv, executionEnv, offloadStore, onceLedger, usageWindows, envLifetimeSuspendAt, runtimeCaps, resolvedInteractionPosture, durableQuestionFace, liveQuestionFace, checkpointStore, sessions, modelGateManifest, autoModeArmReason, lockedPreflight, peerLaneRefs, peerLaneActive, harness, pausedRef, frozenOnAsk, liveInheritedGate, delegation, toolRoster, lspManagerWired, lspDiagnosticsLive: lspDiagnostics !== undefined });
694
694
  if (fileHistoryBoundary !== undefined) {
695
695
  harness.on("tool_call", async () => {
696
696
  await fileHistoryBoundary.settle();
@@ -701,7 +701,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
701
701
  const { saga } = prepareSuspendSaga({ gateMachineryActive, abortController, harness, checkpointStore, deps, sessionId, hostTaskId, taskScope, liveSpendRef, resume, nestedStats, internals, activeTools, outputRef, readFileStateForCheckpoint, faceCheckpointSection, writeFaceCheckpointSection, reminderMark, handsCwdRef, worktreeSessionRef, undeliveredContinuationRef, inheritedParentConstraints, seedInheritedGate, inheritedAncestorRules, inheritedShellGate, autoModeIntent, inheritedAdmittedOrgScopes, ownOrgVerdictRef, orgGovernedProvenance, announcedListingsRef, gitStatusRef, hookIdentity, placementRootResolved, externalContentTargetActive, remoteEnvFailures, memoryEngineSession, suspendLoopRef, ownedEnv, incompleteSuspendAdapter });
702
702
  const { suspendForResource, suspendForPlatformLimit, suspendForReview } = prepareBoundaryParks({ saga, spec, checkpointStore, abortController, harness, session, sessions, sessionId, deps, priorLedger, maxSlices, maxSuspends, resourceTotal, priorSuspendCount, suspendChainBase, humanReviewRef, liveSpendRef, now, faceCheckpointState, f012CheckpointState, orgAdmissionCheckpointState, pausedRef, remoteEnvFailures, resourceSuspendEligible, durableSuspendInfraReady, incompleteSuspendAdapter });
703
703
  const { parkAsk } = prepareParkAsk({ askLane, saga, spec, deps, sessionId, checkpointStore, toolRosterDeltas: rosterSeat, parkLaneArmed, contentAskRoutable, liveQuestionFace, mountedQuestionTool, contentAskBindings, lateStrandedAnswers, discloseStrandedAnswers, onAsk, runtimeCaps, inheritedUnavailableAsks, basePolicyForResumeEdit, budgetSnapshot, handsCwdRef, advertisedWritableDirs, offloadStore, ownedEnv, incompleteSuspendAdapter, session, sessions, suspendChainBase, maxSuspends, remoteEnvFailures, shellGatedTools, effectiveShellGate, durableApproval, priorLedger, liveSpendRef, resourceTotal, faceCheckpointState, f012CheckpointState, orgAdmissionCheckpointState, ruleOffersOf, now, humanReviewRef, abortController, harness, pausedRef });
704
- prepareGateStations({ askLane, parkAsk, tools, toolRosterDeltas: rosterSeat, toolEffects, deps, toolCallGateArmedRef, effectivePolicy, hooks, egressTools, irreversibleTools, spec, complianceDenies, harness, blockedToolCalls, inheritedAskGrants, inheritedUnavailableAsks, foldAskClasses, ancestorSandboxAdmissions, preToolContexts, gateOutcomes, batchHaltRef, blockedTracked, hookIdentity, reminderMark, planModeRef, hostTaskId, sessionId, ownGatePreToolUse, hookTimeoutMs, handsCwdRef, hookEnvFace, handsEnv: handsEnabled ? executionEnv : undefined, taskRootFinal, irreversibilityTier, reversibilityProbes, abortController, shellGatedTools, autoModeDecider, autoModeDenialTracking, stopForDenialLimit, permissionRuleLane, permissionRuleOrgLane, questionToolMounted, sandboxAdmissionArmed, sandboxBoundaryCapable, emitSandboxAdmitted, delegation, notifyOwnHookCrash });
704
+ prepareGateStations({ askLane, parkAsk, tools, toolRosterDeltas: rosterSeat, toolEffects, deps, toolCallGateArmedRef, effectivePolicy, hooks, egressTools, irreversibleTools, spec, complianceDenies, harness, blockedToolCalls, inheritedAskGrants, inheritedUnavailableAsks, foldAskClasses, ancestorSandboxAdmissions, preToolContexts, gateOutcomes, batchHaltRef, blockedTracked, hookIdentity, reminderMark, planModeRef, hostTaskId, sessionId, ownGatePreToolUse, hookTimeoutMs, handsCwdRef, hookEnvFace, writeProtectionCheck, irreversibilityTier, reversibilityProbes, abortController, shellGatedTools, autoModeDecider, autoModeDenialTracking, stopForDenialLimit, permissionRuleLane, permissionRuleOrgLane, questionToolMounted, sandboxAdmissionArmed, sandboxBoundaryCapable, emitSandboxAdmitted, delegation, notifyOwnHookCrash });
705
705
  const { microCompact, charsPerToken } = prepareContextLane({ model, deps, sessionId, runId, hostTaskId, offloadStore, microCompactKnob, harness, batchHaltRef, gitStatusRef, requestLossyRef, trimPressureRef });
706
706
  const preparedHolder = {};
707
707
  const { cacheBreakDetector, cacheFingerprint, promptOverheadTokens, readTaskFile, normalizeAttachmentPath, isDedupStubResult, recentlyReadFiles, onCompactionApplied, detectExternalChanges, listBackgroundTasks, centerCompactionCandidate, effectiveReadFaceObserved, effectiveWriteFaceObserved, effectiveReadDenyObserved } = prepareTurnWiring({ deps, systemPromptSeat, harnessTools, fpRef, model, epochArtifactDigestForSnapshot, promptProfile, fableMitigations, systemBlocks, thinking, spec, sessionId, turnSnapshotRef, promptManifest, charsPerToken, handsEnabled, executionEnv, attachmentRootCanonical, additionalRootsCanonical, additionalReadRootsCanonical, readDenyMatcher, resolvedReadFace, handsCwdRef, readFileStateForCheckpoint, denyNarrowingPolicy, abortController, hostTaskId, taskScope, buildAssembleInputs, centerAdoptionRef, harnessRef, epochDeclaredSections, carrierReadFace, carrierWriteFace, readDenyAdditionsNormalized, preparedHolder });
@@ -28,6 +28,7 @@ import { type ToolResultStore } from "../tool-result-store.js";
28
28
  import { type OnAsk, type ToolPolicy } from "../tool-policy.js";
29
29
  import type { GateOutcome } from "../gate-outcome.js";
30
30
  import type { RunnerDeps, RuntimeCaps, TaskSpec, ToolEffect, ToolExecuteContext } from "../types.js";
31
+ import type { WriteProtectionCheck } from "../write-protect.js";
31
32
  import { type UsageWindow } from "../usage-window-store.js";
32
33
  import type { ToolRoster } from "../tool-roster.js";
33
34
  import { type AutoModeArmFact, type WiringManifest, type WiringManifestMcpEntry } from "../wiring-manifest.js";
@@ -41,7 +42,8 @@ export interface PrepareWiringManifestInput {
41
42
  /** borrowed-readonly — the deployment seats, whole: `onAsk` (the seat's deps half), `onError` (the warning), `executionEnv`
42
43
  * (the hook-env predicate), `checkpointStore` (durable infra), the manifest's dozen wiring facts (`backgroundAgentStore`,
43
44
  * `permissionRuleStore`, `permissionRuleSyncWired`, `onBackgroundChildEvent`, `lockedConfig`, `compliancePostureResolver`,
44
- * `memoryScopeAdmission`, `retentionPolicy`, `onElicit`), `onNotice`, and the usage-governance seats (`usageWindowStore`,
45
+ * `memoryScopeAdmission`, `retentionPolicy`, `onElicit`), `onNotice`, the write-protection judge's compile seats
46
+ * (`writeProtectedPaths`, `memoryEngineDir`), and the usage-governance seats (`usageWindowStore`,
45
47
  * `now`, `onUsageGovernance`…) the helper reads through the whole record. */
46
48
  deps: RunnerDeps;
47
49
  /** borrowed-readonly — the trusted spawn-side channel: `questionFaceStripped`, `isDelegatedChild`, `onTaskNotification`,
@@ -67,6 +69,8 @@ export interface PrepareWiringManifestInput {
67
69
  effectivePolicy: ToolPolicy | undefined;
68
70
  /** borrowed-readonly — whether an execution env is wired (the hand band exists at all). */
69
71
  handsEnabled: boolean;
72
+ /** borrowed-readonly — the resolved task root: the base a `base: "root"` write tool's relative spelling resolves against. */
73
+ taskRootFinal: string;
70
74
  /** borrowed-readonly — the materialized MCP handle: `tools` (the destructive-ungated scan) and `statuses` (the manifest face). */
71
75
  mcp: Pick<MaterializedMcp, "tools" | "statuses">;
72
76
  /** borrowed-readonly — the assembled safety vocabulary (the ungated scans and the manifest's armed cell). Read only. */
@@ -158,6 +162,9 @@ export interface PrepareWiringManifestResult {
158
162
  durableApproval: TaskSpec["durableApproval"];
159
163
  /** owned — this leg's EFFECTIVE wiring manifest (`Prepared.wiringManifest`). */
160
164
  wiringManifest: WiringManifest;
165
+ /** owned — the leg's write-protection gate input, or undefined when the table was emptied (no judge, no
166
+ * tighten mounted); handed to the gate station rather than compiled a second time there. */
167
+ writeProtectionCheck: WriteProtectionCheck | undefined;
161
168
  /** owned — THE park predicate as one named const (`parkLane.effective === true`). */
162
169
  parkLaneArmed: boolean;
163
170
  /** owned — THE gate-machinery activation predicate as one named const: a policy, a screening hook, an egress or
@@ -7,6 +7,8 @@ import { isVolatileOffloadStore } from "../tool-result-store.js";
7
7
  import { durableMandateSourceOf, isLiveApproverSeat } from "../tool-policy.js";
8
8
  import { deliverEngineNotice } from "../types.js";
9
9
  import { inlineUntrusted } from "../untrusted-text.js";
10
+ import { mountWriteProtection } from "./write-protection-station.js";
11
+ import { autoRunOnRecommendationEffective } from "./memory-consolidation-driver.js";
10
12
  import { GLOBAL_USAGE_KEY, usageRetryAfterMs, windowsGovernCost } from "../usage-window-store.js";
11
13
  import { LSP_CONTRACT_ID, countElicitOptIns, deriveLspSeam, deriveWiringManifest, resolveAskSeamForm, resolveDeclaredDurability, resolveElicitSeam, resolveQuestionSeam, resolveSubagentTranscriptTier } from "../wiring-manifest.js";
12
14
  import { DEFAULT_IRREVERSIBLE_SCOPE } from "./checkpoint-scope.js";
@@ -111,7 +113,7 @@ function announceDurableGateUnavailable(args) {
111
113
  });
112
114
  }
113
115
  export function prepareWiringManifest(input) {
114
- const { spec, deps, internals, resume, sessionId, runId, hostTaskId, taskScope, hooks, preToolUseObservational, policyLayers, effectivePolicy, mcp, irreversibleTools, egressTools, toolEffects, ownedEnv, executionEnv, offloadStore, onceLedger, usageWindows, envLifetimeSuspendAt, runtimeCaps, resolvedInteractionPosture, durableQuestionFace, liveQuestionFace, checkpointStore, sessions, modelGateManifest, autoModeArmReason, lockedPreflight, peerLaneRefs, peerLaneActive, harness, pausedRef, frozenOnAsk, liveInheritedGate, delegation, toolRoster, lspManagerWired, lspDiagnosticsLive } = input;
116
+ const { spec, deps, internals, resume, sessionId, runId, hostTaskId, taskScope, hooks, preToolUseObservational, policyLayers, effectivePolicy, handsEnabled, taskRootFinal, mcp, irreversibleTools, egressTools, toolEffects, ownedEnv, executionEnv, offloadStore, onceLedger, usageWindows, envLifetimeSuspendAt, runtimeCaps, resolvedInteractionPosture, durableQuestionFace, liveQuestionFace, checkpointStore, sessions, modelGateManifest, autoModeArmReason, lockedPreflight, peerLaneRefs, peerLaneActive, harness, pausedRef, frozenOnAsk, liveInheritedGate, delegation, toolRoster, lspManagerWired, lspDiagnosticsLive } = input;
115
117
  const onAsk = spec.onAsk ?? deps.onAsk;
116
118
  const handWriteTools = toolRoster.entries.filter((e) => e.mountedBy === "hands" && (e.effect === "write" || e.effect === "idempotent")).map((e) => e.name);
117
119
  const hasEffectAwareGate = Boolean(policyLayers.length > 0 || (hooks?.preToolUse !== undefined && !preToolUseObservational));
@@ -168,6 +170,7 @@ export function prepareWiringManifest(input) {
168
170
  const platformSuspendArmed = durableSuspendInfraReady && (envLifetimeSuspendAt !== undefined || usageGovernance !== undefined);
169
171
  const durableApproval = spec.durableApproval ??
170
172
  (runtimeCaps?.forceDurableGate ? { scope: spec.principal || DEFAULT_IRREVERSIBLE_SCOPE } : undefined);
173
+ const writeProtection = mountWriteProtection({ deps, env: handsEnabled ? executionEnv : undefined, rootPath: taskRootFinal, roster: toolRoster, sessionId, runId });
171
174
  const wiringQuestionSeam = resolveQuestionSeam(spec, deps);
172
175
  const wiringQuestionStripped = internals?.questionFaceStripped === true;
173
176
  const wiringAskSeam = resolveAskSeamForm(spec, deps);
@@ -204,6 +207,8 @@ export function prepareWiringManifest(input) {
204
207
  ...(modelGateManifest !== undefined ? { modelGate: modelGateManifest } : {}),
205
208
  autoMode: { ...autoModeArmReason },
206
209
  mcp: mcpManifestEntries(lockedPreflight.mcp, mcp.statuses),
210
+ ...(writeProtection !== undefined ? { writeProtectionTargetView: writeProtection.targetView } : {}),
211
+ autoConsolidationOnRecommendation: autoRunOnRecommendationEffective(deps),
207
212
  tools: toolRoster,
208
213
  hooks: wiredHookEntries(hooks, spec.hooks !== undefined ? "spec" : "deps", preToolUseObservational),
209
214
  ...(lspManagerWired ? { lsp: deriveLspSeam(toolRoster.entries.some((e) => e.contract.contractId === LSP_CONTRACT_ID), lspDiagnosticsLive) } : {}),
@@ -242,5 +247,5 @@ export function prepareWiringManifest(input) {
242
247
  });
243
248
  const hookContextConsumerWired = hooks?.preToolUse !== undefined || hooks?.postToolUse !== undefined || hooks?.postToolUseFailure !== undefined;
244
249
  const hookEnvFace = hookContextConsumerWired && (ownedEnv ?? deps.executionEnv) != null ? createHookEnvCapabilities(executionEnv) : undefined;
245
- return { onAsk, preToolContexts, blockedToolCalls, gateOutcomes, batchHaltRef, blockedTracked, incompleteSuspendAdapter, durableSuspendInfraReady, resourceSuspendEligible, usageGovernance, platformSuspendArmed, durableApproval, wiringManifest, parkLaneArmed, gateMachineryActive, hookIdentity, hookEnvFace };
250
+ return { onAsk, preToolContexts, blockedToolCalls, gateOutcomes, batchHaltRef, blockedTracked, incompleteSuspendAdapter, durableSuspendInfraReady, resourceSuspendEligible, usageGovernance, platformSuspendArmed, durableApproval, wiringManifest, writeProtectionCheck: writeProtection?.check, parkLaneArmed, gateMachineryActive, hookIdentity, hookEnvFace };
246
251
  }
@@ -18,6 +18,7 @@ import { resumeDecisionWasNegative } from "./decide-continuation.js";
18
18
  import { drainForwardedFramesBeforeDone, forwardsSubagentEvents } from "./prepare-run-refs.js";
19
19
  import { generatePromptSuggestions } from "./prompt-suggestions.js";
20
20
  import { settleTeardownLeg } from "./teardown-bounded.js";
21
+ import { autoRunConsolidationFromRecommendation, autoRunOnRecommendationEffective, scopeHasUnconsumedRecommendation } from "./memory-consolidation-driver.js";
21
22
  import { amendTerminal, terminalProjection } from "./terminal-projection.js";
22
23
  export async function runSettleAndTeardown(input) {
23
24
  const { spec, queue, prepared, resume, internals, rs, stats, ident, parentToolCallId, subagentName, loopLatch, timeout, ownCommittedTailRef, emitDelegationLifecycle, taskIdRef, manualCompactRef, drainManualCompact, final, threw, abortedLive, userInterruptedLive, answerSettled, strandedHumanAnswers, reasoningResolution, comp, setResult, onSuggestions, onSuspend, runner, sessions } = input;
@@ -240,6 +241,10 @@ export async function runSettleAndTeardown(input) {
240
241
  if (prepared.memoryEngineSession) {
241
242
  const mes = prepared.memoryEngineSession;
242
243
  await settleTeardownLeg(() => mes.harvest(), "memoryEngineSession.harvest (settle leg)", (e) => runner.deps.onError?.(e, { phase: "config", sessionId: prepared.sessionId }));
244
+ const scope = mes.handle.writeScope;
245
+ if (scope !== null && autoRunOnRecommendationEffective(runner.deps) && scopeHasUnconsumedRecommendation(mes.engine, scope)) {
246
+ void autoRunConsolidationFromRecommendation({ deps: runner.deps, engine: mes.engine, scope, sessionId: prepared.sessionId, onNotice: runner.deps.onNotice, onError: runner.deps.onError });
247
+ }
243
248
  }
244
249
  emitTrace(rs.telemetry.tracer, () => ({
245
250
  kind: "task.end",
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The leg's write-protection MOUNT — one compile, and the loud outlet for the reading it could not get.
3
+ *
4
+ * The always-on write-protection judge re-judges a table-missing spelling on its RESOLVED real target,
5
+ * which needs a filesystem to resolve in. A deployment that wires no execution environment (a tool-only
6
+ * or MCP-only host) hands it none, so the spelling becomes the whole judgment and a covered write that
7
+ * reaches a protected row only through an alias is cleared. That verdict is the judge's documented
8
+ * absence law and is deliberately unchanged here: the announcement dialect applies (announce, do not
9
+ * refuse) — refusing every covered write instead would break exactly the deployments this shape exists
10
+ * for, and the reference implementation falls back to the same spelling-only set when resolution is
11
+ * unavailable. What this seat removes is the SILENCE.
12
+ *
13
+ * Two readers, one compile: the gate station mounts `check`, and the leg's wiring manifest reports
14
+ * `targetView`. Compiling twice would be two judges free to disagree about the seats they were handed.
15
+ */
16
+ import type { ExecutionEnv } from "../../internal/harness.js";
17
+ import type { RunnerDeps } from "../runner-deps.js";
18
+ import type { ToolRoster } from "../tool-roster.js";
19
+ import { type WriteProtectionMount } from "../write-protect.js";
20
+ /**
21
+ * Compile the leg's write-protection judge and announce a missing target view.
22
+ *
23
+ * Returns the mount, or `undefined` when the deployment emptied the table (`[]`): no judge, no tighten
24
+ * mounted at all, and nothing announced — an explicit opt-out is not a degrade. A garbage table refuses
25
+ * prepare loudly from here, as it did from the gate station before, so an unusable table cannot lie
26
+ * dormant on a write-tool-less task.
27
+ *
28
+ * The notice fires exactly once per prepared leg and only when a judge with NO target view sits beside at
29
+ * least one tool whose writes it covers. Coverage runs the judge's OWN per-call test ({@link
30
+ * protectivePathTargetOf}) rather than a copy of it, over every name a row DISPATCHES under — its own and
31
+ * its aliases, because the gate hands the judge the name the call was made with. A row that declares a
32
+ * path target is covered by that declaration under every name; a row that declares none is covered under
33
+ * whichever of its names the catalog gives a non-read slot to. Both halves are load-bearing: a tool named
34
+ * `Write` that declares nothing, and a tool aliased to `Write` that declares nothing, are each judged by
35
+ * spelling alone on a hands-less leg, and each would otherwise go unnamed by the very line that exists to
36
+ * name them. Reported by the ROW's own name — what the deployment mounted.
37
+ *
38
+ * SNAPSHOT BOUNDARY, deliberate and disclosed: the roster read here is the LEG-START one, the same one
39
+ * the wiring manifest reports and for the same reason — this is one announcement per prepared leg, not a
40
+ * subscription. A run that gains its FIRST covered write tool after prepare (a mid-run roster delta) is
41
+ * judged by the same spelling-only judge and is not announced; the leg's `writeProtection.targetView`
42
+ * still reports the judge's reading correctly, because the judge never gains an environment mid-run —
43
+ * only the covered SET can widen.
44
+ */
45
+ export declare function mountWriteProtection(input: {
46
+ /** borrowed-readonly — the deployment seats: the table (`writeProtectedPaths`; absent ⇒ the default
47
+ * table, `[]` ⇒ opted out), the data root the memory library is materialized under
48
+ * (`memoryEngineDir`; absent ⇒ the resolver's env/default chain) and the operator sink. */
49
+ deps: Pick<RunnerDeps, "writeProtectedPaths" | "memoryEngineDir" | "onNotice">;
50
+ /** The HANDS' environment, or `undefined` with no hands — the driver's own predicate, not a second
51
+ * reading of the env seats, so the judge sees exactly the environment the write tools do. */
52
+ env: ExecutionEnv | undefined;
53
+ /** The base a `base: "root"` write tool's relative spelling resolves against. */
54
+ rootPath: string;
55
+ /** The leg's minted roster — what actually MOUNTED. */
56
+ roster: ToolRoster;
57
+ sessionId: string;
58
+ runId: string;
59
+ }): WriteProtectionMount | undefined;
@@ -0,0 +1,32 @@
1
+ import { deliverEngineNotice } from "../engine-notice.js";
2
+ import { protectivePathTargetOf } from "../tool-registry.js";
3
+ import { createWriteProtectionCheck } from "../write-protect.js";
4
+ export function mountWriteProtection(input) {
5
+ const mount = createWriteProtectionCheck(input.deps.writeProtectedPaths, {
6
+ ...(input.deps.memoryEngineDir !== undefined ? { dataRoot: input.deps.memoryEngineDir } : {}),
7
+ ...(input.env !== undefined ? { env: input.env } : {}),
8
+ rootPath: input.rootPath,
9
+ });
10
+ if (mount === undefined || mount.targetView !== "spelling-only")
11
+ return mount;
12
+ const covered = input.roster.entries
13
+ .filter((e) => [e.name, ...e.aliases].some((name) => {
14
+ const target = protectivePathTargetOf({ toolName: name, args: {}, face: e.pathTarget !== undefined ? { pathTarget: e.pathTarget } : {} });
15
+ return target !== undefined && target.access !== "read";
16
+ }))
17
+ .map((e) => e.name)
18
+ .sort();
19
+ if (covered.length === 0)
20
+ return mount;
21
+ deliverEngineNotice(input.deps.onNotice, {
22
+ code: "config.write_protection_target_view_absent",
23
+ message: `This run mounted path-confinable write tools [${covered.join(", ")}] while its write-protection judge has NO execution environment, ` +
24
+ `so the judge compares path SPELLINGS against its table and nothing else. A write whose spelling misses the table but whose real target ` +
25
+ `resolves onto a protected path — through a symlink, a bind mount or a ".." walk — is cleared, and cleared silently: the resolved-target ` +
26
+ `half of the judgment needs a filesystem to resolve in. Nothing about this run changed. Wire an execution environment (RunnerDeps.executionEnv) ` +
27
+ `to get the target view back, or accept the spelling-only floor knowingly — the leg's wiring manifest reports which one is in force at ` +
28
+ `writeProtection.targetView.`,
29
+ detail: { tools: covered, sessionId: input.sessionId, runId: input.runId },
30
+ });
31
+ return mount;
32
+ }
@@ -391,6 +391,27 @@ export interface RunnerDeps {
391
391
  maxCycles?: number;
392
392
  mintRepairBudget?: number;
393
393
  };
394
+ /**
395
+ * The advisory-to-execution BRIDGE: when `true`, a terminal harvest that crosses
396
+ * the consolidation threshold (the same edge that mints `memory.consolidation_recommended`) fires
397
+ * ONE host consolidation run for the recommended scope at the post-task tail — fire-and-forget,
398
+ * result-first, never on the harvest tail itself. Explicit `false` = OFF (the deployment's own off
399
+ * switch; it beats the default). ABSENT = the named default `AUTO_RUN_ON_RECOMMENDATION_DEFAULT`
400
+ * (`memory-consolidation-driver.ts`, read through `autoRunOnRecommendationEffective` — the ONE
401
+ * resolver), applied ONLY where a run could actually happen (the protocol switch wired AND a model
402
+ * seat resolvable): a default-on value never arms a deployment that wired no consolidation.
403
+ *
404
+ * DEPLOYMENT seat ONLY (no TaskSpec twin), the same law as {@link RunnerDeps.memoryConsolidation} /
405
+ * {@link RunnerDeps.memoryConsolidationDriver}: arming this is an EGRESS decision — memory content crosses
406
+ * to the consolidation model automatically after every recommending task, never a task author's call.
407
+ *
408
+ * `true` with nothing to run (no {@link RunnerDeps.memoryConsolidation}, or no resolvable driver model
409
+ * seat), or a non-boolean, is refused at prepare (`config.auto_consolidation`) — never folded to off.
410
+ *
411
+ * Reported on the effective wiring manifest as `WiringManifest.autoConsolidation`, PRESENT IFF armed
412
+ * (absent and fingerprint-identical when off).
413
+ */
414
+ autoRunOnRecommendation?: boolean;
394
415
  /**
395
416
  * design/199 件A — the DEPLOYMENT's read-face declaration
396
417
  * ({@link import("../tools/fs/containment-face.js").ContainmentFace}; see {@link TaskSpec.readFace} for the
@@ -4,6 +4,7 @@ import { canonicalizeTarget, expandHomeTilde, isAbsoluteForFamily, isWinFormPath
4
4
  import { compileSegmentPattern, matchSegmentPatterns, READ_FACE_BUILTIN_DENY_TABLE } from "../tools/fs/read-deny.js";
5
5
  import { isProtectedWrite, protectivePathTargetOf } from "./tool-registry.js";
6
6
  import { pathTargetBaseOf } from "./effective-path-target.js";
7
+ import { symlinkChainHops } from "./path-identity.js";
7
8
  export function agentConfigDenyPatterns() {
8
9
  return READ_FACE_BUILTIN_DENY_TABLE.filter((row) => row.tier === "agent-config").map((row) => row.pattern);
9
10
  }
@@ -147,6 +148,18 @@ export function createSensitivePathPolicy(opts) {
147
148
  decisionReason: "safety",
148
149
  };
149
150
  }
151
+ if (relative === null) {
152
+ for (const hop of await symlinkChainHops(opts.env, path, signal, pathFamilyOf({ root: opts.rootPath, cwd: req.cwd ?? opts.env.cwd }), pathTargetBaseOf(protective, { root: opts.rootPath, cwd: req.cwd }))) {
153
+ const viaHop = matchSensitive(hop, compiled, false);
154
+ if (viaHop) {
155
+ return {
156
+ action: "deny",
157
+ message: `write to "${path}" is blocked: its symlink chain passes through the guarded sensitive path "${viaHop}"`,
158
+ decisionReason: "safety",
159
+ };
160
+ }
161
+ }
162
+ }
150
163
  return { action: "allow" };
151
164
  },
152
165
  };
@@ -24,6 +24,7 @@ import type { RunnerDeps, TaskSpec } from "./types.js";
24
24
  import { type StoreDurability } from "./checkpoint-store.js";
25
25
  import type { WiringManifestHookEntry } from "./hook-seats.js";
26
26
  import type { McpDelivered, McpFailureKind } from "./mcp-failure.js";
27
+ import type { WriteProtectionTargetView } from "./write-protect.js";
27
28
  export type { StoreDurability };
28
29
  /** The manifest-face projection of {@link StoreDurability}: `"declared_durable"` (the name says it —
29
30
  * a DECLARATION relayed, not a verification) vs `"process_local"` (declared so, or fail-closed
@@ -319,6 +320,46 @@ export interface WiringManifest {
319
320
  mounted: boolean;
320
321
  lane?: LspLane;
321
322
  };
323
+ /**
324
+ * EFFECTIVE half only, PRESENT IFF this leg compiled a write-protection judge — a deployment that
325
+ * replaced the table with `[]` opted the layer out entirely, and reports nothing here rather than a
326
+ * judge-shaped word about a judge that does not exist (absence = "no judge on this leg", which is a
327
+ * different fact from either value below, and strictly weaker than both).
328
+ *
329
+ * `targetView` is the JUDGE'S OWN word (`WriteProtectionMatcher.targetView`, relayed verbatim), and it
330
+ * is how an operator tells a hands-less deployment's write floor from a hands-ful one without inspecting
331
+ * the engine's seats — the same fact `config.write_protection_target_view_absent` announces once per run
332
+ * when a covered write tool is mounted beside a `"spelling-only"` judge.
333
+ *
334
+ * IN the fingerprint below (unlike `tools`/`lsp`/`hooks`): which view the judge got is an ASSEMBLY fact
335
+ * — it follows the table seat and whether the deployment wired hands at all — so legs of one assembly
336
+ * agree on it, which is exactly the equality the fingerprint advertises.
337
+ *
338
+ * @contract write_protection.target_view_disclosed — WHICH reading of a write's target the leg's
339
+ * write-protection judge holds is DISCLOSED, never inferred: the judge self-reports one of two words at
340
+ * its compile (`"target"` — it holds an execution environment and re-judges a table-missing spelling on
341
+ * its resolved real target; `"spelling-only"` — it holds none, so the spelling is the whole judgment and
342
+ * an alias onto a protected path is cleared), the leg's wiring manifest relays that word verbatim, and a
343
+ * `"spelling-only"` judge with a covered write tool mounted also announces
344
+ * `config.write_protection_target_view_absent` exactly once per run; the disclosure changes no verdict —
345
+ * the absence law it makes readable is the judge's own and stands unaltered.
346
+ */
347
+ writeProtection?: {
348
+ targetView: WriteProtectionTargetView;
349
+ };
350
+ /**
351
+ * EFFECTIVE half only. PRESENT IFF auto-consolidation is EFFECTIVELY armed on this
352
+ * deployment (`autoRunOnRecommendationEffective`: an explicit `true`, or an absent seat under the
353
+ * named default `AUTO_RUN_ON_RECOMMENDATION_DEFAULT` where both consolidation seats are wired): a
354
+ * terminal harvest that crosses the threshold auto-runs one host consolidation — memory content
355
+ * auto-egresses to the configured model. ABSENT (and the manifest, including `configFingerprint`,
356
+ * byte-identical) when not armed — an explicit `false`, or a default that does not arm it.
357
+ * IN the fingerprint (an assembly fact, the same class as {@link WiringManifest.writeProtection}); always
358
+ * `{ onRecommendation: true }` when present — the prepare door refuses an armed-but-unrunnable state.
359
+ */
360
+ autoConsolidation?: {
361
+ onRecommendation: true;
362
+ };
322
363
  /** EFFECTIVE half only — a short, non-sensitive fingerprint (sha256 prefix over the canonical
323
364
  * JSON of this manifest's own resolved facts; every field here is an enum/boolean/count, no
324
365
  * secrets) so an operator can correlate legs that ran under the same resolved assembly. */
@@ -397,6 +438,12 @@ export interface WiringFacts {
397
438
  mounted: boolean;
398
439
  lane?: LspLane;
399
440
  };
441
+ /** Effective half only — the compiled write-protection judge's OWN target-view word
442
+ * ({@link WiringManifest.writeProtection}); absent ⇒ this leg compiled no judge (an emptied table). */
443
+ writeProtectionTargetView?: WriteProtectionTargetView;
444
+ /** Effective half only — `RunnerDeps.autoRunOnRecommendation === true`
445
+ * ({@link WiringManifest.autoConsolidation}); false ⇒ no section (byte-identical manifest). */
446
+ autoConsolidationOnRecommendation?: boolean;
400
447
  }
401
448
  /** Named view of the deps seats the static half reads (a `Pick` of the real {@link RunnerDeps} —
402
449
  * single-source shapes, no parallel hand-copied interface). */
@@ -141,6 +141,8 @@ export function deriveWiringManifest(facts) {
141
141
  ...(facts.half === "effective" && facts.tools !== undefined ? { tools: facts.tools } : {}),
142
142
  ...(facts.half === "effective" && facts.hooks !== undefined && facts.hooks.length > 0 ? { hooks: facts.hooks.map((h) => ({ ...h })) } : {}),
143
143
  ...(facts.half === "effective" && facts.lsp !== undefined ? { lsp: { ...facts.lsp } } : {}),
144
+ ...(facts.half === "effective" && facts.writeProtectionTargetView !== undefined ? { writeProtection: { targetView: facts.writeProtectionTargetView } } : {}),
145
+ ...(facts.half === "effective" && facts.autoConsolidationOnRecommendation === true ? { autoConsolidation: { onRecommendation: true } } : {}),
144
146
  };
145
147
  if (facts.half === "effective") {
146
148
  const { leg: _leg, mcp: _mcp, tools: _tools, lsp: _lsp, hooks: _hooks, ...assembly } = manifest;
@@ -51,7 +51,8 @@ export declare function resolveWriteProtectedTable(entries?: readonly WriteProte
51
51
  * The judge's answer for ONE covered write ({@link WriteProtectionMatcher.judge}):
52
52
  * · `clear` — no row on either view: the write is not this layer's business;
53
53
  * · `hit` — a row matched; `via` says which view named it: `"spelling"` (the anchored spelling, no
54
- * filesystem consulted) or `"target"` (the resolved real target, the spelling being innocent);
54
+ * filesystem consulted), `"chain"` (a spelling the leaf symlink chain passes through, judged as a
55
+ * spelling) or `"target"` (the resolved real target, the spelling being innocent);
55
56
  * · `unresolvable` — the real target could not be resolved (an unfollowable symlink, an errored probe, a
56
57
  * namespace spelling the tree refuses); `cause` is the canonicalizer's own sentence and the consumer
57
58
  * REFUSES, an unjudged target never being vouched.
@@ -61,7 +62,7 @@ export type WriteProtectionVerdict = {
61
62
  } | {
62
63
  readonly kind: "hit";
63
64
  readonly hit: WriteProtectedHit;
64
- readonly via: "spelling" | "target";
65
+ readonly via: "spelling" | "chain" | "target";
65
66
  } | {
66
67
  readonly kind: "unresolvable";
67
68
  readonly cause: string;
@@ -78,10 +79,24 @@ export interface WriteProtectionJudgeContext {
78
79
  /** Bounds the filesystem probes of the target view. */
79
80
  readonly signal?: AbortSignal;
80
81
  }
82
+ /**
83
+ * WHICH reading of a write's TARGET a compiled judge has — its own word, and the vocabulary the wiring
84
+ * manifest discloses it with. `"target"`: the judge holds an execution environment, so a spelling that
85
+ * misses the table is judged a SECOND time on its resolved real target (an alias, a symlink, a `..` walk
86
+ * that lands on a protected row is seen). `"spelling-only"`: no environment, so the spelling IS the whole
87
+ * judgment and a path reaching a row only through an alias is cleared — the module header's absence law,
88
+ * unchanged by this word, which only makes it READABLE. Never derived by a reader from the judge's seats.
89
+ */
90
+ export declare const WRITE_PROTECTION_TARGET_VIEWS: readonly ["spelling-only", "target"];
91
+ /** One of {@link WRITE_PROTECTION_TARGET_VIEWS}. */
92
+ export type WriteProtectionTargetView = (typeof WRITE_PROTECTION_TARGET_VIEWS)[number];
81
93
  /** The compiled judge over one resolved table. `matchPath` is the SPELLING view alone (literal folded
82
94
  * comparisons, no filesystem access — the admin preview face); `judge` is the whole judgment (see the
83
95
  * module header for the two views). */
84
96
  export interface WriteProtectionMatcher {
97
+ /** WHICH reading of a target this judge HAS — minted at compile and read verbatim by everyone who needs
98
+ * the fact; a reader re-deriving it from the seats is a second opinion, free to drift. */
99
+ readonly targetView: WriteProtectionTargetView;
85
100
  /** The resolved rows this judge was compiled from (canonical names — the disclosure face). */
86
101
  readonly rows: readonly WriteProtectedRow[];
87
102
  /** Judge ONE path SPELLING. Returns the first matching row (basename rows first, then segment,
@@ -147,6 +162,17 @@ export declare function compileWriteProtection(entries?: readonly WriteProtected
147
162
  * · `clear` ⇒ `undefined`: the surviving decision stands.
148
163
  */
149
164
  export declare function writeProtectionTightenDecision(toolName: string, verdict: WriteProtectionVerdict): PermissionResult | undefined;
165
+ /**
166
+ * @contract write_protection.verdict_is_the_mandate — the judge's two non-clear verdicts are the two facts
167
+ * a standing allow rule may not retire, and `clear` is neither: a MATCHED row is the mandate a live decider
168
+ * may still clear, a target it could not RESOLVE is the mandate no automatic lane may. These two predicates
169
+ * are the ONE reading of a verdict as either fact, beside the decision mint, so the fold records what the
170
+ * judge said and never re-derives "was this write protection?" from the decision it got back.
171
+ */
172
+ export declare function writeProtectionMandated(verdict: WriteProtectionVerdict): boolean;
173
+ /** The second half of {@link writeProtectionMandated}'s contract: the judge could not name this target
174
+ * (the arm that owns the ACTION denies on the same verdict). */
175
+ export declare function writeProtectionUnresolved(verdict: WriteProtectionVerdict): boolean;
150
176
  /**
151
177
  * The ENGINE-FILLED gate input (`ToolGateInput.writeProtectionCheck`). Returns `null` SYNCHRONOUSLY when the
152
178
  * call is not this layer's business — not a covered write, or no resolvable target string — and otherwise
@@ -154,6 +180,18 @@ export declare function writeProtectionTightenDecision(toolName: string, verdict
154
180
  * a call the judge does not cover must not cost the fold a suspension point.
155
181
  */
156
182
  export type WriteProtectionCheck = (toolName: string, args: unknown, face?: ToolCallLike["face"], cwd?: string, signal?: AbortSignal) => Promise<WriteProtectionVerdict> | null;
183
+ /**
184
+ * ONE leg's write-protection mount: the gate input and the judge's own {@link WriteProtectionTargetView},
185
+ * handed back together so the two readers of this compile — the gate station that mounts the check and the
186
+ * disclosure face that reports which reading it got — read ONE compile. `undefined` in place of the whole
187
+ * record is "no judge" (an emptied table), a different fact from a judge whose target view is absent.
188
+ */
189
+ export interface WriteProtectionMount {
190
+ /** The gate input (`ToolGateInput.writeProtectionCheck`). */
191
+ readonly check: WriteProtectionCheck;
192
+ /** The compiled judge's own word for the reading it has of a write's target. */
193
+ readonly targetView: WriteProtectionTargetView;
194
+ }
157
195
  /**
158
196
  * Build the ENGINE-FILLED gate input (`ToolGateInput.writeProtectionCheck`): covered tools are the
159
197
  * path-confinable write set (Write/Edit/NotebookEdit — the shared spelling), the judged target is
@@ -167,4 +205,4 @@ export type WriteProtectionCheck = (toolName: string, args: unknown, face?: Tool
167
205
  * Returns `undefined` when the resolved table is empty — nothing to judge, mount nothing.
168
206
  * `opts` are the judge's own seats, passed straight through ({@link compileWriteProtection}).
169
207
  */
170
- export declare function createWriteProtectionCheck(entries?: readonly WriteProtectedEntry[], opts?: WriteProtectionCompileOptions): WriteProtectionCheck | undefined;
208
+ export declare function createWriteProtectionCheck(entries?: readonly WriteProtectedEntry[], opts?: WriteProtectionCompileOptions): WriteProtectionMount | undefined;