@sema-agent/core 5.29.0 → 5.31.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 (66) hide show
  1. package/CHANGELOG.md +141 -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 +115 -26
  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.d.ts +4 -1
  21. package/dist/core/store-contracts/tool-result-store-contract.js +26 -1
  22. package/dist/core/tighten-task-spec.js +18 -0
  23. package/dist/core/tool-policy.d.ts +20 -1
  24. package/dist/core/tool-policy.js +31 -4
  25. package/dist/core/tool-result-store.d.ts +6 -4
  26. package/dist/core/tool-result-store.js +3 -1
  27. package/dist/core/types.d.ts +63 -1
  28. package/dist/engine/harness/types.d.ts +10 -0
  29. package/dist/index.d.ts +3 -1
  30. package/dist/index.js +3 -1
  31. package/dist/orchestration/run-workflow-tool.d.ts +26 -0
  32. package/dist/orchestration/run-workflow-tool.js +7 -4
  33. package/dist/orchestration/workflow-governance.d.ts +53 -3
  34. package/dist/orchestration/workflow-governance.js +162 -25
  35. package/dist/orchestration/workflow-primitives.d.ts +15 -1
  36. package/dist/orchestration/workflow-primitives.js +13 -2
  37. package/dist/prompt-assembly/epoch.js +2 -0
  38. package/dist/prompt-assembly/packs/sema-default.js +2 -2
  39. package/dist/prompt-assembly/types.d.ts +4 -0
  40. package/dist/prompts/default.d.ts +14 -9
  41. package/dist/prompts/default.js +13 -3
  42. package/dist/tools/fs/bash-readonly-classifier.d.ts +53 -4
  43. package/dist/tools/fs/bash-readonly-classifier.js +148 -16
  44. package/dist/tools/fs/fs-bash.d.ts +7 -0
  45. package/dist/tools/fs/fs-bash.js +8 -3
  46. package/dist/tools/fs/fs-pdf.d.ts +1 -1
  47. package/dist/tools/fs/fs-pdf.js +2 -2
  48. package/dist/tools/fs/fs-read.d.ts +1 -1
  49. package/dist/tools/fs/fs-read.js +11 -7
  50. package/dist/tools/fs/fs-search-tools.d.ts +4 -2
  51. package/dist/tools/fs/fs-search-tools.js +15 -8
  52. package/dist/tools/fs/fs-shared.d.ts +5 -1
  53. package/dist/tools/fs/fs-shared.js +8 -3
  54. package/dist/tools/fs/index.d.ts +18 -0
  55. package/dist/tools/fs/index.js +13 -2
  56. package/dist/tools/fs/read-deny.d.ts +110 -0
  57. package/dist/tools/fs/read-deny.js +159 -0
  58. package/dist/tools/fs/read-face.d.ts +49 -0
  59. package/dist/tools/fs/read-face.js +38 -0
  60. package/dist/tools/fs/repo-map.d.ts +3 -1
  61. package/dist/tools/fs/repo-map.js +11 -5
  62. package/dist/tools/fs/safety.d.ts +34 -11
  63. package/dist/tools/fs/safety.js +108 -8
  64. package/dist/tools/fs/search.d.ts +54 -5
  65. package/dist/tools/fs/search.js +107 -23
  66. 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, assertReadFaceValue, 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";
@@ -342,6 +342,8 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
342
342
  throw e;
343
343
  }
344
344
  }
