@sema-agent/core 5.29.0 → 5.30.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 (62) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/dist/agents/send-message-tool.js +2 -0
  3. package/dist/agents/subagent.d.ts +2 -0
  4. package/dist/agents/subagent.js +6 -0
  5. package/dist/agents/teacher.js +2 -0
  6. package/dist/agents/verify.js +2 -0
  7. package/dist/core/auto-compaction.d.ts +5 -1
  8. package/dist/core/auto-compaction.js +10 -1
  9. package/dist/core/checkpoint-store.d.ts +51 -5
  10. package/dist/core/checkpoint-store.js +2 -1
  11. package/dist/core/hooks.d.ts +12 -1
  12. package/dist/core/hooks.js +8 -2
  13. package/dist/core/permission-rules.js +2 -2
  14. package/dist/core/runner/prepare-task.d.ts +21 -5
  15. package/dist/core/runner/prepare-task.js +105 -19
  16. package/dist/core/runner/runtask.js +29 -3
  17. package/dist/core/runner/session-rule-policy.d.ts +3 -2
  18. package/dist/core/runner/tool-output-projection.js +1 -1
  19. package/dist/core/sensitive-path-policy.js +5 -16
  20. package/dist/core/store-contracts/tool-result-store-contract.js +23 -0
  21. package/dist/core/tighten-task-spec.js +18 -0
  22. package/dist/core/tool-policy.d.ts +20 -1
  23. package/dist/core/tool-policy.js +31 -4
  24. package/dist/core/tool-result-store.js +3 -1
  25. package/dist/core/types.d.ts +55 -0
  26. package/dist/engine/harness/types.d.ts +10 -0
  27. package/dist/index.d.ts +3 -1
  28. package/dist/index.js +3 -1
  29. package/dist/orchestration/run-workflow-tool.d.ts +21 -0
  30. package/dist/orchestration/run-workflow-tool.js +6 -3
  31. package/dist/orchestration/workflow-primitives.d.ts +10 -1
  32. package/dist/orchestration/workflow-primitives.js +12 -1
  33. package/dist/prompt-assembly/epoch.js +2 -0
  34. package/dist/prompt-assembly/packs/sema-default.js +2 -2
  35. package/dist/prompt-assembly/types.d.ts +4 -0
  36. package/dist/prompts/default.d.ts +14 -9
  37. package/dist/prompts/default.js +13 -3
  38. package/dist/tools/fs/bash-readonly-classifier.d.ts +21 -0
  39. package/dist/tools/fs/bash-readonly-classifier.js +11 -0
  40. package/dist/tools/fs/fs-bash.d.ts +7 -0
  41. package/dist/tools/fs/fs-bash.js +8 -3
  42. package/dist/tools/fs/fs-pdf.d.ts +1 -1
  43. package/dist/tools/fs/fs-pdf.js +2 -2
  44. package/dist/tools/fs/fs-read.d.ts +1 -1
  45. package/dist/tools/fs/fs-read.js +11 -7
  46. package/dist/tools/fs/fs-search-tools.d.ts +4 -2
  47. package/dist/tools/fs/fs-search-tools.js +15 -8
  48. package/dist/tools/fs/fs-shared.d.ts +5 -1
  49. package/dist/tools/fs/fs-shared.js +8 -3
  50. package/dist/tools/fs/index.d.ts +18 -0
  51. package/dist/tools/fs/index.js +13 -2
  52. package/dist/tools/fs/read-deny.d.ts +105 -0
  53. package/dist/tools/fs/read-deny.js +151 -0
  54. package/dist/tools/fs/read-face.d.ts +43 -0
  55. package/dist/tools/fs/read-face.js +38 -0
  56. package/dist/tools/fs/repo-map.d.ts +3 -1
  57. package/dist/tools/fs/repo-map.js +11 -5
  58. package/dist/tools/fs/safety.d.ts +33 -11
  59. package/dist/tools/fs/safety.js +88 -7
  60. package/dist/tools/fs/search.d.ts +54 -5
  61. package/dist/tools/fs/search.js +103 -21
  62. package/package.json +1 -1
@@ -57,7 +57,7 @@ import { foldAdmissionFreeze } from "../memory-admission.js";
57
57
  import { prepareMemory } from "./prepare-memory.js";
58
58
  import { defaultPromptProvider, buildEnvironmentContext, buildGitSnapshot, formatLocalDate, isValidTimeZone, PROJECT_CONTEXT_FRAMING } from "../../prompts/default.js";
59
59
  import { assemblePrompt } from "../../prompt-assembly/assemble.js";
60
- import { auditToolCollisions, projectToolManifest } from "../../prompt-assembly/tool-catalog.js";
60
+ import { auditToolCollisions, getToolContract, projectToolManifest } from "../../prompt-assembly/tool-catalog.js";
61
61
  import { resolveEpochAgainstBundled } from "../../prompt-assembly/epoch.js";
62
62
  import { artifactDeclarations } from "../../prompt-assembly/artifact.js";
63
63
  import { buildTurnPromptSnapshot } from "../../prompt-assembly/turn-snapshot.js";
@@ -74,7 +74,7 @@ import { hasBackgroundShell, sweepBackgroundShells } from "../background-shell.j
74
74
  import { createTaskOutputTool, createTaskStopTool, defaultTaskRegistry } from "../task-registry.js";
75
75
  import { createMonitorTool } from "../../tools/monitor.js";
76
76
  import { createWorktreeTools } from "../../tools/worktree.js";
77
- import { applyCompactionToReadFileState, bashReversibilityProbe, createHandsToolkit, isReadDedupStubResult, seedReadFileStateFromContext, seedReadFileStateFromTranscript, HAND_TOOL_EFFECTS, pdfModelCapabilitiesOf } from "../../tools/fs/index.js";
77
+ import { applyCompactionToReadFileState, bashReversibilityProbe, compileReadDeny, createHandsToolkit, isReadDedupStubResult, resolveReadFace, seedReadFileStateFromContext, seedReadFileStateFromTranscript, FULL_SHELL_CONTRACT_ID, HAND_TOOL_EFFECTS, pdfModelCapabilitiesOf } from "../../tools/fs/index.js";
78
78
  import { decodeTextBytes } from "../../tools/fs/encoding.js";
