@sema-agent/core 5.23.0 → 5.25.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 (35) hide show
  1. package/CHANGELOG.md +192 -1
  2. package/dist/core/checkpoint-store.d.ts +84 -10
  3. package/dist/core/checkpoint-store.js +3 -1
  4. package/dist/core/hooks.d.ts +18 -5
  5. package/dist/core/hooks.js +28 -4
  6. package/dist/core/memory-engine/engine.js +18 -3
  7. package/dist/core/permission-rule-org.d.ts +9 -0
  8. package/dist/core/permission-rule-org.js +12 -5
  9. package/dist/core/permission-rule-store.d.ts +25 -14
  10. package/dist/core/permission-rule-store.js +5 -1
  11. package/dist/core/runner/prepare-task.d.ts +2 -0
  12. package/dist/core/runner/prepare-task.js +40 -9
  13. package/dist/core/runner/runtask.js +49 -6
  14. package/dist/core/runner/session-file-state-replay.js +3 -0
  15. package/dist/core/tool-policy.d.ts +11 -0
  16. package/dist/core/tool-policy.js +17 -15
  17. package/dist/core/tool-result-store.d.ts +8 -0
  18. package/dist/core/tool-result-store.js +77 -3
  19. package/dist/core/types.d.ts +7 -5
  20. package/dist/index.d.ts +13 -7
  21. package/dist/index.js +2 -2
  22. package/dist/stores/file/adoption/adopt.d.ts +23 -3
  23. package/dist/stores/file/adoption/adopt.js +4 -8
  24. package/dist/stores/file/adoption/marker.d.ts +41 -18
  25. package/dist/stores/file/adoption/marker.js +14 -7
  26. package/dist/stores/file/permission-rule-store.d.ts +15 -1
  27. package/dist/stores/file/permission-rule-store.js +4 -1
  28. package/dist/stores/file/session-policy-store.d.ts +11 -1
  29. package/dist/stores/file/session-policy-store.js +8 -3
  30. package/dist/stores/file/task-list-store.d.ts +15 -1
  31. package/dist/stores/file/task-list-store.js +2 -2
  32. package/dist/tools/fs/fs-bash.js +7 -4
  33. package/dist/tools/fs/fs-shared.js +23 -7
  34. package/dist/tools/monitor.js +3 -3
  35. package/package.json +1 -1