345
+ assertReadFaceValue(spec.readFace, "TaskSpec.readFace");
346
+ assertReadFaceValue(deps.readFace, "readFace (deployment seat)");
345
347
  if (spec.resumeAtMode !== undefined) {
346
348
  if (spec.resumeAt === undefined) {
347
349
  const e = new Error(`resumeAtMode "${spec.resumeAtMode}" requires resumeAt (there is no branch target to position against)`);
@@ -917,7 +919,8 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
917
919
  internals.onWorkspaceResolved({
918
920
  cwd: taskRootPath,
919
921
  isolated: internals.isolation === "worktree",
920
- remote: workspaceEnv !== undefined && isRemoteExecutionEnv(workspaceEnv),
922
+ remote: workspaceEnv !== undefined &&
923
+ (isRemoteExecutionEnv(workspaceEnv) || workspaceEnv.hostLocalPaths === false),
921
924
  });
922
925
  }
923
926
  catch {
@@ -1081,6 +1084,17 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1081
1084
  const memoryAdmittedOrgScopesRef = { current: [] };
1082
1085
  const ownOrgVerdictRef = { current: undefined };
1083
1086
  const orgAdmissionCheckpointState = () => inheritedAdmittedOrgScopes !== undefined || ownOrgVerdictRef.current !== undefined || orgGovernedProvenance;
1087
+ const faceCheckpointSection = () => {
1088
+ if (resolvedReadFace === undefined) {
1089
+ const seed = resume?.seed.readFace;
1090
+ return seed !== undefined ? { face: seed.face, ...(seed.denyEntries !== undefined ? { denyEntries: seed.denyEntries.map((e) => ({ ...e })) } : {}) } : undefined;
1091
+ }
1092
+ if (resolvedReadFace === "open" || readDenyAdditionsNormalized.length > 0) {
1093
+ return { face: resolvedReadFace, ...(readDenyAdditionsNormalized.length > 0 ? { denyEntries: readDenyAdditionsNormalized.map((e) => ({ ...e })) } : {}) };
1094
+ }
1095
+ return undefined;
1096
+ };
1097
+ const faceCheckpointState = () => faceCheckpointSection() !== undefined;
1084
1098
  const f012CheckpointState = () => (inheritedParentConstraints?.length ?? 0) > 0 ||
1085
1099
  seedInheritedGate?.constraintChain !== undefined ||
1086
1100
  internals?.delegationProvenance !== undefined;
@@ -1151,6 +1165,12 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1151
1165
  };
1152
1166
  let agentForkDenial;
1153
1167
  let observersActive = false;
1168
+ let resolvedReadFace;
1169
+ let readDenyAdditionsNormalized = [];
1170
+ const fullShellReachable = handsEnabled &&
1171
+ !(executionEnv instanceof StubExecutionEnv) &&
1172
+ spec.handsReadOnly !== true &&
1173
+ !(toolFaceSnapshot.exclude?.includes("Bash") ?? false);
1154
1174
  let autoModeDecider;
1155
1175
  const enrichSpecToolCtx = (ctx) => ({
1156
1176
  ...ctx,
@@ -1161,6 +1181,8 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1161
1181
  ...(frozenOnAsk !== undefined ? { onAsk: frozenOnAsk } : {}),
1162
1182
  ...(frozenOnQuestion !== undefined ? { onQuestion: frozenOnQuestion } : {}),
1163
1183
  ...(spec.handsReadOnly === true ? { handsReadOnly: true } : {}),
1184
+ ...(resolvedReadFace === "roots" ? { readFace: "roots" } : {}),
1185
+ ...(readDenyAdditionsNormalized.length > 0 ? { readDenyPatterns: Object.freeze(readDenyAdditionsNormalized.map((e) => ({ ...e }))) } : {}),
1164
1186
  ...(spec.interactiveTools === false ? { interactiveTools: false } : {}),
1165
1187
  oneShot: spec.oneShot,
1166
1188
  clientContext: spec.clientContext,
@@ -1366,6 +1388,12 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1366
1388
  workflowDepth: internals?.workflowDepth,
1367
1389
  parentCwd: taskRootPath,
1368
1390
  parentThinking: () => harnessRef.current?.getThinkingLevel() ?? thinking,
1391
+ parentReadFace: () => resolvedReadFace,
1392
+ parentReadDenyPatterns: () => (readDenyAdditionsNormalized.length > 0 ? readDenyAdditionsNormalized : undefined),
1393
+ onNotice: deps.onNotice,
1394
+ parentCheckpointStoreDisabled: spec.checkpointStore === null,
1395
+ parentCenterArtifactDigest: () => centerAdoption?.artifact.artifactDigest,
1396
+ parentCenterSourceRevision: () => centerAdoption?.sourceRevision,
1369
1397
  ...(forwardEvent ? { forwardEvent } : {}),
1370
1398
  inheritedGateForChildren,
1371
1399
  }));