79
79
  import { ASK_USER_QUESTION_TOOL_NAME, createAskUserQuestionTool, classifyQuestionOutcome, isLiveQuestionFace, validateAskQuestions, } from "../ask-question.js";
80
80
  import { createSchedulerTools } from "../../tools/scheduler-tools.js";
@@ -85,7 +85,7 @@ 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, REAL_APPROVAL_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, FACE_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";
@@ -917,7 +917,8 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
917
917
  internals.onWorkspaceResolved({
918
918
  cwd: taskRootPath,
919
919
  isolated: internals.isolation === "worktree",
920
- remote: workspaceEnv !== undefined && isRemoteExecutionEnv(workspaceEnv),
920
+ remote: workspaceEnv !== undefined &&
921
+ (isRemoteExecutionEnv(workspaceEnv) || workspaceEnv.hostLocalPaths === false),
921
922
  });
922
923
  }
923
924
  catch {
@@ -1081,6 +1082,17 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1081
1082
  const memoryAdmittedOrgScopesRef = { current: [] };
1082
1083
  const ownOrgVerdictRef = { current: undefined };
1083
1084
  const orgAdmissionCheckpointState = () => inheritedAdmittedOrgScopes !== undefined || ownOrgVerdictRef.current !== undefined || orgGovernedProvenance;
1085
+ const faceCheckpointSection = () => {
1086
+ if (resolvedReadFace === undefined) {
1087
+ const seed = resume?.seed.readFace;
1088
+ return seed !== undefined ? { face: seed.face, ...(seed.denyEntries !== undefined ? { denyEntries: seed.denyEntries.map((e) => ({ ...e })) } : {}) } : undefined;
1089
+ }
1090
+ if (resolvedReadFace === "open" || readDenyAdditionsNormalized.length > 0) {
1091
+ return { face: resolvedReadFace, ...(readDenyAdditionsNormalized.length > 0 ? { denyEntries: readDenyAdditionsNormalized.map((e) => ({ ...e })) } : {}) };
1092
+ }
1093
+ return undefined;
1094
+ };
1095
+ const faceCheckpointState = () => faceCheckpointSection() !== undefined;
1084
1096
  const f012CheckpointState = () => (inheritedParentConstraints?.length ?? 0) > 0 ||
1085
1097
  seedInheritedGate?.constraintChain !== undefined ||
1086
1098
  internals?.delegationProvenance !== undefined;
@@ -1151,6 +1163,12 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1151
1163
  };
1152
1164
  let agentForkDenial;
1153
1165
  let observersActive = false;
1166
+ let resolvedReadFace;
1167
+ let readDenyAdditionsNormalized = [];
1168
+ const fullShellReachable = handsEnabled &&
1169
+ !(executionEnv instanceof StubExecutionEnv) &&
1170
+ spec.handsReadOnly !== true &&
1171
+ !(toolFaceSnapshot.exclude?.includes("Bash") ?? false);
1154
1172
  let autoModeDecider;
1155
1173
  const enrichSpecToolCtx = (ctx) => ({
1156
1174
  ...ctx,
@@ -1161,6 +1179,8 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1161
1179
  ...(frozenOnAsk !== undefined ? { onAsk: frozenOnAsk } : {}),
1162
1180
  ...(frozenOnQuestion !== undefined ? { onQuestion: frozenOnQuestion } : {}),
1163
1181
  ...(spec.handsReadOnly === true ? { handsReadOnly: true } : {}),
1182
+ ...(resolvedReadFace === "roots" ? { readFace: "roots" } : {}),
1183
+ ...(readDenyAdditionsNormalized.length > 0 ? { readDenyPatterns: Object.freeze(readDenyAdditionsNormalized.map((e) => ({ ...e }))) } : {}),
1164
1184
  ...(spec.interactiveTools === false ? { interactiveTools: false } : {}),
1165
1185
  oneShot: spec.oneShot,
1166
1186
  clientContext: spec.clientContext,
@@ -1366,6 +1386,11 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1366
1386
  workflowDepth: internals?.workflowDepth,
1367
1387
  parentCwd: taskRootPath,
1368
1388
  parentThinking: () => harnessRef.current?.getThinkingLevel() ?? thinking,
1389
+ parentReadFace: () => resolvedReadFace,
1390
+ parentReadDenyPatterns: () => (readDenyAdditionsNormalized.length > 0 ? readDenyAdditionsNormalized : undefined),
1391
+ parentCheckpointStoreDisabled: spec.checkpointStore === null,
1392
+ parentCenterArtifactDigest: () => centerAdoption?.artifact.artifactDigest,
1393
+ parentCenterSourceRevision: () => centerAdoption?.sourceRevision,
1369
1394
  ...(forwardEvent ? { forwardEvent } : {}),
1370
1395
  inheritedGateForChildren,
1371
1396
  }));
@@ -1578,6 +1603,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1578
1603
  const additionalRootsCanonical = [];
1579
1604
  const additionalReadRootsCanonical = [];
1580
1605
  let attachmentRootCanonical;