@@ -11,22 +11,29 @@ export const ORG_ADJUDICATION_TIMEOUT_MS = 15_000;
11
11
  export function settleOrgVerdictWithin(p, fallback, opts) {
12
12
  return new Promise((resolve) => {
13
13
  let settled = false;
14
- const finish = (v) => {
14
+ const finish = (v, cause) => {
15
15
  if (settled)
16
16
  return;
17
17
  settled = true;
18
18
  clearTimeout(timer);
19
19
  opts.signal?.removeEventListener("abort", onAbort);
20
+ if (cause !== undefined) {
21
+ try {
22
+ opts.onFallback?.(cause);
23
+ }
24
+ catch {
25
+ }
26
+ }
20
27
  resolve(v);
21
28
  };
22
- const onAbort = () => finish(fallback);
23
- const timer = setTimeout(() => finish(fallback), opts.timeoutMs);
29
+ const onAbort = () => finish(fallback, "aborted");
30
+ const timer = setTimeout(() => finish(fallback, "timeout"), opts.timeoutMs);
24
31
  if (opts.signal?.aborted === true) {
25
- finish(fallback);
32
+ finish(fallback, "aborted");
26
33
  return;
27
34
  }
28
35
  opts.signal?.addEventListener("abort", onAbort);
29
- p.then(finish, () => finish(fallback));
36
+ p.then((v) => finish(v), () => finish(fallback));
30
37
  });
31
38
  }
32
39
  export const ORG_RULE_DECISION_REASON = "org_rule";
@@ -1,11 +1,14 @@
1
1
  /**
2
- * design/179 §8 — the persisted allow-rule store seam, its core-private write face, and the removal entry.
2
+ * design/179 §8 — the persisted allow-rule store seam, its backend write face, and the removal entry.
3
3
  *
4
4
  * ## Two faces, deliberately unequal
5
5
  *
6
- * A deployment sees a READ face: `list()`, scoped to one verified principal by a factory. There is no
7
- * exported write API at all the only way a rule enters the store through the engine is the core-private
8
- * redemption path (`permission-rule-consent.ts`), which requires an approved durable approval record.
6
+ * A deployment sees a READ face: `list()`, scoped to one verified principal by a factory. The write face
7
+ * is a BACKEND CONTRACT (ruled 2026-08-10: exported so an out-of-repo store twin builds against the same
8
+ * definitions instead of mirroring them), not a host write API the only way a rule enters the store
9
+ * through the ENGINE is the redemption path (`permission-rule-consent.ts`), which requires an approved
10
+ * durable approval record; that invariant lives in the engine's wiring and is pinned by the
11
+ * writer-caller registry test, not in type visibility.
9
12
  * `expectedRev` is concurrency control, not authorization, so "hold a put API and skip the ticket" is not
10
13
  * a shape that exists here rather than a rule someone must remember.
11
14
  *
@@ -156,15 +159,19 @@ export interface RuleSyncJoinDelta {
156
159
  * What a write may say. Authorization discriminates on the DELTA SHAPE, not on a full snapshot: only the
157
160
  * add arm can introduce a dot, and the delete arm carries a tombstone and no adds. A backend additionally
158
161
  * REFUSES at runtime any delete that would introduce a new add dot — structure and runtime check together,
159
- * so "pick the delete arm and smuggle an add" is neither expressible nor accepted. The `sync-join` arm is
160
- * constructible only by core (the writer is never exported), and every inbound record inside it passes the
161
- * single validator again AT THE BACKEND the fifth door of design/179 §4's validator list closes here,
162
- * not at the calling layer.
162
+ * so "pick the delete arm and smuggle an add" is neither expressible nor accepted. The `sync-join` arm's
163
+ * safety does not rest on hiding the type (ruled 2026-08-10: the backend contract IS exported for
164
+ * out-of-repo store twins) it rests on every inbound record inside it passing the single validator
165
+ * again AT THE BACKEND: the fifth door of design/179 §4's validator list closes here, not at the
166
+ * calling layer, and closes identically for every caller.
163
167
  */
164
168
  export type RuleWriteDelta = RuleAddDelta | RuleDeleteDelta | RuleSyncJoinDelta;
165
169
  /**
166
- * The core-private write face. Deliberately absent from the package's public exports: a host cannot hold
167
- * one, so no API-level path to the store bypasses the consent protocol.
170
+ * The backend write face. Exported as part of the BACKEND CONTRACT (ruled 2026-08-10) so an
171
+ * out-of-repo store implementation hangs the same face the file backend does, instead of mirroring the
172
+ * types. The consent boundary is unchanged by the export: the ENGINE reaches a writer only through the
173
+ * consent protocol's redemption (and the sync client's join) — a property of the engine's wiring —
174
+ * and a deployment always owned its own storage bytes, so type visibility grants nothing new.
168
175
  */
169
176
  export interface PermissionRuleWriter {
170
177
  /** Mint the next dot for this replica. Counters need only be unique and monotonic, so a dot minted for
@@ -198,11 +205,11 @@ export interface RawRuleSyncState {
198
205
  quarantined?: QuarantinedRuleAdd[];
199
206
  }
200
207
  /**
201
- * The internal handle a writable backend hangs its write face on. Not exported from the package index
202
- * that omission IS the boundary described in the module doc.
208
+ * The handle a writable backend hangs its write face on (exported with the backend contract, ruled 2026-08-10;
209
+ * the consent boundary lives in the engine's wiring, not in this key's visibility).
203
210
  */
204
211
  export declare const PERMISSION_RULE_WRITER = "__semaPermissionRuleWriter";
205
- /** A store that also carries the core-private write face. */
212
+ /** A store that also carries the backend write face. */
206
213
  export interface WritablePermissionRuleStore extends PermissionRuleStore {
207
214
  readonly [PERMISSION_RULE_WRITER]: PermissionRuleWriter;
208
215
  }
@@ -419,7 +426,11 @@ export type RemoveResult =
419
426
  export declare function removePersistedRule(opts: {
420
427
  rule: string;
421
428
  scope: RuleScope;
422
- principal: string;
429
+ /** Whose bucket. A bare string stays the principal shorthand (unchanged callers); the structural
430
+ * {@link RuleOwner} form adds the local-owner bucket (downstream request, 2026-08-10 — design/182 §4.5's
431
+ * `forLocalOwner()` face existed, but removal could not name it, so a local-owner rule was
432
+ * unrevokable through this entry). Same observed-remove/add-wins/stillLive semantics either way. */
433
+ principal: string | RuleOwner;
423
434
  provider: PermissionRuleStoreProvider;
424
435
  }): Promise<RemoveResult>;
425
436
  export declare function errText(err: unknown): string;
@@ -327,7 +327,11 @@ export async function ruleStoreChecksum(payload) {
327
327
  }
328
328
  const REMOVE_MAX_ATTEMPTS = 8;
329
329
  export async function removePersistedRule(opts) {
330
- const store = opts.provider.forPrincipal(opts.principal);
330
+ const owner = typeof opts.principal === "string" ? { kind: "principal", principal: opts.principal } : opts.principal;
331
+ if (owner.kind === "local-owner" && opts.provider.forLocalOwner === undefined) {
332
+ return { status: "failed", error: "this provider has no local-owner bucket (forLocalOwner is not implemented) — a local-owner rule cannot be removed through it" };
333
+ }
334
+ const store = owner.kind === "local-owner" ? opts.provider.forLocalOwner() : opts.provider.forPrincipal(owner.principal);
331
335
  const writer = writerOf(store);
332
336
  if (writer === undefined) {
333
337
  return { status: "failed", error: "the resolved permission-rule store has no write face — rules cannot be removed through it" };
@@ -28,6 +28,8 @@ import { type WiringManifest } from "../wiring-manifest.js";
28
28
  import type { ActiveWorktreeSession, AgentMessage, AgentTool, ExecutionEnv } from "../../internal/harness.js";
29
29
  import type { NestedUsageAccum, RunnerDeps, TaskEvent, TaskLimits, TaskResult, TaskSpec, ToolActivity, ToolEffect } from "../types.js";
30
30
  import type { RepairBundle } from "../../agents/repair-loop.js";
31
+ /** Test seam (mirrors `__resetBashTimeoutAnnouncements`): never called by production code. */
32
+ export declare function __resetMaterializeEnvAnnouncements(): void;
31
33
  /**
32
34
  * design/164 — validate `TaskSpec.limits` at the door and return it unchanged.
33
35
  *
@@ -85,10 +85,14 @@ import { resolveWorkflowSizeGuideline } from "../../orchestration/workflow-size-
85
85
  import { createLspTool, gitCheckIgnoreFilter, resolveLspPath } from "../lsp.js";
86
86
  import { resolveKey } from "../../tools/fs/safety.js";
87
87
  import { wholeFileRecordsFromTranscript } from "./session-file-state-replay.js";
88
- import { BINDING_CHECKPOINT_VERSION, mintCheckpointToken, ORG_ADMISSION_CHECKPOINT_VERSION, F012_CHECKPOINT_VERSION, RESOURCE_CHECKPOINT_VERSION, TOKEN_CHECKPOINT_VERSION, buildRiskDescriptor, debitLedger, encodeAtFidelity, remainingBudgetMicroUsd, resolveCheckpointStore, resolveDeclaredFidelity, samePlainValue, } from "../checkpoint-store.js";
88
+ import { BINDING_CHECKPOINT_VERSION, mintCheckpointToken, ORG_ADMISSION_CHECKPOINT_VERSION, F012_CHECKPOINT_VERSION, REAL_APPROVAL_CHECKPOINT_VERSION, RESOURCE_CHECKPOINT_VERSION, TOKEN_CHECKPOINT_VERSION, buildRiskDescriptor, debitLedger, encodeAtFidelity, remainingBudgetMicroUsd, resolveCheckpointStore, resolveDeclaredFidelity, samePlainValue, } from "../checkpoint-store.js";
89
89
  import { boundInputHashOf } from "../canonical-json.js";
90
90
  import { countElicitOptIns, deriveAskEffective, deriveWiringManifest, resolveAskSeamForm, resolveDeclaredDurability, resolveElicitSeam, resolveQuestionSeam } from "../wiring-manifest.js";
91
91
  import { GLOBAL_USAGE_KEY, resolveUsageWindows, usageRetryAfterMs } from "../usage-window-store.js";
92
+ const announcedMaterializeEnv = new Set();
93
+ export function __resetMaterializeEnvAnnouncements() {
94
+ announcedMaterializeEnv.clear();
95
+ }
92
96
  const RECONCILE_MAX_RETRIES = 3;
93
97
  const DEFAULT_MAX_SUSPENDS = 5;
94
98
  const TASK_LIMIT_KEY_DICT = {
@@ -2572,6 +2576,16 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2572
2576
  toolsDeltaRef = { pending: [], pendingRemoved: [], pendingReadded: [], pendingFailed: failedMcpServers };
2573
2577
  }
2574
2578
  const listingRideRef = {};
2579
+ {
2580
+ const raw = process.env.SEMA_TOOL_MATERIALIZE_STRATEGY;
2581
+ if (raw !== undefined && raw !== "swap" && raw !== "static" && deferred.size === 0) {
2582
+ const line = `SEMA_TOOL_MATERIALIZE_STRATEGY=${JSON.stringify(raw)} is not "swap" or "static" — inert on this task (no deferred tools), but a deferring task WITHOUT an explicit spec strategy will refuse to prepare under it (an explicit legal spec outranks and discards it, loudly). Fix or unset the flag.`;
2583
+ if (!announcedMaterializeEnv.has(line)) {
2584
+ announcedMaterializeEnv.add(line);
2585
+ console.warn(line);
2586
+ }
2587
+ }
2588
+ }
2575
2589
  if (deferred.size > 0) {
2576
2590
  if (deferred.has(TOOL_SEARCH_NAME) || tools.some((t) => t.name === TOOL_SEARCH_NAME)) {
2577
2591
  const e = new Error(`Tool name "${TOOL_SEARCH_NAME}" is reserved when deferred tools are present.`);
@@ -2579,12 +2593,21 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2579
2593
  throw e;
2580
2594
  }
2581
2595
  const registry = buildDeferredRegistry(deferred, tools);
2582
- const envStrategy = process.env.SEMA_TOOL_MATERIALIZE_STRATEGY;
2583
- if (envStrategy !== undefined && envStrategy !== "swap" && envStrategy !== "static") {
2584
- const e = new Error(`SEMA_TOOL_MATERIALIZE_STRATEGY must be "swap" or "static" (got ${JSON.stringify(envStrategy)}).`);
2596
+ const rawEnvStrategy = process.env.SEMA_TOOL_MATERIALIZE_STRATEGY;
2597
+ const envStrategyInvalid = rawEnvStrategy !== undefined && rawEnvStrategy !== "swap" && rawEnvStrategy !== "static";
2598
+ if (envStrategyInvalid && spec.toolMaterializeStrategy === undefined) {
2599
+ const e = new Error(`SEMA_TOOL_MATERIALIZE_STRATEGY must be "swap" or "static" (got ${JSON.stringify(rawEnvStrategy)}).`);
2585
2600
  e.code = "config.tool_materialize_invalid";
2586
2601
  throw e;
2587
2602
  }
2603
+ if (envStrategyInvalid) {
2604
+ const line = `SEMA_TOOL_MATERIALIZE_STRATEGY=${JSON.stringify(rawEnvStrategy)} was ignored — not "swap" or "static", and the task spec pins toolMaterializeStrategy=${JSON.stringify(spec.toolMaterializeStrategy)} which outranks it. Fix or unset the env flag.`;
2605
+ if (!announcedMaterializeEnv.has(line)) {
2606
+ announcedMaterializeEnv.add(line);
2607
+ console.warn(line);
2608
+ }
2609
+ }
2610
+ const envStrategy = envStrategyInvalid ? undefined : rawEnvStrategy;
2588
2611
  const requestedStrategy = spec.toolMaterializeStrategy ?? envStrategy ?? "swap";
2589
2612
  const laneDegrade = requestedStrategy === "static" && spec.deferSelfResolve === false;
2590
2613
  const materializeStatic = requestedStrategy === "static" && !laneDegrade;
@@ -3273,6 +3296,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3273
3296
  ...askSourceIdentity(),
3274
3297
  ...riskAxesOf(creq.toolName),
3275
3298
  ...(re.requiresRealApproval === true ? { requiresRealApproval: true } : {}),
3299
+ ...(re.persistedRuleShadowed !== undefined ? { persistedRuleShadowed: re.persistedRuleShadowed } : {}),
3276
3300
  }, onAskOf, csignal ?? abortController.signal);
3277
3301
  if (rr.action !== "allow")
3278
3302
  return rr;
@@ -3357,6 +3381,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3357
3381
  ...askSourceIdentity(),
3358
3382
  ...riskAxesOf(creq.toolName),
3359
3383
  ...(first.action === "ask" && first.requiresRealApproval === true ? { requiresRealApproval: true } : {}),
3384
+ ...(first.action === "ask" && first.persistedRuleShadowed !== undefined ? { persistedRuleShadowed: first.persistedRuleShadowed } : {}),
3360
3385
  }, pc.onAsk, csignal ?? abortController.signal);
3361
3386
  const askWaitMs = Math.max(0, now() - askT0);
3362
3387
  if (resolved.action === "deny" && resolved.approverUnavailable === true) {
@@ -3443,6 +3468,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3443
3468
  ...askSourceIdentity(),
3444
3469
  ...riskAxesOf(creq.toolName),
3445
3470
  ...(decision.action === "ask" && decision.requiresRealApproval === true ? { requiresRealApproval: true } : {}),
3471
+ ...(decision.action === "ask" && decision.persistedRuleShadowed !== undefined ? { persistedRuleShadowed: decision.persistedRuleShadowed } : {}),
3446
3472
  }, pc.onAsk, csignal ?? abortController.signal);
3447
3473
  const askWaitMs = Math.max(0, now() - askT0);
3448
3474
  if (resolved.action === "deny" && resolved.approverUnavailable === true) {
@@ -3755,6 +3781,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3755
3781
  ...askSourceIdentity(),
3756
3782
  ...riskAxesOf(req.toolName),
3757
3783
  ...(decision.action === "ask" && decision.requiresRealApproval === true ? { requiresRealApproval: true } : {}),
3784
+ ...(decision.action === "ask" && decision.persistedRuleShadowed !== undefined ? { persistedRuleShadowed: decision.persistedRuleShadowed } : {}),
3758
3785
  }, onAsk, abortController.signal);
3759
3786
  const waitMs = Math.max(0, now() - t0);
3760
3787
  if (resolved.approverUnavailable !== true) {
@@ -4236,7 +4263,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
4236
4263
  }
4237
4264
  };
4238
4265
  const suspendAsk = parkLaneArmed && checkpointStore !== undefined
4239
- ? async (req, postHookArgs, safety, liveFaceUnavailable) => {
4266
+ ? async (req, postHookArgs, safety, liveFaceUnavailable, realApproval, shadowedRule) => {
4240
4267
  const syncFirstEligible = req.toolName === ASK_USER_QUESTION_TOOL_NAME ? contentAskRoutable(req.toolCallId) : isLiveApproverSeat(onAsk);
4241
4268
  if (syncFirstEligible &&
4242
4269
  runtimeCaps?.forceDurableGate !== true &&
@@ -4341,16 +4368,20 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
4341
4368
  toolName: req.toolName,
4342
4369
  args: parkedArgs,
4343
4370
  safety,
4371
+ ...(shadowedRule !== undefined ? { shadowedRule } : {}),
4344
4372
  shellGated: (req.toolName === "Bash" && shellGatedBash) || (req.toolName === "Monitor" && shellGatedMonitor),
4345
4373
  ...(effectiveShellGate !== "off" ? { shellGateDoctrine: effectiveShellGate } : {}),
4346
4374
  });
4347
4375
  gate =
4348
- safety !== undefined
4376
+ safety !== undefined || realApproval !== undefined
4349
4377
  ? {
4350
4378
  kind: "irreversible_ask",
4351
- reason: `human approval required before safety-tightened tool "${req.toolName}"`,
4379
+ reason: safety !== undefined
4380
+ ? `human approval required before safety-tightened tool "${req.toolName}"`
4381
+ : `real human approval required for tool "${req.toolName}" (non-budgetable: ${realApproval.origin})`,
4352
4382
  toolName: req.toolName,
4353
- safetyAxis: safety,
4383
+ ...(safety !== undefined ? { safetyAxis: safety } : {}),
4384
+ ...(realApproval !== undefined ? { realApproval } : {}),
4354
4385
  riskDescriptor,
4355
4386
  }
4356
4387
  : durableApproval
@@ -4373,7 +4404,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
4373
4404
  const mintedAt = Date.now();
4374
4405
  cp = {
4375
4406
  token,
4376
- version: f012CheckpointState() ? F012_CHECKPOINT_VERSION : orgAdmissionCheckpointState() ? ORG_ADMISSION_CHECKPOINT_VERSION : approvalLedger.totalTokens !== undefined ? TOKEN_CHECKPOINT_VERSION : BINDING_CHECKPOINT_VERSION,
4407
+ version: realApproval !== undefined ? REAL_APPROVAL_CHECKPOINT_VERSION : f012CheckpointState() ? F012_CHECKPOINT_VERSION : orgAdmissionCheckpointState() ? ORG_ADMISSION_CHECKPOINT_VERSION : approvalLedger.totalTokens !== undefined ? TOKEN_CHECKPOINT_VERSION : BINDING_CHECKPOINT_VERSION,
4377
4408
  scope,
4378
4409
  sessionId,
4379
4410
  leafId,
@@ -1,6 +1,6 @@
1
1
  import { AgentHarness, DEFAULT_COMPACTION_SETTINGS, uuidv7 } from "../../internal/harness.js";
2
2
  import { snapshotActorAssertion } from "../../internal/llm.js";
3
- import { CheckpointError, BINDING_CHECKPOINT_VERSION, checkpointVersionOf, MAX_SUPPORTED_CHECKPOINT_VERSION, remainingBudgetMicroUsd, readPendingSteerQueue, remainingTokens, validatePendingSteer, winnerFromOutcome, } from "../checkpoint-store.js";
3
+ import { CheckpointError, BINDING_CHECKPOINT_VERSION, checkpointVersionOf, F012_CHECKPOINT_VERSION, MAX_SUPPORTED_CHECKPOINT_VERSION, REAL_APPROVAL_CHECKPOINT_VERSION, remainingBudgetMicroUsd, readPendingSteerQueue, remainingTokens, validatePendingSteer, winnerFromOutcome, } from "../checkpoint-store.js";
4
4
  import { engineVersion } from "../version.js";
5
5
  import { CONFIG_CATALOG_VERSION, declarationReasons, resolveEffectiveConfig } from "../../config/catalog.js";
6
6
  import { eventDefaultOn } from "../../prompt-assembly/event-registry.js";
@@ -71,6 +71,7 @@ const STOP_HOOK_BLOCK_CAP = 8;
71
71
  const COMPACTION_REGROWTH_FACTOR = 1.5;
72
72
  const COMPACTION_FREED_EPSILON = 256;
73
73
  const BATCH_RESPONSE_MAX_CHARS = 500;
74
+ const ORG_DISCLOSURE_MAX_CHARS = 600;
74
75
  function batchResponseDigest(result) {
75
76
  const content = result !== null && typeof result === "object" ? result.content : result;
76
77
  if (content === undefined || content === null)
@@ -3702,7 +3703,22 @@ export class Runner {
3702
3703
  }
3703
3704
  }
3704
3705
  if (checkpointVersionOf(cp) > MAX_SUPPORTED_CHECKPOINT_VERSION) {
3705
- throw new CheckpointError("checkpoint.unsupported_version", `checkpoint version ${checkpointVersionOf(cp)} is newer than this worker supports (max ${MAX_SUPPORTED_CHECKPOINT_VERSION})`);
3706
+ throw new CheckpointError("checkpoint.unsupported_version", `checkpoint version ${checkpointVersionOf(cp)} is newer than this worker supports (max ${MAX_SUPPORTED_CHECKPOINT_VERSION})`, { reason: "version_newer" });
3707
+ }
3708
+ const preCasGateBit = cp.gate.kind === "irreversible_ask" ? cp.gate.realApproval : undefined;
3709
+ const preCasBitWellFormed = preCasGateBit !== undefined &&
3710
+ typeof preCasGateBit === "object" &&
3711
+ (preCasGateBit.origin === "org_rule" ||
3712
+ preCasGateBit.origin === "org_unavailable" ||
3713
+ preCasGateBit.origin === "policy");
3714
+ if (checkpointVersionOf(cp) >= REAL_APPROVAL_CHECKPOINT_VERSION ? !preCasBitWellFormed : preCasGateBit !== undefined) {
3715
+ throw new CheckpointError("checkpoint.invalid_outcome", checkpointVersionOf(cp) >= REAL_APPROVAL_CHECKPOINT_VERSION
3716
+ ? `a v${checkpointVersionOf(cp)} checkpoint must carry a well-formed non-budgetable realApproval gate bit (origin org_rule/org_unavailable/policy) on an irreversible_ask gate — this row does not; refusing to resume a damaged real-approval row (corruption / downgrade guard), the checkpoint stays pending`
3717
+ : `a v${checkpointVersionOf(cp)} checkpoint carries a realApproval gate bit no release of that version ever minted — refusing to honor a fabricated origin (corruption / forgery guard), the checkpoint stays pending`, { reason: checkpointVersionOf(cp) >= REAL_APPROVAL_CHECKPOINT_VERSION ? "real_approval_damaged" : "real_approval_forged" });
3718
+ }
3719
+ if ((preCasGateBit?.origin === "org_rule" || preCasGateBit?.origin === "org_unavailable") &&
3720
+ this.deps.permissionRuleOrg === undefined) {
3721
+ throw new CheckpointError("checkpoint.unsupported_version", `this checkpoint's approval was minted under organization governance (${preCasGateBit.origin}) and this worker has no org adjudication wiring (permissionRuleOrg) — a governed approval may only be redeemed where governance can be enforced; the checkpoint stays pending, resume it on an org-wired worker`, { reason: "governed_unwired" });
3706
3722
  }
3707
3723
  const retiredWalltimeTotal = cp.resourceLedger?.totalWalltimeSec;
3708
3724
  if (retiredWalltimeTotal !== undefined) {
@@ -3714,7 +3730,7 @@ export class Runner {
3714
3730
  throw new CheckpointError("checkpoint.resume_aborted", "the resume was handed an ALREADY-ABORTED signal — refusing to consume the approval on a leg that cannot run it (the checkpoint stays pending and is resumable with a live signal)");
3715
3731
  }
3716
3732
  if (cp.state.workspaceHandle !== undefined && this.deps.executionEnvFactory === undefined) {
3717
- throw new CheckpointError("checkpoint.unsupported_version", "checkpoint has a remote workspaceHandle but no RunnerDeps.executionEnvFactory is wired to rebuild the env");
3733
+ throw new CheckpointError("checkpoint.unsupported_version", "checkpoint has a remote workspaceHandle but no RunnerDeps.executionEnvFactory is wired to rebuild the env", { reason: "env_factory_missing" });
3718
3734
  }
3719
3735
  if (cp.state.inheritedGate?.requiresParentConstraint === true) {
3720
3736
  const supplied = internals?.inheritedGate?.parentConstraints?.length ?? 0;
@@ -3730,6 +3746,10 @@ export class Runner {
3730
3746
  "re-supply would run the resumed leg under a different ancestor chain than it suspended with; " +
3731
3747
  "rejected pre-CAS (the checkpoint stays pending) — re-resume with the full original chain");
3732
3748
  }
3749
+ if (checkpointVersionOf(cp) >= F012_CHECKPOINT_VERSION &&
3750
+ (cp.state.inheritedGate.constraintChain === undefined || cp.state.inheritedGate.constraintDigest === undefined)) {
3751
+ throw new CheckpointError("checkpoint.invalid_outcome", `a v${checkpointVersionOf(cp)} checkpoint that requires parent constraints must carry BOTH the frozen constraintChain and its constraintDigest (they are minted in one write with the version stamp) — this row carries neither or only one; refusing to fall back to the count-only contract on a damaged row (corruption / downgrade guard), the checkpoint stays pending`, { reason: "constraint_chain_missing" });
3752
+ }
3733
3753
  const expectedDigest = cp.state.inheritedGate.constraintDigest;
3734
3754
  if (expectedDigest !== undefined) {
3735
3755
  const persistedChain = cp.state.inheritedGate.constraintChain;
@@ -4009,18 +4029,41 @@ export class Runner {
4009
4029
  return;
4010
4030
  }
4011
4031
  }
4032
+ const gateRealApproval = resume.cp.gate.kind === "irreversible_ask" ? resume.cp.gate.realApproval : undefined;
4033
+ const gateOrgGoverned = gateRealApproval?.origin === "org_rule" || gateRealApproval?.origin === "org_unavailable";
4034
+ if (gateOrgGoverned && prepared.permissionRuleOrg === undefined) {
4035
+ const unwiredDenial = formatHookFeedback(`The approved tool call "${pendingAction.toolName}" was not executed: its approval was minted under organization governance (${gateRealApproval.origin}), and this worker has no org adjudication wiring — a governed approval may only be redeemed where governance can be enforced. This approval is spent; re-issue the call on an org-wired worker.`);
4036
+ emitEnd(true, { content: unwiredDenial });
4037
+ const eid = await prepared.session.appendMessage(toolResultMsg(pendingAction.toolCallId, pendingAction.toolName, unwiredDenial, true));
4038
+ emitCommitted(eid, "toolResult", pendingAction.toolCallId);
4039
+ return;
4040
+ }
4012
4041
  if (prepared.permissionRuleOrg !== undefined) {
4013
4042
  const orgVerdict = prepared.permissionRuleOrg
4014
4043
  .adjudicate({ toolName: pendingAction.toolName, args: resolvedArgs, toolCallId: pendingAction.toolCallId })
4015
4044
  .catch(() => ({ status: "unavailable", disclosures: ["the org adjudication face threw on resume"] }));
4016
- const org = await settleOrgVerdictWithin(orgVerdict, { status: "unavailable", disclosures: [`the org adjudication face did not answer within ${ORG_ADJUDICATION_TIMEOUT_MS}ms (or the task ended first)`] }, { signal: prepared.abortController.signal, timeoutMs: ORG_ADJUDICATION_TIMEOUT_MS });
4045
+ let orgWaitCancelled = false;
4046
+ const org = await settleOrgVerdictWithin(orgVerdict, { status: "unavailable", disclosures: [`the org adjudication face did not answer within ${ORG_ADJUDICATION_TIMEOUT_MS}ms (or the task ended first)`] }, {
4047
+ signal: prepared.abortController.signal,
4048
+ timeoutMs: ORG_ADJUDICATION_TIMEOUT_MS,
4049
+ onFallback: (cause) => {
4050
+ orgWaitCancelled = cause === "aborted";
4051
+ },
4052
+ });
4017
4053
  const blocked = org.status === "unavailable"
4018
- ? "this deployment is org-governed and cannot currently adjudicate against an organization policy snapshot"
4054
+ ? gateRealApproval?.origin === "org_unavailable"
4055
+ ? undefined
4056
+ : orgWaitCancelled
4057
+ ? "this deployment is org-governed and the task was cancelled before the organization policy snapshot could be adjudicated"
4058
+ : "this deployment is org-governed and cannot currently adjudicate against an organization policy snapshot"
4019
4059
  : org.verdict?.behavior === "deny"
4020
4060
  ? `an organization policy rule (${org.verdict.rule}) denies it`
4021
4061
  : undefined;
4022
4062
  if (blocked !== undefined) {
4023
- const orgDenial = formatHookFeedback(`The approved tool call "${pendingAction.toolName}" was not executed: ${blocked}. This approval is spent — the call has to be re-issued and approved again once organization policy permits it.`);
4063
+ const orgDisclosures = org.status === "unavailable" && org.disclosures.length > 0
4064
+ ? ` Governance disclosures: ${inlineUntrusted(org.disclosures.join("; "), ORG_DISCLOSURE_MAX_CHARS)}.`
4065
+ : "";
4066
+ const orgDenial = formatHookFeedback(`The approved tool call "${pendingAction.toolName}" was not executed: ${blocked}.${orgDisclosures} This approval is spent — the call has to be re-issued and approved again once organization policy permits it.`);
4024
4067
  emitEnd(true, { content: orgDenial });
4025
4068
  const eid = await prepared.session.appendMessage(toolResultMsg(pendingAction.toolCallId, pendingAction.toolName, orgDenial, true));
4026
4069
  emitCommitted(eid, "toolResult", pendingAction.toolCallId);
@@ -1,4 +1,5 @@
1
1
  import { isAbsolutePathForm } from "../../tools/fs/safety.js";
2
+ import { isOffloadedDetailReplacement } from "../tool-result-store.js";
2
3
  const READ_TOOL = "Read";
3
4
  const WRITE_TOOL = "Write";
4
5
  const RETRACTING_RESULTS = [
@@ -50,6 +51,8 @@ export function wholeFileRecordsFromTranscript(messages) {
50
51
  const content = isRead ? wholeFileFromReadCard(rest) : typeof rest.content === "string" ? rest.content : undefined;
51
52
  if (content === undefined)
52
53
  continue;
54
+ if (isOffloadedDetailReplacement(content))
55
+ continue;
53
56
  byPath.set(filePath, { path: filePath, content, at: m.timestamp });
54
57
  }
55
58
  return [...byPath.values()];
@@ -167,6 +167,13 @@ export type PermissionResult = {
167
167
  message?: string;
168
168
  decisionReason?: DecisionReason;
169
169
  requiresRealApproval?: boolean;
170
+ /** #144 disclosure (additive): a persisted allow rule MATCHED this call but could not clear the
171
+ * ask, because the ask is MANDATED (operator shellGate:"always", or the tool's own
172
+ * egress/irreversibility marks) rather than a classifier's hesitation — "allow rules silence
173
+ * the classifier's questions, never a mandated one". Carries the matched rule text so a
174
+ * consumer (approval card, wire frame) can tell the person their rule is alive, just outranked.
175
+ * Absent ⇒ no rule matched, or the ask was cleared normally. */
176
+ persistedRuleShadowed?: string;
170
177
  } | {
171
178
  action: "deny";
172
179
  updatedInput?: unknown;
@@ -601,6 +608,10 @@ export interface AskDelegationProvenance {
601
608
  /** The structured context an `onAsk` approver receives for an `ask` decision (design/37). */
602
609
  export interface AskRequest {
603
610
  toolName: string;
611
+ /** #144: a persisted allow rule MATCHED this call but could not clear the ask (mandated — see
612
+ * {@link PermissionResult}'s ask arm). The matched rule text, so the approval card renders "your
613
+ * rule is alive, just outranked" instead of leaving the person to regex the message prose. */
614
+ persistedRuleShadowed?: string;
604
615
  toolCallId: string;
605
616
  /** The (post-rewrite) args the tool would run with. */
606
617
  args: unknown;
@@ -122,12 +122,9 @@ export function createAllowDenyPolicy(opts) {
122
122
  if (entry.startsWith("mcp__")) {
123
123
  const segments = entry.slice("mcp__".length).split("__");
124
124
  if (segments.some((seg) => seg.length === 0)) {
125
- invalid.push({
126
- entry,
127
- list,
128
- message: `"${entry}" is a malformed MCP tool name (empty segment) — it can never match any mounted tool. ` +
129
- `Use \`mcp__<server>\` for every tool of a server, or \`mcp__<server>__<tool>\` for one tool.`,
130
- });
125
+ const lesson = `a malformed MCP tool name (empty segment) — it can never match any mounted tool. ` +
126
+ `Use \`mcp__<server>\` for every tool of a server, or \`mcp__<server>__<tool>\` for one tool.`;
127
+ invalid.push({ entry, list, message: `"${entry}" is ${lesson}`, lesson });
131
128
  continue;
132
129
  }
133
130
  }
@@ -135,14 +132,11 @@ export function createAllowDenyPolicy(opts) {
135
132
  kept.push(entry);
136
133
  continue;
137
134
  }
138
- invalid.push({
139
- entry,
140
- list,
141
- message: `"${entry}" is a rule CONTENT form, not a tool name a name set matches raw tool names, so this entry ` +
142
- `can never match any mounted tool (in an allow list it removes the tool entirely). Use the tool NAME here, ` +
143
- `and route the narrowing through the lane that speaks this grammar: parameter rules via ` +
144
- `createPermissionRulePolicy, Bash command prefixes via the persisted allow-rule lane.`,
145
- });
135
+ const lesson = `a rule CONTENT form, not a tool name — a name set matches raw tool names, so this entry ` +
136
+ `can never match any mounted tool (in an allow list it removes the tool entirely). Use the tool NAME here, ` +
137
+ `and route the narrowing through the lane that speaks this grammar: parameter rules via ` +
138
+ `createPermissionRulePolicy, Bash command prefixes via the persisted allow-rule lane.`;
139
+ invalid.push({ entry, list, message: `"${entry}" is ${lesson}`, lesson });
146
140
  }
147
141
  return kept;
148
142
  };
@@ -150,8 +144,16 @@ export function createAllowDenyPolicy(opts) {
150
144
  const screenedDeny = screen(opts.deny, "deny");
151
145
  if (invalid.length > 0) {
152
146
  if ((opts.onInvalidName ?? "throw") === "throw") {
147
+ const byLesson = new Map();
148
+ for (const i of invalid) {
149
+ const group = byLesson.get(i.lesson) ?? [];
150
+ group.push({ entry: i.entry, list: i.list });
151
+ byLesson.set(i.lesson, group);
152
+ }
153
153
  const e = new Error(`createAllowDenyPolicy: ${invalid.length} entr${invalid.length === 1 ? "y is" : "ies are"} not tool name(s):\n` +
154
- invalid.map((i) => ` [${i.list}] ${i.message}`).join("\n"));
154
+ [...byLesson.entries()]
155
+ .map(([lesson, group]) => ` Each of the following is ${lesson}\n` + group.map((g) => ` [${g.list}] "${g.entry}"`).join("\n"))
156
+ .join("\n"));
155
157
  e.code = "config.invalid_tool_name_set";
156
158
  e.issues = invalid;
157
159
  throw e;
@@ -240,5 +240,13 @@ export declare function withToolResultOffload(tool: AgentTool, store: ToolResult
240
240
  * ⇒ byte-identical historic preview. An accessor (not a snapshot) because the wrap happens at
241
241
  * prepare time, before the deferred classification exists and before any activation can. */
242
242
  reachableTools?: () => ReadonlySet<string> | undefined): AgentTool;
243
+ /** The machine-recognizable start of an offloaded-detail replacement notice. Exported for consumers
244
+ * that treat a `details` string as SEMANTIC INPUT (not display) — e.g. the session-continuation
245
+ * read-state replay — so they can tell "this member was offloaded, the bytes here are a preview"
246
+ * and degrade honestly instead of consuming preview bytes as the real value. */
247
+ export declare const OFFLOADED_DETAIL_NOTICE_PREFIX = "\u2026[offloaded \u2014 ";
248
+ /** True when `s` carries an offloaded-detail replacement notice (the newline-anchored prefix form the
249
+ * walk emits). A semantic consumer treats such a string as NOT the member's real bytes. */
250
+ export declare function isOffloadedDetailReplacement(s: string): boolean;
243
251
  /** The injected `read_tool_result` tool: pages through an offloaded result. `effect:"read"` (verifier/reconcile-safe). */
244
252
  export declare function createReadToolResultTool(store: ToolResultStore): AgentTool;
@@ -121,6 +121,8 @@ export function firstPartyOffloadPolicy(toolName) {
121
121
  switch (toolName) {
122
122
  case "Read":
123
123
  return { offload: false };
124
+ case "Write":
125
+ return { offload: false };
124
126
  case "Bash":
125
127
  return { offloadThresholdChars: 30_000 };
126
128
  case "Grep":
@@ -164,21 +166,93 @@ export function withToolResultOffload(tool, store, thresholdChars, sessionId, re
164
166
  return tool;
165
167
  const wrappedExecute = async (toolCallId, params, signal, onUpdate) => {
166
168
  const res = await tool.execute(toolCallId, params, signal, onUpdate);
169
+ const offloadedDetails = res.details === undefined ? undefined : await offloadOversizedDetailStrings(res.details, store, thresholdChars, sessionId, toolCallId);
170
+ const withDetails = (r) => offloadedDetails === undefined || offloadedDetails.value === res.details ? r : { ...r, details: offloadedDetails.value };
167
171
  if (totalTextChars(res.content) <= thresholdChars)
168
- return res;
172
+ return withDetails(res);
169
173
  const full = res.content
170
174
  .filter((b) => b.type === "text")
171
175
  .map((b) => b.text)
172
176
  .join("\n");
173
177
  if (full.length <= PREVIEW_HEAD_CHARS + PREVIEW_TAIL_CHARS)
174
- return res;
178
+ return withDetails(res);
175
179
  const ref = buildToolResultRef(sessionId, toolCallId);
176
180
  await store.put(ref, full);
177
181
  const images = res.content.filter((b) => b.type !== "text");
178
- return { ...res, content: [{ type: "text", text: buildPreview(full, ref, undefined, reachableTools?.()) }, ...images] };
182
+ return withDetails({ ...res, content: [{ type: "text", text: buildPreview(full, ref, undefined, reachableTools?.()) }, ...images] });
179
183
  };
180
184
  return { ...tool, execute: wrappedExecute };
181
185
  }
186
+ const OFFLOADED_DETAIL_HEAD_CHARS = 2_000;
187
+ const OFFLOADED_DETAIL_NOTICE_ALLOWANCE_CHARS = 400;
188
+ export const OFFLOADED_DETAIL_NOTICE_PREFIX = "…[offloaded — ";
189
+ export function isOffloadedDetailReplacement(s) {
190
+ return s.includes(`\n${OFFLOADED_DETAIL_NOTICE_PREFIX}`);
191
+ }
192
+ async function offloadOversizedDetailStrings(details, store, thresholdChars, sessionId, toolCallId) {
193
+ const puts = [];
194
+ const onStack = new Set();
195
+ const isPlainObject = (v) => {
196
+ if (typeof v !== "object" || v === null)
197
+ return false;
198
+ const p = Object.getPrototypeOf(v);
199
+ return p === Object.prototype || p === null;
200
+ };
201
+ const replace = (full, path) => {
202
+ const detailRef = buildToolResultRef(sessionId, toolCallId + " " + path);
203
+ puts.push(Promise.resolve(store.put(detailRef, full)));
204
+ return (`${full.slice(0, OFFLOADED_DETAIL_HEAD_CHARS)}\n` +
205
+ `${OFFLOADED_DETAIL_NOTICE_PREFIX}${full.length} chars total; ref "${detailRef}"; the remainder is retained in this run's tool-result store and reads back through the deployment's tool-results face]`);
206
+ };
207
+ const memo = new Map();
208
+ const walk = (v, segs) => {
209
+ if (typeof v === "string")
210
+ return v.length > thresholdChars && v.length > OFFLOADED_DETAIL_HEAD_CHARS + OFFLOADED_DETAIL_NOTICE_ALLOWANCE_CHARS ? replace(v, JSON.stringify(segs)) : v;
211
+ if (Array.isArray(v)) {
212
+ if (onStack.has(v))
213
+ return v;
214
+ const done = memo.get(v);
215
+ if (done !== undefined)
216
+ return done;
217
+ onStack.add(v);
218
+ let changed = false;
219
+ const next = v.map((item, i) => {
220
+ const w = walk(item, [...segs, i]);
221
+ if (w !== item)
222
+ changed = true;
223
+ return w;
224
+ });
225
+ onStack.delete(v);
226
+ const result = changed ? next : v;
227
+ memo.set(v, result);
228
+ return result;
229
+ }
230
+ if (isPlainObject(v)) {
231
+ if (onStack.has(v))
232
+ return v;
233
+ const done = memo.get(v);
234
+ if (done !== undefined)
235
+ return done;
236
+ onStack.add(v);
237
+ let changed = false;
238
+ const next = {};
239
+ for (const [k, item] of Object.entries(v)) {
240
+ const w = walk(item, [...segs, k]);
241
+ if (w !== item)
242
+ changed = true;
243
+ Object.defineProperty(next, k, { value: w, enumerable: true, writable: true, configurable: true });
244
+ }
245
+ onStack.delete(v);
246
+ const result = changed ? next : v;
247
+ memo.set(v, result);
248
+ return result;
249
+ }
250
+ return v;
251
+ };
252
+ const value = walk(details, []);
253
+ await Promise.all(puts);
254
+ return { value };
255
+ }
182
256
  export function createReadToolResultTool(store) {
183
257
  return defineTool({
184
258
  name: OFFLOAD_TOOL_NAME,
@@ -4188,11 +4188,13 @@ export interface RunnerDeps {
4188
4188
  * they confirmed once is not asked about again.
4189
4189
  *
4190
4190
  * This is the one seam in this file that LOOSENS, and it is shaped so it can only do so within limits
4191
- * the engine holds. The provider hands out a READ face anchored to one verified principal; the write
4192
- * face is core-private, so a deployment has no API path to put a rule in — that goes through the
4193
- * approval-record protocol, which requires a confirmed human decision. Rules are consumed post-fold, in
4194
- * the gate's ask branch, and never resolve an ask carrying `requiresRealApproval` or one a PreToolUse
4195
- * hook raised.
4191
+ * the engine holds. The provider hands out a READ face anchored to one verified principal. The write
4192
+ * face is an exported BACKEND CONTRACT (ruled 2026-08-10: an out-of-repo store twin builds against the
4193
+ * same definitions instead of mirroring them), but the ENGINE reaches it only on the consent lanes
4194
+ * redemption of a confirmed human decision, the tighten-delete, and the sync join a wiring invariant
4195
+ * pinned by a registered-caller scan (test/permission-rule-writer-callers); no exported convenience
4196
+ * mints a rule around consent. Rules are consumed post-fold, in the gate's ask branch, and never
4197
+ * resolve an ask carrying `requiresRealApproval` or one a PreToolUse hook raised.
4196
4198
  *
4197
4199
  * Omitted ⇒ the lane does not exist: no rules are read, no field is added to any ask, and the decision
4198
4200
  * path is byte-identical to a build without it. An unauthenticated task (no `principal`) resolves to