@@ -1578,6 +1606,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1578
1606
  const additionalRootsCanonical = [];
1579
1607
  const additionalReadRootsCanonical = [];
1580
1608
  let attachmentRootCanonical;
1609
+ let readDenyMatcher;
1581
1610
  if (handsEnabled) {
1582
1611
  const rootRaw = taskRootPath;
1583
1612
  const canon = await executionEnv.canonicalPath(rootRaw);
@@ -1626,17 +1655,9 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1626
1655
  }
1627
1656
  const readFileState = new Map((resume?.seed.readFileState ?? []).map(([k, v]) => [rebaseRestoredPath(k), v]));
1628
1657
  readFileStateForCheckpoint = readFileState;
1629
- if (resume === undefined && spec.sessionId !== undefined) {
1630
- const prior = await session.buildContext().catch(() => undefined);
1631
- for (const rec of wholeFileRecordsFromTranscript(prior?.messages ?? [])) {
1632
- const rk = await resolveKey(executionEnv, rootCanonical, rec.path, undefined, undefined, additionalRootsCanonical);
1633
- if (rk.ok)
1634
- seedReadFileStateFromTranscript(readFileState, rk.key, rec.content, rec.at);
1635
- }
1636
- }
1637
1658
  seedContextFiles = async (files) => {
1638
1659
  for (const f of files) {
1639
- const rk = await resolveKey(executionEnv, rootCanonical, f.path, undefined, undefined, additionalRootsCanonical);
1660
+ const rk = await resolveKey(executionEnv, rootCanonical, f.path, undefined, undefined, [...additionalRootsCanonical, ...additionalReadRootsCanonical], undefined, undefined, resolvedReadFace);
1640
1661
  if (rk.ok)
1641
1662
  seedReadFileStateFromContext(readFileState, rk.key, f.content);
1642
1663
  }
@@ -1658,9 +1679,36 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1658
1679
  ...(resume !== undefined ? { baselineUnknown: true } : {}),
1659
1680
  };
1660
1681
  }