1606
+ let readDenyMatcher;
1581
1607
  if (handsEnabled) {
1582
1608
  const rootRaw = taskRootPath;
1583
1609
  const canon = await executionEnv.canonicalPath(rootRaw);
@@ -1629,14 +1655,14 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1629
1655
  if (resume === undefined && spec.sessionId !== undefined) {
1630
1656
  const prior = await session.buildContext().catch(() => undefined);
1631
1657
  for (const rec of wholeFileRecordsFromTranscript(prior?.messages ?? [])) {
1632
- const rk = await resolveKey(executionEnv, rootCanonical, rec.path, undefined, undefined, additionalRootsCanonical);
1658
+ const rk = await resolveKey(executionEnv, rootCanonical, rec.path, undefined, undefined, [...additionalRootsCanonical, ...additionalReadRootsCanonical]);
1633
1659
  if (rk.ok)
1634
1660
  seedReadFileStateFromTranscript(readFileState, rk.key, rec.content, rec.at);
1635
1661
  }
1636
1662
  }
1637
1663
  seedContextFiles = async (files) => {
1638
1664
  for (const f of files) {
1639
- const rk = await resolveKey(executionEnv, rootCanonical, f.path, undefined, undefined, additionalRootsCanonical);
1665
+ const rk = await resolveKey(executionEnv, rootCanonical, f.path, undefined, undefined, [...additionalRootsCanonical, ...additionalReadRootsCanonical]);
1640
1666
  if (rk.ok)
1641
1667
  seedReadFileStateFromContext(readFileState, rk.key, f.content);
1642
1668
  }
@@ -1658,9 +1684,28 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1658
1684
  ...(resume !== undefined ? { baselineUnknown: true } : {}),
1659
1685
  };
1660
1686
  }
1687
+ const seedReadFaceSection = resume?.seed.readFace;
1688
+ const readDenyAdditions = [...(deps.readDenyPatterns ?? []), ...(spec.readDenyPatterns ?? []), ...(seedReadFaceSection?.denyEntries ?? [])];
1689
+ readDenyMatcher = compileReadDeny(readDenyAdditions, "readDenyPatterns");
1690
+ {
1691
+ const builtinCount = compileReadDeny([], "built-in").entries.length;
1692
+ readDenyAdditionsNormalized = readDenyMatcher.entries.slice(builtinCount);
1693
+ }
1694
+ let liveReadFace = resolveReadFace({
1695
+ specReadFace: spec.readFace,
1696
+ depsReadFace: deps.readFace,
1697
+ readOnlyMount: handsReadOnly,
1698
+ orgGoverned: deps.permissionRuleOrg !== undefined,
1699
+ fullShellReachable,
1700
+ });
1701
+ if (resume !== undefined && (seedReadFaceSection === undefined || seedReadFaceSection.face === "roots"))
1702
+ liveReadFace = "roots";
1703
+ resolvedReadFace = liveReadFace;
1661
1704
  const band = createHandsToolkit(executionEnv, readFileState, rootCanonical, {
1662
1705
  ...(additionalRootsCanonical.length > 0 ? { additionalRoots: additionalRootsCanonical } : {}),
1663
1706
  ...(additionalReadRootsCanonical.length > 0 ? { additionalReadRoots: additionalReadRootsCanonical } : {}),
1707
+ ...(readDenyAdditions.length > 0 ? { readDenyPatterns: readDenyAdditions } : {}),
1708
+ readFace: liveReadFace,
1664
1709
  includeShell: handsIncludeShell,
1665
1710
  readOnly: handsReadOnly,
1666
1711
  ...(handsCwdRef ? { cwdRef: handsCwdRef } : {}),
@@ -1714,6 +1759,8 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1714
1759
  const shellReadBoundary = () => ({
1715
1760
  roots: [rootCanonical, ...additionalRootsCanonical, ...additionalReadRootsCanonical],
1716
1761
  ...(handsCwdRef?.current !== undefined ? { cwd: handsCwdRef.current } : {}),
1762
+ denyMatch: (p) => readDenyMatcher?.matchPath(p)?.pattern ?? null,
1763
+ ...(resolvedReadFace !== undefined ? { face: resolvedReadFace } : {}),
1717
1764
  });
1718
1765
  if (shellGate === "classify" && shellGatedBash)
1719
1766
  reversibilityProbes.set("Bash", bashReversibilityProbe(undefined, shellReadBoundary));
@@ -2164,6 +2211,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2164
2211
  policyEnabled: promptPolicyEnabled,
2165
2212
  hooksEnabled: promptHooksEnabled,
2166
2213
  isolationEnabled: isIsolated(executionEnv),
2214
+ readFaceOpen: resolvedReadFace === "open",
2167
2215
  orchestrationEnabled: selfOrchestrationActive,
2168
2216
  orchestrationDeferred: selfOrchestrationActive && (toolFaceSnapshot.defer?.includes("Workflow") ?? false),
2169
2217
  promptProfile,
@@ -2216,6 +2264,8 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2216
2264
  if (additionalRootsCanonical.length > 0) {
2217
2265
  envFacts.additionalDirectories = [...additionalRootsCanonical];
2218
2266
  }
2267
+ if (resolvedReadFace !== undefined)
2268
+ envFacts.readFace = resolvedReadFace;
2219
2269
  if (additionalReadRootsCanonical.length > 0) {
2220
2270
  envFacts.additionalReadDirectories = [...additionalReadRootsCanonical];
2221
2271
  }
@@ -2309,6 +2359,16 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2309
2359
  if (excluded.has(tools[i].name))
2310
2360
  tools.splice(i, 1);
2311
2361
  }
2362
+ if (handsEnabled) {
2363
+ const fullShellOnRoster = tools.some((t) => t.name === "Bash" && getToolContract(t, "core").contractId === FULL_SHELL_CONTRACT_ID);
2364
+ if (fullShellOnRoster !== fullShellReachable) {
2365
+ const e = new Error(`internal invariant: fullShellReachable=${String(fullShellReachable)} but the post-exclusion roster ` +
2366
+ `${fullShellOnRoster ? "carries" : "does not carry"} the full shell (${FULL_SHELL_CONTRACT_ID}). ` +
2367
+ `A new roster-affecting mechanism was added without updating the predicate.`);
2368
+ e.code = "internal.full_shell_reachable_mismatch";
2369
+ throw e;
2370
+ }
2371
+ }
2312
2372
  if (promptProfile === "classic") {
2313
2373
  for (let i = 0; i < tools.length; i++) {
2314
2374
  const t = tools[i];
@@ -3300,6 +3360,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3300
3360
  const suggestions = suggestRulesForCommand(command);
3301
3361
  return suggestions.length > 0 ? { ruleSuggestions: suggestions } : {};
3302
3362
  };
3363
+ const frozenClassifierExcluded = (d) => d.decisionReason === "hook" || d.matchedAskRule !== undefined;
3303
3364
  const recheckApprovedEdit = async (pol, onAskOf, creq, edit, csignal, ancestorDecider) => {
3304
3365
  let editArgs = edit;
3305
3366
  for (let round = 0;; round++) {
@@ -3326,7 +3387,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3326
3387
  }
3327
3388
  if (re.updatedInput !== undefined)
3328
3389
  editArgs = re.updatedInput;
3329
- if (ancestorDecider !== undefined) {
3390
+ if (ancestorDecider !== undefined && !frozenClassifierExcluded(re)) {
3330
3391
  const verdict = await ancestorDecider
3331
3392
  .decide({ req: { ...creq, args: editArgs }, ...(re.message !== undefined ? { askMessage: re.message } : {}) }, csignal ?? abortController.signal)
3332
3393
  .catch(() => ({ kind: "unavailable", cause: "error" }));
@@ -3405,7 +3466,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3405
3466
  `ask cannot be reconstructed in a delegated child; denied fail-closed (tighten-only)`,
3406
3467
  };
3407
3468
  }
3408
- if (pc.autoMode !== undefined) {
3469
+ if (pc.autoMode !== undefined && !frozenClassifierExcluded(first)) {
3409
3470
  const verdict = await pc.autoMode.decider
3410
3471
  .decide({ req: creq, ...(first.message !== undefined ? { askMessage: first.message } : {}) }, csignal ?? abortController.signal)
3411
3472
  .catch(() => ({ kind: "unavailable", cause: "error" }));
@@ -3492,7 +3553,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3492
3553
  };
3493
3554
  }
3494
3555
  const presentedArgs = decision.updatedInput !== undefined ? decision.updatedInput : creq.args;
3495
- if (pc.autoMode !== undefined) {
3556
+ if (pc.autoMode !== undefined && !frozenClassifierExcluded(decision)) {
3496
3557
  const verdict = await pc.autoMode.decider
3497
3558
  .decide({ req: { ...creq, args: presentedArgs }, ...(decision.message !== undefined ? { askMessage: decision.message } : {}) }, csignal ?? abortController.signal)
3498
3559
  .catch(() => ({ kind: "unavailable", cause: "error" }));
@@ -3902,6 +3963,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3902
3963
  nestedStats: { ...nestedStats },
3903
3964
  consolidationNotes: undefined,
3904
3965
  readFileState: readFileStateForCheckpoint ? [...readFileStateForCheckpoint.entries()] : undefined,
3966
+ readFace: faceCheckpointSection(),
3905
3967
  repairBundle: repairBundleForCheckpoint(parkedSpendMicroUsd),
3906
3968
  workspaceHandle,
3907
3969
  handsCwd: handsCwdRef?.current,
@@ -4121,7 +4183,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
4121
4183
  const mintedAt = Date.now();
4122
4184
  const cp = {
4123
4185
  token,
4124
- version: f012CheckpointState() ? F012_CHECKPOINT_VERSION : orgAdmissionCheckpointState() ? ORG_ADMISSION_CHECKPOINT_VERSION : resourceLedgerOut.totalTokens !== undefined ? TOKEN_CHECKPOINT_VERSION : RESOURCE_CHECKPOINT_VERSION,
4186
+ version: faceCheckpointState() ? FACE_CHECKPOINT_VERSION : f012CheckpointState() ? F012_CHECKPOINT_VERSION : orgAdmissionCheckpointState() ? ORG_ADMISSION_CHECKPOINT_VERSION : resourceLedgerOut.totalTokens !== undefined ? TOKEN_CHECKPOINT_VERSION : RESOURCE_CHECKPOINT_VERSION,
4125
4187
  scope,
4126
4188
  sessionId,
4127
4189
  leafId,
@@ -4203,7 +4265,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
4203
4265
  const mintedAt = Date.now();
4204
4266
  const cp = {
4205
4267
  token,
4206
- version: f012CheckpointState() ? F012_CHECKPOINT_VERSION : orgAdmissionCheckpointState() ? ORG_ADMISSION_CHECKPOINT_VERSION : reviewLedger.totalTokens !== undefined ? TOKEN_CHECKPOINT_VERSION : BINDING_CHECKPOINT_VERSION,
4268
+ version: faceCheckpointState() ? FACE_CHECKPOINT_VERSION : f012CheckpointState() ? F012_CHECKPOINT_VERSION : orgAdmissionCheckpointState() ? ORG_ADMISSION_CHECKPOINT_VERSION : reviewLedger.totalTokens !== undefined ? TOKEN_CHECKPOINT_VERSION : BINDING_CHECKPOINT_VERSION,
4207
4269
  scope,
4208
4270
  sessionId,
4209
4271
  leafId,
@@ -4455,11 +4517,14 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
4455
4517
  const scope = durableApproval?.scope || checkpointScopeOf({ principal: spec.principal });
4456
4518
  const ttlMs = sanitizedTtlMs(durableApproval?.ttlMs);
4457
4519
  const checkpointState = serializeCheckpointState(remoteHandle, inFlightSpendMicroUsd());
4520
+ if (realApproval !== undefined && checkpointState.readFace !== undefined) {
4521
+ checkpointState.readFace = { ...checkpointState.readFace, realApproval: true };
4522
+ }
4458
4523
  const approvalLedger = debitLedger(priorLedger, liveSpendRef.get?.() ?? { costMicroUsd: 0, tokens: 0, turns: 0, walltimeMs: 0 }, resourceTotal, { countSlice: false });
4459
4524
  const mintedAt = Date.now();
4460
4525
  cp = {
4461
4526
  token,
4462
- 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,
4527
+ version: faceCheckpointState() ? FACE_CHECKPOINT_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,
4463
4528
  scope,
4464
4529
  sessionId,
4465
4530
  leafId,
@@ -4809,21 +4874,42 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
4809
4874
  promptManifest.lowering = turnSnapshot.lowering;
4810
4875
  const promptOverheadTokens = Math.ceil((systemPrompt.length +
4811
4876
  harnessTools.reduce((a, t) => a + t.name.length + (t.description?.length ?? 0) + JSON.stringify(t.parameters ?? {}).length, 0)) / charsPerToken);
4877
+ const announceAttachmentSkip = (path, reason) => {
4878
+ deps.onError?.(new Error(`working-file attachment skipped — ${path}: ${reason}`), {
4879
+ phase: "degraded",
4880
+ sessionId,
4881
+ classification: "attachment-read",
4882
+ });
4883
+ };
4812
4884
  const readTaskFile = handsEnabled
4813
4885
  ? async (path) => {
4814
4886
  try {
4887
+ let key = path;
4815
4888
  if (attachmentRootCanonical !== undefined) {
4816
- const rk = await resolveKey(executionEnv, attachmentRootCanonical, path, undefined, undefined, additionalRootsCanonical);
4817
- if (!rk.ok)
4889
+ const rk = await resolveKey(executionEnv, attachmentRootCanonical, path, undefined, undefined, [...additionalRootsCanonical, ...additionalReadRootsCanonical], undefined, readDenyMatcher, resolvedReadFace);
4890
+ if (!rk.ok) {
4891
+ announceAttachmentSkip(path, rk.violation.message);
4892
+ if (rk.violation.code === "read_path_denied" && rk.violation.pattern !== undefined) {
4893
+ return { withheld: { pattern: rk.violation.pattern } };
4894
+ }
4818
4895
  return null;
4896
+ }
4897
+ key = rk.key;
4819
4898
  }
4820
- const r = await executionEnv.readBinaryFile(path);
4821
- if (!r.ok)
4899
+ const r = await executionEnv.readBinaryFile(key);
4900
+ if (!r.ok) {
4901
+ announceAttachmentSkip(path, r.error.message);
4822
4902
  return null;
4903
+ }
4823
4904
  const d = decodeTextBytes(r.value);
4824
- return d.malformed ? null : d.text;
4905
+ if (d.malformed) {
4906
+ announceAttachmentSkip(path, "malformed text encoding (truncated UTF-16 body)");
4907
+ return null;
4908
+ }
4909
+ return d.text;
4825
4910
  }
4826
- catch {
4911
+ catch (err) {
4912
+ announceAttachmentSkip(path, err instanceof Error ? err.message : String(err));
4827
4913
  return null;
4828
4914
  }
4829
4915
  }
@@ -4833,7 +4919,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
4833
4919
  if (attachmentRootCanonical === undefined)
4834
4920
  return path;
4835
4921
  try {
4836
- const rk = await resolveKey(executionEnv, attachmentRootCanonical, path, undefined, handsCwdRef?.current, additionalRootsCanonical);
4922
+ const rk = await resolveKey(executionEnv, attachmentRootCanonical, path, undefined, handsCwdRef?.current, [...additionalRootsCanonical, ...additionalReadRootsCanonical], undefined, undefined, resolvedReadFace);
4837
4923
  return rk.ok ? rk.key : path;
4838
4924
  }
4839
4925
  catch {
@@ -1,6 +1,7 @@
1
+ import { persistedReadDenyEntryProblem } from "../../tools/fs/read-deny.js";
1
2
  import { AgentHarness, DEFAULT_COMPACTION_SETTINGS, uuidv7 } from "../../internal/harness.js";
2
3
  import { snapshotActorAssertion } from "../../internal/llm.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
+ import { CheckpointError, BINDING_CHECKPOINT_VERSION, checkpointVersionOf, F012_CHECKPOINT_VERSION, MAX_SUPPORTED_CHECKPOINT_VERSION, REAL_APPROVAL_CHECKPOINT_VERSION, FACE_CHECKPOINT_VERSION, remainingBudgetMicroUsd, readPendingSteerQueue, remainingTokens, validatePendingSteer, winnerFromOutcome, } from "../checkpoint-store.js";
4
5
  import { engineVersion } from "../version.js";
5
6
  import { CONFIG_CATALOG_VERSION, declarationReasons, resolveEffectiveConfig } from "../../config/catalog.js";
6
7
  import { eventDefaultOn } from "../../prompt-assembly/event-registry.js";
@@ -3718,11 +3719,36 @@ export class Runner {
3718
3719
  (preCasGateBit.origin === "org_rule" ||
3719
3720
  preCasGateBit.origin === "org_unavailable" ||
3720
3721
  preCasGateBit.origin === "policy");
3721
- if (checkpointVersionOf(cp) >= REAL_APPROVAL_CHECKPOINT_VERSION ? !preCasBitWellFormed : preCasGateBit !== undefined) {
3722
+ const faceSaysGoverned = (cp.state.readFace?.realApproval) === true;
3723
+ const preCasBitViolation = checkpointVersionOf(cp) === REAL_APPROVAL_CHECKPOINT_VERSION
3724
+ ? !preCasBitWellFormed
3725
+ : checkpointVersionOf(cp) < REAL_APPROVAL_CHECKPOINT_VERSION
3726
+ ? preCasGateBit !== undefined
3727
+ : faceSaysGoverned
3728
+ ? !preCasBitWellFormed
3729
+ : preCasGateBit !== undefined && !preCasBitWellFormed;
3730
+ if (preCasBitViolation) {
3722
3731
  throw new CheckpointError("checkpoint.invalid_outcome", checkpointVersionOf(cp) >= REAL_APPROVAL_CHECKPOINT_VERSION
3723
- ? `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`
3732
+ ? `a v${checkpointVersionOf(cp)} checkpoint's realApproval gate bit must be well-formed (origin org_rule/org_unavailable/policy)${checkpointVersionOf(cp) === REAL_APPROVAL_CHECKPOINT_VERSION ? " and present on an irreversible_ask gate" : ""} — this row's is not; refusing to resume a damaged real-approval row (corruption / downgrade guard), the checkpoint stays pending`
3724
3733
  : `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" });
3725
3734
  }
3735
+ {
3736
+ const faceSectionRaw = cp.state.readFace;
3737
+ const faceCandidate = faceSectionRaw !== undefined && typeof faceSectionRaw === "object" && faceSectionRaw !== null
3738
+ ? faceSectionRaw
3739
+ : undefined;
3740
+ const faceWellFormed = faceCandidate !== undefined &&
3741
+ (faceCandidate.face === "open" || faceCandidate.face === "roots") &&
3742
+ (faceCandidate.realApproval === undefined || faceCandidate.realApproval === true) &&
3743
+ (faceCandidate.denyEntries === undefined ||
3744
+ (Array.isArray(faceCandidate.denyEntries) &&
3745
+ faceCandidate.denyEntries.every((e) => persistedReadDenyEntryProblem(e) === null)));
3746
+ if (checkpointVersionOf(cp) >= FACE_CHECKPOINT_VERSION ? !faceWellFormed : faceSectionRaw !== undefined) {
3747
+ throw new CheckpointError("checkpoint.invalid_outcome", checkpointVersionOf(cp) >= FACE_CHECKPOINT_VERSION
3748
+ ? `a v${checkpointVersionOf(cp)} checkpoint must carry a well-formed read-face section (state.readFace) — this row's is missing or malformed; refusing to resume a damaged read-face row (corruption / downgrade guard), the checkpoint stays pending`
3749
+ : `a v${checkpointVersionOf(cp)} checkpoint carries a read-face section no release of that version ever minted — refusing to honor a fabricated read-face state (corruption / forgery guard), the checkpoint stays pending`, { reason: checkpointVersionOf(cp) >= FACE_CHECKPOINT_VERSION ? "read_face_damaged" : "read_face_forged" });
3750
+ }
3751
+ }
3726
3752
  if ((preCasGateBit?.origin === "org_rule" || preCasGateBit?.origin === "org_unavailable") &&
3727
3753
  this.deps.permissionRuleOrg === undefined) {
3728
3754
  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" });
@@ -53,8 +53,9 @@ export declare function isWithin(root: string, p: string): boolean;
53
53
  * - `toolDeny` → deny the listed tools.
54
54
  * - `toolAllow` (if set) → deny any tool NOT listed (a narrowing allowlist).
55
55
  * - `commandAllow`/`commandDeny` → delegate to {@link createCoarseCommandNamePolicy} (argv[0] names; only
56
- * speaks about shell tools). allowlist mode denies the un-listed/un-parseable; blocklist mode asks on the
57
- * un-parseable circumvention surface (the gate decides).
56
+ * speaks about shell tools) with `defaultAction:"deny"` in BOTH modes so this policy structurally
57
+ * emits no ask. Allowlist mode denies the un-listed and the un-parseable; blocklist mode denies the
58
+ * listed and the un-parseable (a parseable command absent from the deny list stays allowed).
58
59
  * - `allowDirs` (if set) → a WRITE tool must land inside one of the dirs (resolved via `canonicalizeTarget`,
59
60
  * so a symlink can't carry across a write out); a write-capable tool that cannot be path-confined (e.g. `bash`)
60
61
  * is DENIED while `allowDirs` is set (fail-closed, mirrors active-skill-scope MAJOR-2).
@@ -62,7 +62,7 @@ const CC_DETAIL_TYPES = new Set([
62
62
  "agent", "task", "task-list", "task-output", "workflow-run",
63
63
  "web-fetch", "web-search", "todo", "cron-create", "cron-delete", "cron-list", "image",
64
64
  "task-stop", "tool-search", "repo-map", "fork", "enter-plan-mode", "exit-plan-mode",
65
- "monitor-start", "path_not_in_root", "readonly_out_of_root", "bash_invalid_timeout",
65
+ "monitor-start", "path_not_in_root", "read_path_denied", "readonly_out_of_root", "bash_invalid_timeout",
66
66
  "report-findings", "schedule-wakeup", "send-message", "agent-transcript", "a2a", "document",
67
67
  ]);
68
68
  export const structuredFrom = (result) => {
@@ -1,4 +1,5 @@
1
1
  import { canonicalizeTarget, writeTargetPath } from "../tools/fs/safety.js";
2
+ import { compileSegmentPattern, matchSegmentPatterns } from "../tools/fs/read-deny.js";
2
3
  const DEFAULT_GUARDED_TOOLS = ["Write", "Edit", "MultiEdit", "NotebookEdit"];
3
4
  export const RECOMMENDED_SENSITIVE_PATTERNS = [
4
5
  ".env",
@@ -25,33 +26,21 @@ export const RECOMMENDED_SENSITIVE_PATTERNS = [
25
26
  ".bash_history",
26
27
  ".zsh_history",
27
28
  ];
28
- function segmentGlobToRegExp(segment) {
29
- const escaped = segment.replace(/[.+^${}()|[\]\\?]/g, "\\$&").replace(/\*/g, "[^/]*");
30
- return new RegExp(`^${escaped}$`, CASE_INSENSITIVE_FS ? "i" : "");
31
- }
32
29
  const CASE_INSENSITIVE_FS = process.platform === "darwin" || process.platform === "win32";
33
30
  function compilePatterns(patterns) {
34
31
  const out = [];
35
32
  for (const raw of patterns) {
36
- const segments = raw.split("/").filter(Boolean).map(segmentGlobToRegExp);
37
- if (segments.length === 0) {
33
+ const compiled = compileSegmentPattern(raw, CASE_INSENSITIVE_FS ? "unicode" : "none");
34
+ if (compiled === null) {
38
35
  throw new Error(`createSensitivePathPolicy: pattern ${JSON.stringify(raw)} contains no path segments and would guard nothing. ` +
39
36
  `Patterns are "/"-separated runs of path SEGMENTS (e.g. ".ssh", ".git/hooks", "*.pem"); remove the entry or spell the segments.`);
40
37
  }
41
- out.push({ raw, segments });
38
+ out.push(compiled);
42
39
  }
43
40
  return out;
44
41
  }
45
42
  function matchSensitive(canonicalKey, compiled) {
46
- const segs = canonicalKey.split(/[\\/]/).filter(Boolean);
47
- for (const pat of compiled) {
48
- const n = pat.segments.length;
49
- for (let i = 0; i + n <= segs.length; i++) {
50
- if (pat.segments.every((re, j) => re.test(segs[i + j])))
51
- return pat.raw;
52
- }
53
- }
54
- return null;
43
+ return matchSegmentPatterns(canonicalKey, compiled);
55
44
  }
56
45
  export function createSensitivePathPolicy(opts) {
57
46
  const compiled = compilePatterns(opts.patterns);
@@ -55,5 +55,28 @@ export async function toolResultStoreContract(make, runAssertion) {
55
55
  assert.equal((await store.get(unowned)).content, "bytes from a write site that stated no owner");
56
56
  assert.equal(await ownerOf("tr_never~written"), undefined);
57
57
  });
58
+ run("#196 deleteBySession (optional): four-state semantics when present; absence is REPORTED, not silently green", async () => {
59
+ const probe = make();
60
+ if (typeof probe.deleteBySession !== "function") {
61
+ assert.equal(probe.deleteBySession, undefined, "deleteBySession is NOT IMPLEMENTED on this backend — sweep semantics unverified (optional member; this is a disclosure, not a failure)");
62
+ return;
63
+ }
64
+ const store = make();
65
+ const del = (sessionId) => Promise.resolve(store.deleteBySession(sessionId));
66
+ await store.put("tr_sweep~a", "A-bytes", { sessionId: "sess-X", taskId: "t1" });
67
+ await store.put("tr_sweep~b", "B-bytes", { sessionId: "sess-X" });
68
+ await store.put("tr_sweep~other", "OTHER-bytes", { sessionId: "sess-Y" });
69
+ await store.put("tr_sweep~unowned", "UNOWNED-bytes");
70
+ const first = await del("sess-X");
71
+ assert.equal(first.deleted, 2, "both rows recorded for the session are deleted (taskId presence does not split the key)");
72
+ assert.equal(await store.get("tr_sweep~a"), undefined, "a swept row is gone");
73
+ assert.equal(await store.get("tr_sweep~b"), undefined);
74
+ assert.equal((await store.get("tr_sweep~other")).content, "OTHER-bytes", "another session's row survives the sweep");
75
+ assert.equal(first.unattributable >= 1, true, "an unowned row is counted unattributable");
76
+ assert.equal((await store.get("tr_sweep~unowned")).content, "UNOWNED-bytes", "an unowned row is never deleted by a session sweep");
77
+ const second = await del("sess-X");
78
+ assert.equal(second.deleted, 0, "a repeated sweep deletes nothing");
79
+ assert.equal((await store.get("tr_sweep~other")).content, "OTHER-bytes");
80
+ });
58
81
  await settle();
59
82
  }
@@ -25,6 +25,24 @@ export function tightenTaskSpec(base, overrides) {
25
25
  merged[field] = [...new Set([...(b ?? []), ...(o ?? [])])];
26
26
  }
27
27
  }
28
+ if (base.readFace === "roots" && overrides.readFace === "open") {
29
+ throw new TaskSpecTightenError('tightenTaskSpec: cannot loosen readFace from "roots" to "open" (override may only tighten)');
30
+ }
31
+ if (base.readFace !== undefined || overrides.readFace !== undefined) {
32
+ merged.readFace = base.readFace === "roots" || overrides.readFace === "roots" ? "roots" : (overrides.readFace ?? base.readFace);
33
+ }
34
+ if (base.readDenyPatterns !== undefined || overrides.readDenyPatterns !== undefined) {
35
+ const seen = new Set();
36
+ const union = [];
37
+ for (const e of [...(base.readDenyPatterns ?? []), ...(overrides.readDenyPatterns ?? [])]) {
38
+ const key = typeof e === "string" ? `i:${e}` : `${e.caseSensitive === true ? "s" : "i"}:${e.pattern}`;
39
+ if (seen.has(key))
40
+ continue;
41
+ seen.add(key);
42
+ union.push(e);
43
+ }
44
+ merged.readDenyPatterns = union;
45
+ }
28
46
  if (base.handsReadOnly === true && overrides.handsReadOnly === false) {
29
47
  throw new TaskSpecTightenError("tightenTaskSpec: cannot loosen handsReadOnly from true to false (override may only tighten)");
30
48
  }
@@ -175,6 +175,19 @@ export type PermissionResult = {
175
175
  * consumer (approval card, wire frame) can tell the person their rule is alive, just outranked.
176
176
  * Absent ⇒ no rule matched, or the ask was cleared normally. */
177
177
  persistedRuleShadowed?: string;
178
+ /** An EXPLICIT `ask` permission rule matched this call (design/127 DSL — the CC `alwaysAskRules`
179
+ * shape); carries the matched rule's text. Present ⇔ a rule someone WROTE says "ask about this",
180
+ * never for a `defaultAction:"ask"` fallback (an unmatched call is default-closed posture, not a
181
+ * per-call instruction — CC draws the same line: only a matched ask rule diverts the auto-mode
182
+ * lane, a mode-default ask stays classifier-eligible). Consumer: the gate's auto-mode classifier
183
+ * step refuses to resolve an ask carrying it — a person's standing "ask me each time" is not
184
+ * classifier hesitation, so a classifier verdict must not be the thing that clears it. A policy
185
+ * self-declaring the member only ever OPTS ITS OWN ask OUT of classifier auto-resolution
186
+ * (tightening — the false-claim direction is safe, unlike the engine-stamped `"hook"` word whose
187
+ * risk was laundering provenance AWAY). {@link combinePolicies} carries it onto the surviving ask
188
+ * when ANY folded-away concurrent ask bore it (monotone, tighten-only); it is consumed inside the
189
+ * gate and deliberately NOT copied onto the park/approval-card request. */
190
+ matchedAskRule?: string;
178
191
  } | {
179
192
  action: "deny";
180
193
  updatedInput?: unknown;
@@ -723,7 +736,13 @@ export interface AskRequest {
723
736
  * (codex adversarial round, confirmed). NOT the same fact as {@link fromSubagent} either: that is
724
737
  * the design/153 attribution discriminator (`parentTaskId` presence), which a directly-started
725
738
  * workflow's children lack even though refusals must still speak the child posture to them.
726
- * `readonly`, trusted (internals never ride a TaskSpec), sync-path-only like its siblings. */
739
+ * `readonly`, trusted (internals never ride a TaskSpec), sync-path-only like its siblings.
740
+ *
741
+ * Key division, at a glance (the two child-ish keys serve DIFFERENT faces — neither implies the other):
742
+ * - `fromSubagent` — ATTRIBUTION: "a nameable parent task issued this" (`parentTaskId` presence);
743
+ * drives display/identity surfaces.
744
+ * - `isDelegatedChild` — REFUSAL POSTURE: "no user turn ever lands in this run's transcript";
745
+ * drives which deny text the model is told (stop-and-wait vs adapt-or-report), forks included. */
727
746
  readonly isDelegatedChild?: true;
728
747
  /** RB-203 (codex review, confirmed P1) — carried from the originating {@link PermissionResult}'s ask
729
748
  * variant of the same name: `true` ⇒ {@link resolveAsk} must not let a blanket `onAsk: "allow"`
@@ -317,6 +317,7 @@ export function combinePolicies(...policies) {
317
317
  let current = req;
318
318
  let rewrite;
319
319
  let settled;
320
+ let ruleAskText;
320
321
  for (const p of policies) {
321
322
  const d = refuseOutOfContractDecision(await p.check(current, signal));
322
323
  if (d.action === "deny") {
@@ -334,10 +335,14 @@ export function combinePolicies(...policies) {
334
335
  if (d.action === "ask" && (asked === undefined || (d.requiresRealApproval === true && asked.requiresRealApproval !== true))) {
335
336
  asked = d;
336
337
  }
338
+ if (d.action === "ask" && d.matchedAskRule !== undefined && ruleAskText === undefined) {
339
+ ruleAskText = d.matchedAskRule;
340
+ }
337
341
  }
338
342
  if (asked) {
339
343
  const merged = rewrite?.updatedInput;
340
- return merged !== undefined ? { ...asked, updatedInput: merged } : asked;
344
+ const withMark = ruleAskText !== undefined && asked.matchedAskRule === undefined ? { ...asked, matchedAskRule: ruleAskText } : asked;
345
+ return merged !== undefined ? { ...withMark, updatedInput: merged } : withMark;
341
346
  }
342
347
  const allowed = rewrite ?? ALLOW;
343
348
  return settled !== undefined ? { ...allowed, settledBy: settled } : allowed;
@@ -891,9 +896,31 @@ export async function resolveAsk(req, onAsk, signal) {
891
896
  };
892
897
  }
893
898
  if (typeof ok === "object" && ok !== null) {
894
- const supplied = ok.settledBy;
895
- const allowed = ok.allow;
896
- const suppliedEdit = ok.updatedInput;
899
+ let supplied;
900
+ let allowed;
901
+ let suppliedEdit;
902
+ try {
903
+ supplied = ok.settledBy;
904
+ allowed = ok.allow;
905
+ suppliedEdit = ok.updatedInput;
906
+ }
907
+ catch (err) {
908
+ return {
909
+ action: "deny",
910
+ message: `the approver's outcome for "${req.toolName}" could not be read (${describeThrown(err)}) — denied fail-closed`,
911
+ decisionReason: "mode",
912
+ settledBy: "aborted",
913
+ };
914
+ }
915
+ if (typeof allowed !== "boolean") {
916
+ return {
917
+ action: "deny",
918
+ message: `the approver for "${req.toolName}" returned an object whose allow is not a boolean ` +
919
+ `(got ${allowed === null ? "null" : typeof allowed}) — a verdict is exactly true or false; denied fail-closed`,
920
+ decisionReason: "mode",
921
+ settledBy: "aborted",
922
+ };
923
+ }
897
924
  if (supplied !== undefined && supplied !== "human" && supplied !== "timeout") {
898
925
  return {
899
926
  action: "deny",
@@ -366,7 +366,9 @@ async function offloadOversizedDetailStrings(details, store, thresholdChars, ses
366
366
  const value = walk(details, []);
367
367
  const settled = await Promise.allSettled(pending.map((p) => p.done));
368
368
  const notices = new Map();
369
- pending.forEach((p, i) => void notices.set(p.marker, p.render(settled[i].status === "fulfilled")));
369
+ pending.forEach((p, i) => {
370
+ notices.set(p.marker, p.render(settled[i].status === "fulfilled"));
371
+ });
370
372
  const seen = new Set();
371
373
  const finalize = (node) => {
372
374
  if (typeof node !== "object" || node === null)