1682
+ const seedReadFaceSection = resume?.seed.readFace;
1683
+ const readDenyAdditions = [...(deps.readDenyPatterns ?? []), ...(spec.readDenyPatterns ?? []), ...(seedReadFaceSection?.denyEntries ?? [])];
1684
+ readDenyMatcher = compileReadDeny(readDenyAdditions, "readDenyPatterns");
1685
+ {
1686
+ const builtinCount = compileReadDeny([], "built-in").entries.length;
1687
+ readDenyAdditionsNormalized = readDenyMatcher.entries.slice(builtinCount);
1688
+ }
1689
+ let liveReadFace = resolveReadFace({
1690
+ specReadFace: spec.readFace,
1691
+ depsReadFace: deps.readFace,
1692
+ readOnlyMount: handsReadOnly,
1693
+ orgGoverned: deps.permissionRuleOrg !== undefined,
1694
+ fullShellReachable,
1695
+ });
1696
+ if (resume !== undefined && (seedReadFaceSection === undefined || seedReadFaceSection.face === "roots"))
1697
+ liveReadFace = "roots";
1698
+ resolvedReadFace = liveReadFace;
1699
+ if (resume === undefined && spec.sessionId !== undefined) {
1700
+ const prior = await session.buildContext().catch(() => undefined);
1701
+ for (const rec of wholeFileRecordsFromTranscript(prior?.messages ?? [])) {
1702
+ const rk = await resolveKey(executionEnv, rootCanonical, rec.path, undefined, undefined, [...additionalRootsCanonical, ...additionalReadRootsCanonical], undefined, undefined, liveReadFace);
1703
+ if (rk.ok)
1704
+ seedReadFileStateFromTranscript(readFileState, rk.key, rec.content, rec.at);
1705
+ }
1706
+ }
1661
1707
  const band = createHandsToolkit(executionEnv, readFileState, rootCanonical, {
1662
1708
  ...(additionalRootsCanonical.length > 0 ? { additionalRoots: additionalRootsCanonical } : {}),
1663
1709
  ...(additionalReadRootsCanonical.length > 0 ? { additionalReadRoots: additionalReadRootsCanonical } : {}),
1710
+ ...(readDenyAdditions.length > 0 ? { readDenyPatterns: readDenyAdditions } : {}),
1711
+ readFace: liveReadFace,
1664
1712
  includeShell: handsIncludeShell,
1665
1713
  readOnly: handsReadOnly,
1666
1714
  ...(handsCwdRef ? { cwdRef: handsCwdRef } : {}),
@@ -1714,6 +1762,8 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1714
1762
  const shellReadBoundary = () => ({
1715
1763
  roots: [rootCanonical, ...additionalRootsCanonical, ...additionalReadRootsCanonical],
1716
1764
  ...(handsCwdRef?.current !== undefined ? { cwd: handsCwdRef.current } : {}),
1765
+ denyMatch: (p) => readDenyMatcher?.matchPath(p)?.pattern ?? null,
1766
+ ...(resolvedReadFace !== undefined ? { face: resolvedReadFace } : {}),
1717
1767
  });
1718
1768
  if (shellGate === "classify" && shellGatedBash)
1719
1769
  reversibilityProbes.set("Bash", bashReversibilityProbe(undefined, shellReadBoundary));
@@ -2164,6 +2214,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2164
2214
  policyEnabled: promptPolicyEnabled,
2165
2215
  hooksEnabled: promptHooksEnabled,
2166
2216
  isolationEnabled: isIsolated(executionEnv),
2217
+ readFaceOpen: resolvedReadFace === "open",
2167
2218
  orchestrationEnabled: selfOrchestrationActive,
2168
2219
  orchestrationDeferred: selfOrchestrationActive && (toolFaceSnapshot.defer?.includes("Workflow") ?? false),
2169
2220
  promptProfile,
@@ -2216,6 +2267,8 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2216
2267
  if (additionalRootsCanonical.length > 0) {
2217
2268
  envFacts.additionalDirectories = [...additionalRootsCanonical];
2218
2269
  }
2270
+ if (resolvedReadFace !== undefined)
2271
+ envFacts.readFace = resolvedReadFace;
2219
2272
  if (additionalReadRootsCanonical.length > 0) {
2220
2273
  envFacts.additionalReadDirectories = [...additionalReadRootsCanonical];
2221
2274
  }
@@ -2309,6 +2362,16 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2309
2362
  if (excluded.has(tools[i].name))
2310
2363
  tools.splice(i, 1);
2311
2364
  }
2365
+ if (handsEnabled) {
2366
+ const fullShellOnRoster = tools.some((t) => t.name === "Bash" && getToolContract(t, "core").contractId === FULL_SHELL_CONTRACT_ID);
2367
+ if (fullShellOnRoster !== fullShellReachable) {
2368
+ const e = new Error(`internal invariant: fullShellReachable=${String(fullShellReachable)} but the post-exclusion roster ` +
2369
+ `${fullShellOnRoster ? "carries" : "does not carry"} the full shell (${FULL_SHELL_CONTRACT_ID}). ` +
2370
+ `A new roster-affecting mechanism was added without updating the predicate.`);
2371
+ e.code = "internal.full_shell_reachable_mismatch";
2372
+ throw e;
2373
+ }
2374
+ }
2312
2375
  if (promptProfile === "classic") {
2313
2376
  for (let i = 0; i < tools.length; i++) {
2314
2377
  const t = tools[i];
@@ -3300,6 +3363,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3300
3363
  const suggestions = suggestRulesForCommand(command);
3301
3364
  return suggestions.length > 0 ? { ruleSuggestions: suggestions } : {};
3302
3365
  };
3366
+ const frozenClassifierExcluded = (d) => d.decisionReason === "hook" || d.matchedAskRule !== undefined;
3303
3367
  const recheckApprovedEdit = async (pol, onAskOf, creq, edit, csignal, ancestorDecider) => {
3304
3368
  let editArgs = edit;
3305
3369
  for (let round = 0;; round++) {
@@ -3326,7 +3390,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3326
3390
  }
3327
3391
  if (re.updatedInput !== undefined)
3328
3392
  editArgs = re.updatedInput;
3329
- if (ancestorDecider !== undefined) {
3393
+ if (ancestorDecider !== undefined && !frozenClassifierExcluded(re)) {
3330
3394
  const verdict = await ancestorDecider
3331
3395
  .decide({ req: { ...creq, args: editArgs }, ...(re.message !== undefined ? { askMessage: re.message } : {}) }, csignal ?? abortController.signal)
3332
3396
  .catch(() => ({ kind: "unavailable", cause: "error" }));
@@ -3405,7 +3469,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3405
3469
  `ask cannot be reconstructed in a delegated child; denied fail-closed (tighten-only)`,
3406
3470
  };
3407
3471
  }
3408
- if (pc.autoMode !== undefined) {
3472
+ if (pc.autoMode !== undefined && !frozenClassifierExcluded(first)) {
3409
3473
  const verdict = await pc.autoMode.decider
3410
3474
  .decide({ req: creq, ...(first.message !== undefined ? { askMessage: first.message } : {}) }, csignal ?? abortController.signal)
3411
3475
  .catch(() => ({ kind: "unavailable", cause: "error" }));
@@ -3492,7 +3556,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3492
3556
  };
3493
3557
  }
3494
3558
  const presentedArgs = decision.updatedInput !== undefined ? decision.updatedInput : creq.args;
3495
- if (pc.autoMode !== undefined) {
3559
+ if (pc.autoMode !== undefined && !frozenClassifierExcluded(decision)) {
3496
3560
  const verdict = await pc.autoMode.decider
3497
3561
  .decide({ req: { ...creq, args: presentedArgs }, ...(decision.message !== undefined ? { askMessage: decision.message } : {}) }, csignal ?? abortController.signal)
3498
3562
  .catch(() => ({ kind: "unavailable", cause: "error" }));
@@ -3902,6 +3966,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3902
3966
  nestedStats: { ...nestedStats },
3903
3967
  consolidationNotes: undefined,
3904
3968
  readFileState: readFileStateForCheckpoint ? [...readFileStateForCheckpoint.entries()] : undefined,
3969
+ readFace: faceCheckpointSection(),
3905
3970
  repairBundle: repairBundleForCheckpoint(parkedSpendMicroUsd),
3906
3971
  workspaceHandle,
3907
3972
  handsCwd: handsCwdRef?.current,
@@ -4121,7 +4186,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
4121
4186
  const mintedAt = Date.now();
4122
4187
  const cp = {
4123
4188
  token,
4124
- version: f012CheckpointState() ? F012_CHECKPOINT_VERSION : orgAdmissionCheckpointState() ? ORG_ADMISSION_CHECKPOINT_VERSION : resourceLedgerOut.totalTokens !== undefined ? TOKEN_CHECKPOINT_VERSION : RESOURCE_CHECKPOINT_VERSION,
4189
+ version: faceCheckpointState() ? FACE_CHECKPOINT_VERSION : f012CheckpointState() ? F012_CHECKPOINT_VERSION : orgAdmissionCheckpointState() ? ORG_ADMISSION_CHECKPOINT_VERSION : resourceLedgerOut.totalTokens !== undefined ? TOKEN_CHECKPOINT_VERSION : RESOURCE_CHECKPOINT_VERSION,
4125
4190
  scope,
4126
4191
  sessionId,
4127
4192
  leafId,
@@ -4203,7 +4268,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
4203
4268
  const mintedAt = Date.now();
4204
4269
  const cp = {
4205
4270
  token,
4206
- version: f012CheckpointState() ? F012_CHECKPOINT_VERSION : orgAdmissionCheckpointState() ? ORG_ADMISSION_CHECKPOINT_VERSION : reviewLedger.totalTokens !== undefined ? TOKEN_CHECKPOINT_VERSION : BINDING_CHECKPOINT_VERSION,
4271
+ version: faceCheckpointState() ? FACE_CHECKPOINT_VERSION : f012CheckpointState() ? F012_CHECKPOINT_VERSION : orgAdmissionCheckpointState() ? ORG_ADMISSION_CHECKPOINT_VERSION : reviewLedger.totalTokens !== undefined ? TOKEN_CHECKPOINT_VERSION : BINDING_CHECKPOINT_VERSION,
4207
4272
  scope,
4208
4273
  sessionId,
4209
4274
  leafId,
@@ -4455,11 +4520,14 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
4455
4520
  const scope = durableApproval?.scope || checkpointScopeOf({ principal: spec.principal });
4456
4521
  const ttlMs = sanitizedTtlMs(durableApproval?.ttlMs);
4457
4522
  const checkpointState = serializeCheckpointState(remoteHandle, inFlightSpendMicroUsd());
4523
+ if (realApproval !== undefined && checkpointState.readFace !== undefined) {
4524
+ checkpointState.readFace = { ...checkpointState.readFace, realApproval: true };
4525
+ }
4458
4526
  const approvalLedger = debitLedger(priorLedger, liveSpendRef.get?.() ?? { costMicroUsd: 0, tokens: 0, turns: 0, walltimeMs: 0 }, resourceTotal, { countSlice: false });
4459
4527
  const mintedAt = Date.now();
4460
4528
  cp = {
4461
4529
  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,
4530
+ 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
4531
  scope,
4464
4532
  sessionId,
4465
4533
  leafId,
@@ -4809,21 +4877,42 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
4809
4877
  promptManifest.lowering = turnSnapshot.lowering;
4810
4878
  const promptOverheadTokens = Math.ceil((systemPrompt.length +
4811
4879
  harnessTools.reduce((a, t) => a + t.name.length + (t.description?.length ?? 0) + JSON.stringify(t.parameters ?? {}).length, 0)) / charsPerToken);
4880
+ const announceAttachmentSkip = (path, reason) => {
4881
+ deps.onError?.(new Error(`working-file attachment skipped — ${path}: ${reason}`), {
4882
+ phase: "degraded",
4883
+ sessionId,
4884
+ classification: "attachment-read",
4885
+ });
4886
+ };
4812
4887
  const readTaskFile = handsEnabled
4813
4888
  ? async (path) => {
4814
4889
  try {
4890
+ let key = path;
4815
4891
  if (attachmentRootCanonical !== undefined) {
4816
- const rk = await resolveKey(executionEnv, attachmentRootCanonical, path, undefined, undefined, additionalRootsCanonical);
4817
- if (!rk.ok)
4892
+ const rk = await resolveKey(executionEnv, attachmentRootCanonical, path, undefined, undefined, [...additionalRootsCanonical, ...additionalReadRootsCanonical], undefined, readDenyMatcher, resolvedReadFace);
4893
+ if (!rk.ok) {
4894
+ announceAttachmentSkip(path, rk.violation.message);
4895
+ if (rk.violation.code === "read_path_denied" && rk.violation.pattern !== undefined) {
4896
+ return { withheld: { pattern: rk.violation.pattern } };
4897
+ }
4818
4898
  return null;
4899
+ }
4900
+ key = rk.key;
4819
4901
  }
4820
- const r = await executionEnv.readBinaryFile(path);
4821
- if (!r.ok)
4902
+ const r = await executionEnv.readBinaryFile(key);
4903
+ if (!r.ok) {
4904
+ announceAttachmentSkip(path, r.error.message);
4822
4905
  return null;
4906
+ }
4823
4907
  const d = decodeTextBytes(r.value);
4824
- return d.malformed ? null : d.text;
4908
+ if (d.malformed) {
4909
+ announceAttachmentSkip(path, "malformed text encoding (truncated UTF-16 body)");
4910
+ return null;
4911
+ }
4912
+ return d.text;
4825
4913
  }
4826
- catch {
4914
+ catch (err) {
4915
+ announceAttachmentSkip(path, err instanceof Error ? err.message : String(err));
4827
4916
  return null;
4828
4917
  }
4829
4918
  }
@@ -4833,7 +4922,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
4833
4922
  if (attachmentRootCanonical === undefined)
4834
4923
  return path;
4835
4924
  try {
4836
- const rk = await resolveKey(executionEnv, attachmentRootCanonical, path, undefined, handsCwdRef?.current, additionalRootsCanonical);
4925
+ const rk = await resolveKey(executionEnv, attachmentRootCanonical, path, undefined, handsCwdRef?.current, [...additionalRootsCanonical, ...additionalReadRootsCanonical], undefined, undefined, resolvedReadFace);
4837
4926
  return rk.ok ? rk.key : path;
4838
4927
  }
4839
4928
  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);
@@ -19,4 +19,7 @@ import { type ContractAssertionRunner } from "./contract-harness.js";
19
19
  * host-side read face, and an optional parameter one store honors while another drops it silently is
20
20
  * exactly the divergence the two legs above exist to prevent.
21
21
  */
22
- export declare function toolResultStoreContract(make: () => ToolResultStore, runAssertion?: ContractAssertionRunner): Promise<void>;
22
+ export declare function toolResultStoreContract(make: () => ToolResultStore, runAssertion?: ContractAssertionRunner, onOptionalMember?: (report: {
23
+ member: "deleteBySession";
24
+ status: "verified" | "absent";
25
+ }) => void): Promise<void>;
@@ -1,6 +1,6 @@
1
1
  import { strict as assert } from "node:assert";
2
2
  import { beginContract } from "./contract-harness.js";
3
- export async function toolResultStoreContract(make, runAssertion) {
3
+ export async function toolResultStoreContract(make, runAssertion, onOptionalMember) {
4
4
  const { run, settle } = beginContract(runAssertion);
5
5
  run("put/get full + sliced; write-once no-op; unknown → undefined", async () => {
6
6
  const store = make();
@@ -55,5 +55,30 @@ 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 (probe.deleteBySession === undefined) {
61
+ onOptionalMember?.({ member: "deleteBySession", status: "absent" });
62
+ return;
63
+ }
64
+ assert.equal(typeof probe.deleteBySession, "function", `deleteBySession is present but not callable (got ${typeof probe.deleteBySession}) — a garbage member is a DEFECT, not an optional-member absence`);
65
+ const store = make();
66
+ const del = (sessionId) => Promise.resolve(store.deleteBySession(sessionId));
67
+ await store.put("tr_sweep~a", "A-bytes", { sessionId: "sess-X", taskId: "t1" });
68
+ await store.put("tr_sweep~b", "B-bytes", { sessionId: "sess-X" });
69
+ await store.put("tr_sweep~other", "OTHER-bytes", { sessionId: "sess-Y" });
70
+ await store.put("tr_sweep~unowned", "UNOWNED-bytes");
71
+ const first = await del("sess-X");
72
+ assert.equal(first.deleted, 2, "both rows recorded for the session are deleted (taskId presence does not split the key)");
73
+ assert.equal(await store.get("tr_sweep~a"), undefined, "a swept row is gone");
74
+ assert.equal(await store.get("tr_sweep~b"), undefined);
75
+ assert.equal((await store.get("tr_sweep~other")).content, "OTHER-bytes", "another session's row survives the sweep");
76
+ assert.equal(first.unattributable >= 1, true, "an unowned row is counted unattributable");
77
+ assert.equal((await store.get("tr_sweep~unowned")).content, "UNOWNED-bytes", "an unowned row is never deleted by a session sweep");
78
+ const second = await del("sess-X");
79
+ assert.equal(second.deleted, 0, "a repeated sweep deletes nothing");
80
+ assert.equal((await store.get("tr_sweep~other")).content, "OTHER-bytes");
81
+ onOptionalMember?.({ member: "deleteBySession", status: "verified" });
82
+ });
58
83
  await settle();
59
84
  }
@@ -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",