@guilz-dev/belay 0.6.0 → 0.7.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 (143) hide show
  1. package/README.md +20 -1
  2. package/dist/adapters/claude/runtime-entry.js +54 -25
  3. package/dist/adapters/shared/gate-runtime.d.ts +7 -0
  4. package/dist/adapters/shared/gate-runtime.js +420 -220
  5. package/dist/bundle/claude-runtime.mjs +13215 -8864
  6. package/dist/bundle/codex-runtime.mjs +10771 -6453
  7. package/dist/bundle/cursor-runtime.mjs +10797 -6484
  8. package/dist/cli.js +37 -1
  9. package/dist/commands/approve.js +34 -10
  10. package/dist/commands/doctor.js +51 -0
  11. package/dist/commands/recovery-checkpoints.d.ts +164 -0
  12. package/dist/commands/recovery-checkpoints.js +347 -0
  13. package/dist/commands/revoke.js +19 -7
  14. package/dist/commands/status.js +7 -0
  15. package/dist/config-io.js +49 -18
  16. package/dist/conformance/guarantee-table.js +6 -0
  17. package/dist/conformance/types.d.ts +1 -0
  18. package/dist/core/approval-replay.d.ts +1 -1
  19. package/dist/core/approval-replay.js +4 -1
  20. package/dist/core/approval-service.d.ts +6 -1
  21. package/dist/core/approval-service.js +86 -27
  22. package/dist/core/approval.d.ts +2 -0
  23. package/dist/core/approval.js +6 -3
  24. package/dist/core/audit-types.d.ts +13 -0
  25. package/dist/core/capability/approval-state-mutation.d.ts +19 -0
  26. package/dist/core/capability/approval-state-mutation.js +76 -3
  27. package/dist/core/capability/approval-v3.d.ts +18 -1
  28. package/dist/core/capability/approval-v3.js +63 -23
  29. package/dist/core/capability/attestation.d.ts +3 -0
  30. package/dist/core/capability/attestation.js +7 -0
  31. package/dist/core/capability/boundary-driver-container.d.ts +12 -0
  32. package/dist/core/capability/boundary-driver-container.js +101 -34
  33. package/dist/core/capability/boundary-driver.js +1 -0
  34. package/dist/core/capability/boundary-grant-materialize.d.ts +1 -0
  35. package/dist/core/capability/boundary-grant-materialize.js +15 -0
  36. package/dist/core/capability/boundary-run.d.ts +25 -0
  37. package/dist/core/capability/boundary-run.js +37 -9
  38. package/dist/core/capability/boundary-session.d.ts +4 -0
  39. package/dist/core/capability/boundary-session.js +9 -0
  40. package/dist/core/capability/boundary-workspace-mount.d.ts +5 -0
  41. package/dist/core/capability/boundary-workspace-mount.js +68 -0
  42. package/dist/core/capability/gate-policy-shadow.js +23 -1
  43. package/dist/core/capability/grant-consumption.d.ts +4 -0
  44. package/dist/core/capability/grant-consumption.js +11 -0
  45. package/dist/core/capability/grant-lease.d.ts +36 -1
  46. package/dist/core/capability/grant-lease.js +213 -16
  47. package/dist/core/capability/grant-match.js +3 -0
  48. package/dist/core/capability/index.d.ts +1 -1
  49. package/dist/core/capability/index.js +1 -1
  50. package/dist/core/capability/policy-engine.d.ts +7 -0
  51. package/dist/core/capability/policy-engine.js +202 -15
  52. package/dist/core/capability/request.d.ts +3 -0
  53. package/dist/core/config.d.ts +20 -0
  54. package/dist/core/config.js +62 -0
  55. package/dist/core/effect-ir/audit.d.ts +9 -0
  56. package/dist/core/effect-ir/audit.js +86 -0
  57. package/dist/core/effect-ir/build.d.ts +31 -0
  58. package/dist/core/effect-ir/build.js +277 -0
  59. package/dist/core/effect-ir/index.d.ts +8 -0
  60. package/dist/core/effect-ir/index.js +7 -0
  61. package/dist/core/effect-ir/normalize.d.ts +4 -0
  62. package/dist/core/effect-ir/normalize.js +104 -0
  63. package/dist/core/effect-ir/package-exec-eval.d.ts +8 -0
  64. package/dist/core/effect-ir/package-exec-eval.js +152 -0
  65. package/dist/core/effect-ir/package-exec.d.ts +29 -0
  66. package/dist/core/effect-ir/package-exec.js +292 -0
  67. package/dist/core/effect-ir/policy.d.ts +13 -0
  68. package/dist/core/effect-ir/policy.js +77 -0
  69. package/dist/core/effect-ir/types.d.ts +55 -0
  70. package/dist/core/effect-ir/types.js +1 -0
  71. package/dist/core/egress/allowlist.d.ts +1 -0
  72. package/dist/core/egress/allowlist.js +8 -0
  73. package/dist/core/egress-approval.js +45 -51
  74. package/dist/core/gate-contract.d.ts +2 -0
  75. package/dist/core/gate-contract.js +2 -0
  76. package/dist/core/gate-engine.js +26 -9
  77. package/dist/core/index.d.ts +3 -1
  78. package/dist/core/index.js +2 -1
  79. package/dist/core/judge-cloud-consent.js +29 -15
  80. package/dist/core/network-endpoint.d.ts +10 -0
  81. package/dist/core/network-endpoint.js +51 -0
  82. package/dist/core/process-runner.d.ts +10 -0
  83. package/dist/core/process-runner.js +124 -0
  84. package/dist/core/recover-advice.js +2 -1
  85. package/dist/core/recovery/artifact-store.d.ts +29 -0
  86. package/dist/core/recovery/artifact-store.js +336 -0
  87. package/dist/core/recovery/checkpoint.d.ts +25 -0
  88. package/dist/core/recovery/checkpoint.js +260 -0
  89. package/dist/core/recovery/index.d.ts +3 -1
  90. package/dist/core/recovery/index.js +2 -0
  91. package/dist/core/recovery/operator-guidance.d.ts +7 -0
  92. package/dist/core/recovery/operator-guidance.js +39 -0
  93. package/dist/core/recovery/reconcile.d.ts +3 -0
  94. package/dist/core/recovery/reconcile.js +48 -0
  95. package/dist/core/recovery/resource-identity.d.ts +4 -0
  96. package/dist/core/recovery/resource-identity.js +34 -0
  97. package/dist/core/recovery/restore.d.ts +13 -0
  98. package/dist/core/recovery/restore.js +114 -0
  99. package/dist/core/recovery/snapshot-node.d.ts +27 -0
  100. package/dist/core/recovery/snapshot-node.js +261 -0
  101. package/dist/core/recovery/types.d.ts +85 -1
  102. package/dist/core/transactional/apply-observed-changes.d.ts +13 -0
  103. package/dist/core/transactional/apply-observed-changes.js +275 -0
  104. package/dist/core/transactional/backend-selector.d.ts +8 -0
  105. package/dist/core/transactional/backend-selector.js +118 -0
  106. package/dist/core/transactional/backend.d.ts +55 -0
  107. package/dist/core/transactional/backend.js +1 -0
  108. package/dist/core/transactional/file-checkpoint-backend.d.ts +3 -0
  109. package/dist/core/transactional/file-checkpoint-backend.js +278 -0
  110. package/dist/core/transactional/file-checkpoint-git.d.ts +11 -0
  111. package/dist/core/transactional/file-checkpoint-git.js +217 -0
  112. package/dist/core/transactional/file-checkpoint-staging.d.ts +13 -0
  113. package/dist/core/transactional/file-checkpoint-staging.js +54 -0
  114. package/dist/core/transactional/file-clone.d.ts +15 -0
  115. package/dist/core/transactional/file-clone.js +139 -0
  116. package/dist/core/transactional/file-tree-path.d.ts +7 -0
  117. package/dist/core/transactional/file-tree-path.js +50 -0
  118. package/dist/core/transactional/file-tree.d.ts +41 -0
  119. package/dist/core/transactional/file-tree.js +186 -0
  120. package/dist/core/transactional/git-worktree-backend.d.ts +2 -0
  121. package/dist/core/transactional/git-worktree-backend.js +50 -0
  122. package/dist/core/transactional/git-worktree.d.ts +9 -11
  123. package/dist/core/transactional/git-worktree.js +24 -119
  124. package/dist/core/transactional/index.d.ts +11 -0
  125. package/dist/core/transactional/index.js +10 -0
  126. package/dist/core/transactional/reasons.js +1 -0
  127. package/dist/core/transactional/runner.js +157 -25
  128. package/dist/core/transactional/snapshot-node.d.ts +26 -0
  129. package/dist/core/transactional/snapshot-node.js +87 -0
  130. package/dist/core/transactional/types.d.ts +17 -1
  131. package/dist/core/types.d.ts +10 -1
  132. package/dist/core/verdict/adapter.js +8 -0
  133. package/dist/core/verdict/launcher-resolve.js +2 -20
  134. package/dist/core/verdict/shell-policy.d.ts +1 -0
  135. package/dist/core/verdict/shell-policy.js +1 -1
  136. package/dist/core/verdict/types.d.ts +5 -0
  137. package/dist/core/verdict/verdict.js +37 -20
  138. package/dist/types.d.ts +11 -0
  139. package/dist/version.d.ts +1 -1
  140. package/dist/version.js +1 -1
  141. package/package.json +1 -1
  142. package/skills/belay/SKILL.md +8 -3
  143. package/skills/belay/belay-approve.md +10 -7
@@ -1,32 +1,36 @@
1
- import { randomUUID } from 'node:crypto';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import { compactApprovals, createApprovalRecordWithEnvelope } from '../../core/approval.js';
6
- import { buildReplayHint, buildRetryInstructionForConfig, getExecutionLeaseMs, replayShellCommand, validateReplayEnvelope, } from '../../core/approval-replay.js';
7
- import { consumeApprovedAfterCliReplay, gateApprovalStoreFromDeps, recordApproval, } from '../../core/approval-service.js';
6
+ import { buildReplayHint, buildRetryInstructionForConfig, canAutoReplay, getExecutionLeaseMs, validateReplayEnvelope, } from '../../core/approval-replay.js';
7
+ import { claimApprovedForReplay, gateApprovalStoreFromDeps, recordApproval, } from '../../core/approval-service.js';
8
8
  import { issueApprovalToken } from '../../core/approval-token.js';
9
9
  import { buildAuditActionSnapshot, buildAuditReplayContext, } from '../../core/audit-replay-context.js';
10
10
  import { mutateApprovalStateWithRetry } from '../../core/capability/approval-state-mutation.js';
11
11
  import { APPROVAL_STATE_VERSION_V3 } from '../../core/capability/approval-v3.js';
12
12
  import { isEgressProxyActive } from '../../core/capability/boundary-egress.js';
13
- import { resolveBoundaryDriverContext } from '../../core/capability/boundary-session.js';
13
+ import { isBoundaryCleanupError, safeBoundaryCleanupResourceId, } from '../../core/capability/boundary-run.js';
14
+ import { resolveBoundaryDriverContext, runBoundaryAgentCommand, } from '../../core/capability/boundary-session.js';
14
15
  import { hashCapabilityRequests } from '../../core/capability/capability-request-hash.js';
15
16
  import { recordGateApprovalAsk, scheduleGateShadowAudit, } from '../../core/capability/gate-shadow-audit.js';
16
- import { consumeGrantLease, findMatchingGrant, grantsFromApprovedState, } from '../../core/capability/grant-lease.js';
17
+ import { canConsumeCapabilityGrantLease } from '../../core/capability/grant-consumption.js';
18
+ import { approvalGrantBundleExhausted, consumeApprovedRecordGrantBundle, consumeGrantLeasesForRequests, decrementApprovalLegacyGrant, grantsFromApproval, validateAndConsumeGrantBundle, validateGrantBundleForLeaseReuse, } from '../../core/capability/grant-lease.js';
17
19
  import { loadClassifierAuthorization } from '../../core/capability/grant-loader.js';
18
20
  import { collectOutsideRepoPaths, collectOutsideRepoPathsFromToolPayload, fsScopeAllowlistPath, isCapabilityBrokerDemotionActive, loadFsScopeAllowlistSync, loadTrustedWorkspaceRootsSync, shouldSkipBrokerApprovedOnce, shouldSkipBrokerApprovedRecord, trustedWorkspaceRootsPath, validateTrustedWorkspaceRootCandidate, } from '../../core/capability/index.js';
19
21
  import { resolveLayeredConfig, teamConfigPath } from '../../core/config-layers.js';
22
+ import { effectPlanAuditFields, hashEffectPlan } from '../../core/effect-ir/audit.js';
23
+ import { buildCapabilityEffectPlan } from '../../core/effect-ir/build.js';
20
24
  import { classifyResultToGateVerdict, unnormalizedGateVerdict, } from '../../core/gate-contract.js';
21
25
  import { classifyGatedActionAsync, extractAgentAssessment, GateNormalizationError, gateEnabledForAction, normalizeGatedAction, } from '../../core/gate-engine.js';
22
- import { approvalCommandMatch, approvedApprovalsFile, belayStateDir, canonicalStringify, classifierOptionsFromConfig, configuredControlPlaneDir, pendingApprovalsFile, resolveControlPlaneDir, scrubOptionsFromConfig, scrubValue, toolFingerprint, } from '../../core/index.js';
26
+ import { approvalCommandMatch, approvedApprovalsFile, belayStateDir, canonicalStringify, classifierOptionsFromConfig, configuredControlPlaneDir, hashValue, pendingApprovalsFile, resolveControlPlaneDir, scrubOptionsFromConfig, scrubValue, toolFingerprint, } from '../../core/index.js';
23
27
  import { extractJudgeFallbackReason, formatJudgeInfrastructureDenyMessage, inferProviderIdFromFallbackReason, isJudgeInfrastructureFailure, } from '../../core/judge-fallback-hints.js';
24
28
  import { notifyDeny } from '../../core/notify.js';
25
29
  import { canonicalPath } from '../../core/path-utils.js';
26
30
  import { recoveryFailClosedResult, recoveryFailReasonFromSkip, } from '../../core/recovery/fail-closed.js';
27
31
  import { fingerprintReplayPayload } from '../../core/replay-scrub.js';
28
32
  import { loadStandingAllow, resolveStandingAllowMatch, standingAllowFile, } from '../../core/standing-allow.js';
29
- import { isTransactionalEligible, runTransactionalExecution, TRANSACTIONAL_ALREADY_APPLIED, TRANSACTIONAL_APPROVAL_BYPASS_REASONS, } from '../../core/transactional/index.js';
33
+ import { FILE_CHECKPOINT_ISOLATION_UNAVAILABLE, isTransactionalEligible, runTransactionalExecution, TRANSACTIONAL_ALREADY_APPLIED, TRANSACTIONAL_APPROVAL_BYPASS_REASONS, } from '../../core/transactional/index.js';
30
34
  import { egressStatus } from '../../services/egress-service.js';
31
35
  import { protectedArtifactRoots } from '../layouts/protected-paths.js';
32
36
  const EMPTY_APPROVALS = {
@@ -44,6 +48,16 @@ function adapterIdFromContext(ctx) {
44
48
  }
45
49
  return undefined;
46
50
  }
51
+ const REPLAY_AUDIT_FAILURE_NOTE = ' Audit recording failed; inspect audit storage before the next approval.';
52
+ async function appendReplayAuditSafely(ctx, deps, event) {
53
+ try {
54
+ await deps.appendAudit(ctx, event);
55
+ return true;
56
+ }
57
+ catch {
58
+ return false;
59
+ }
60
+ }
47
61
  async function loadJsonFile(filePath, fallback) {
48
62
  try {
49
63
  const raw = await readFile(filePath, 'utf8');
@@ -91,6 +105,16 @@ export function createDefaultGateRuntimeDeps() {
91
105
  await mkdir(path.dirname(filePath), { recursive: true });
92
106
  await writeFile(filePath, `${JSON.stringify(compactApprovals(state), null, 2)}\n`, 'utf8');
93
107
  },
108
+ async replayApprovedShell(ctx, command, cwd, timeoutMs) {
109
+ return runBoundaryAgentCommand({
110
+ repoRoot: ctx.repoRoot,
111
+ config: ctx.config,
112
+ command,
113
+ cwd,
114
+ timeoutMs,
115
+ runOptions: { mountReadOnly: false },
116
+ });
117
+ },
94
118
  };
95
119
  }
96
120
  export async function resolveGateConfig(ctx, deps) {
@@ -139,16 +163,7 @@ function gateAuditEventName(kind) {
139
163
  return 'subagentGate';
140
164
  }
141
165
  async function ensurePendingApproval(ctx, deps, kind, result, approvalInput, scopeHint) {
142
- const pending = await deps.loadApprovals(ctx, 'pending-approvals.json');
143
- pending.state = compactApprovals(pending.state);
144
- const existing = pending.state.approvals.find((approval) => approval.kind === kind &&
145
- approval.fingerprint === result.fingerprint &&
146
- approval.repoRoot === ctx.repoRoot);
147
- if (existing) {
148
- await deps.writeApprovals(pending.filePath, pending.state);
149
- return { approval: existing, created: false };
150
- }
151
- const approval = createApprovalRecordWithEnvelope({
166
+ const candidate = createApprovalRecordWithEnvelope({
152
167
  kind,
153
168
  fingerprint: result.fingerprint,
154
169
  repoRoot: ctx.repoRoot,
@@ -159,12 +174,31 @@ async function ensurePendingApproval(ctx, deps, kind, result, approvalInput, sco
159
174
  approvalInput,
160
175
  scopeHint,
161
176
  capabilityRequests: result.capabilityRequests,
177
+ effectPlanHash: result.effectPlan ? hashEffectPlan(result.effectPlan) : undefined,
162
178
  });
163
- pending.state.version = APPROVAL_STATE_VERSION_V3;
164
- pending.state.revision = (pending.state.revision ?? 0) + 1;
165
- pending.state.approvals.push(approval);
166
- await deps.writeApprovals(pending.filePath, pending.state);
167
- return { approval, created: true };
179
+ const outcome = await mutateApprovalStateWithRetry({
180
+ load: () => deps.loadApprovals(ctx, 'pending-approvals.json'),
181
+ write: (filePath, state) => deps.writeApprovals(filePath, state),
182
+ mutate: (state) => {
183
+ const compacted = compactApprovals(state);
184
+ const existing = compacted.approvals.find((approval) => approval.kind === kind &&
185
+ approval.fingerprint === result.fingerprint &&
186
+ approval.repoRoot === ctx.repoRoot);
187
+ if (existing) {
188
+ return { state: compacted, result: { approval: existing, created: false } };
189
+ }
190
+ compacted.version = APPROVAL_STATE_VERSION_V3;
191
+ compacted.approvals.push(candidate);
192
+ return { state: compacted, result: { approval: candidate, created: true } };
193
+ },
194
+ });
195
+ if (!outcome) {
196
+ throw new Error('Failed to persist pending approval');
197
+ }
198
+ return outcome;
199
+ }
200
+ function approvalCorrelationId(approvalId) {
201
+ return createHash('sha256').update(approvalId).digest('hex').slice(0, 16);
168
202
  }
169
203
  function extractShellCommandFromPayload(payload) {
170
204
  const toolInput = payload.tool_input;
@@ -222,21 +256,7 @@ function deriveWorkspaceRootScopeHint(params) {
222
256
  }
223
257
  return { scope: 'workspace-root', path: validation.normalizedPath };
224
258
  }
225
- async function consumeApprovedApproval(ctx, deps, kind, fingerprint) {
226
- const loaded = await deps.loadApprovals(ctx, 'approved-approvals.json');
227
- loaded.state = compactApprovals(loaded.state);
228
- const index = loaded.state.approvals.findIndex((approval) => approval.kind === kind &&
229
- approval.fingerprint === fingerprint &&
230
- approval.repoRoot === ctx.repoRoot);
231
- if (index === -1) {
232
- await deps.writeApprovals(loaded.filePath, loaded.state);
233
- return null;
234
- }
235
- const approval = loaded.state.approvals[index];
236
- if (approval.executionLeaseExpiresAt) {
237
- await deps.writeApprovals(loaded.filePath, loaded.state);
238
- return { approval, firstExecution: false };
239
- }
259
+ async function consumeApprovedApproval(ctx, deps, kind, fingerprint, requests) {
240
260
  const consumed = await mutateApprovalStateWithRetry({
241
261
  load: () => deps.loadApprovals(ctx, 'approved-approvals.json'),
242
262
  write: (filePath, state) => deps.writeApprovals(filePath, state),
@@ -246,30 +266,83 @@ async function consumeApprovedApproval(ctx, deps, kind, fingerprint) {
246
266
  approval.fingerprint === fingerprint &&
247
267
  approval.repoRoot === ctx.repoRoot);
248
268
  if (matchIndex === -1) {
249
- return null;
269
+ return { state: compacted, result: null };
250
270
  }
251
271
  const approval = compacted.approvals[matchIndex];
252
- if (approval.grant && approval.grant.usesRemaining <= 0) {
272
+ if (approval.executionLeaseExpiresAt) {
273
+ if (approval.grantBundleVersion === 1) {
274
+ const validated = validateGrantBundleForLeaseReuse(approval, requests);
275
+ if (!validated.ok) {
276
+ compacted.approvals.splice(matchIndex, 1);
277
+ return {
278
+ state: compacted,
279
+ result: { status: 'invalid_bundle', approval, reason: validated.reason },
280
+ };
281
+ }
282
+ }
283
+ return {
284
+ state: compacted,
285
+ result: { status: 'consumed', approval, firstExecution: false },
286
+ };
287
+ }
288
+ if (approvalGrantBundleExhausted(approval) && !approval.executionLeaseExpiresAt) {
253
289
  compacted.approvals.splice(matchIndex, 1);
254
290
  return { state: compacted, result: null };
255
291
  }
292
+ let updatedApproval = approval;
293
+ const bundle = grantsFromApproval(approval);
294
+ if (approval.grantBundleVersion === 1) {
295
+ const validated = validateAndConsumeGrantBundle(approval, requests);
296
+ if (!validated.ok) {
297
+ compacted.approvals.splice(matchIndex, 1);
298
+ return {
299
+ state: compacted,
300
+ result: { status: 'invalid_bundle', approval, reason: validated.reason },
301
+ };
302
+ }
303
+ updatedApproval = validated.approval;
304
+ }
305
+ else if (bundle.length > 0) {
306
+ const consumed = consumeApprovedRecordGrantBundle(approval);
307
+ if (!consumed.consumed) {
308
+ return { state: compacted, result: null };
309
+ }
310
+ updatedApproval = consumed.approval;
311
+ }
312
+ else if (approval.grant) {
313
+ updatedApproval = decrementApprovalLegacyGrant(approval);
314
+ }
256
315
  compacted.approvals[matchIndex] = {
257
- ...approval,
316
+ ...updatedApproval,
258
317
  executionLeaseExpiresAt: new Date(Date.now() + getExecutionLeaseMs(ctx.config)).toISOString(),
259
- grant: approval.grant
260
- ? {
261
- ...approval.grant,
262
- usesRemaining: Math.max(0, approval.grant.usesRemaining - 1),
263
- }
264
- : undefined,
265
318
  };
266
- return { state: compacted, result: approval };
319
+ return {
320
+ state: compacted,
321
+ result: { status: 'consumed', approval: updatedApproval, firstExecution: true },
322
+ };
267
323
  },
268
324
  });
269
325
  if (!consumed) {
270
326
  return null;
271
327
  }
272
- return { approval: consumed, firstExecution: true };
328
+ return consumed;
329
+ }
330
+ async function discardApprovedApproval(ctx, deps, approvalId) {
331
+ const discarded = await mutateApprovalStateWithRetry({
332
+ load: () => deps.loadApprovals(ctx, 'approved-approvals.json'),
333
+ write: (filePath, state) => deps.writeApprovals(filePath, state),
334
+ mutate: (state) => {
335
+ const compacted = compactApprovals(state);
336
+ const index = compacted.approvals.findIndex((approval) => approval.approvalId === approvalId);
337
+ if (index !== -1) {
338
+ compacted.approvals.splice(index, 1);
339
+ }
340
+ return { state: compacted, result: true };
341
+ },
342
+ });
343
+ if (discarded !== true) {
344
+ throw new Error(`Failed to discard rejected approval ${approvalId}`);
345
+ }
273
346
  }
274
347
  export async function evaluateGatedAction(ctx, deps, params) {
275
348
  let action;
@@ -285,37 +358,65 @@ export async function evaluateGatedAction(ctx, deps, params) {
285
358
  });
286
359
  }
287
360
  catch {
288
- const verdict = unnormalizedGateVerdict({
289
- reason: 'normalization_failed',
290
- mode: ctx.config.mode,
291
- user_message: 'belay could not normalize this gated action. Run belay doctor, then retry.',
292
- agent_message: 'Belay denied this action because the hook payload could not be normalized.',
293
- });
361
+ const scrubbedInput = scrubValue({
362
+ kind: params.kind,
363
+ cwd: params.cwd,
364
+ command: params.command,
365
+ payload: params.payload,
366
+ toolName: params.toolName,
367
+ }, scrubOptionsFromConfig(ctx.config));
368
+ const inputFingerprint = hashValue(`unnormalized-gated-action:v1:${canonicalStringify(scrubbedInput)}`);
369
+ const verdict = {
370
+ ...unnormalizedGateVerdict({
371
+ reason: 'normalization_failed',
372
+ mode: ctx.config.mode,
373
+ user_message: 'belay could not normalize this gated action. Run belay doctor, then retry.',
374
+ agent_message: 'Belay denied this action because the hook payload could not be normalized.',
375
+ }),
376
+ fingerprint: inputFingerprint,
377
+ effectPlan: buildCapabilityEffectPlan({
378
+ actionKind: params.kind,
379
+ summary: params.command ?? params.toolName ?? params.kind,
380
+ inputFingerprint,
381
+ requests: [],
382
+ effectFree: false,
383
+ }),
384
+ };
294
385
  await deps.appendAudit(ctx, {
295
386
  event: gateAuditEventName(params.kind),
296
387
  kind: params.kind,
388
+ fingerprint: verdict.fingerprint,
297
389
  verdict: verdict.verdict,
298
390
  reason: verdict.reason,
299
391
  mode: ctx.config.mode,
392
+ ...effectPlanAuditFields(verdict.effectPlan),
300
393
  wouldBlock: true,
301
394
  permission: 'deny',
302
395
  });
303
396
  return verdict;
304
397
  }
305
398
  if (!gateEnabledForAction(ctx.config, action)) {
306
- return classifyResultToGateVerdict({
307
- result: {
308
- verdict: 'allow',
309
- reason: 'gate_disabled',
310
- fingerprint: 'gate_disabled',
311
- assessment: {
312
- reversibility: 'reversible',
313
- external: false,
314
- blastRadius: 'none',
315
- confidence: 1,
316
- signals: ['gate_disabled'],
317
- },
399
+ const result = {
400
+ verdict: 'allow',
401
+ reason: 'gate_disabled',
402
+ fingerprint: 'gate_disabled',
403
+ assessment: {
404
+ reversibility: 'reversible',
405
+ external: false,
406
+ blastRadius: 'none',
407
+ confidence: 1,
408
+ signals: ['gate_disabled'],
318
409
  },
410
+ effectPlan: buildCapabilityEffectPlan({
411
+ actionKind: action.kind,
412
+ summary: action.command ?? action.toolName ?? action.kind,
413
+ inputFingerprint: 'gate_disabled',
414
+ requests: [],
415
+ effectFree: false,
416
+ }),
417
+ };
418
+ return classifyResultToGateVerdict({
419
+ result,
319
420
  mode: ctx.config.mode,
320
421
  permission: 'allow',
321
422
  wouldBlock: false,
@@ -362,7 +463,7 @@ export async function evaluateGatedAction(ctx, deps, params) {
362
463
  command: params.command,
363
464
  cwd: params.cwd,
364
465
  repoRoot: ctx.repoRoot,
365
- stateDir: path.join(ctx.layout.repoLocalStateDir(ctx.repoRoot), 'transactional'),
466
+ stateDir: belayStateDir(ctx.config, ctx.layout.repoLocalStateDir(ctx.repoRoot)),
366
467
  timeoutMs: transactional.timeoutMs,
367
468
  predicted,
368
469
  diffContext: {
@@ -373,6 +474,8 @@ export async function evaluateGatedAction(ctx, deps, params) {
373
474
  },
374
475
  boundaryContext,
375
476
  dirtyIgnoreRoots: protectedArtifactRoots(ctx.layout, ctx.repoRoot, ctx.config.controlPlane.enabled ? configuredControlPlaneDir(ctx.config) : null),
477
+ fileCheckpoint: transactional.fileCheckpoint,
478
+ checkpoint: transactional.checkpoint,
376
479
  });
377
480
  if (!txResult.skipped && txResult.observed) {
378
481
  result = txResult.result;
@@ -380,20 +483,48 @@ export async function evaluateGatedAction(ctx, deps, params) {
380
483
  observedAssessment = txResult.observed.assessment;
381
484
  transactionalLayer = {
382
485
  transactional: true,
486
+ transactionalBackend: txResult.transactionalBackend,
487
+ resourceKind: txResult.resourceKind,
488
+ baselineTreeHash: txResult.baselineTreeHash,
489
+ snapshotFileCount: txResult.snapshotFileCount,
490
+ snapshotSourceBytes: txResult.snapshotSourceBytes,
491
+ snapshotWorkspaceBytes: txResult.snapshotWorkspaceBytes,
492
+ snapshotCopyStrategy: txResult.snapshotCopyStrategy,
493
+ snapshotPrepareMs: txResult.snapshotPrepareMs,
494
+ executionRoute: `boundary:${boundaryContext.driverId}`,
495
+ enforcementStatus: 'mediated_unattested',
383
496
  transactionalReason: txResult.observed.reason,
384
497
  transactionalCategories: txResult.observed.categories,
385
498
  transactionalChangeCount: txResult.observed.changes.length,
386
499
  transactionalTimedOut: txResult.timedOut === true,
500
+ ...(txResult.recoveryCheckpointId
501
+ ? {
502
+ recoveryCheckpointId: txResult.recoveryCheckpointId,
503
+ recoveryBackend: txResult.recoveryBackend,
504
+ recoveryProofHash: txResult.recoveryProofHash,
505
+ recoveryState: txResult.recoveryState,
506
+ }
507
+ : {}),
387
508
  };
388
509
  }
389
510
  else {
390
511
  const skipReason = txResult.skipReason ?? 'recovery_observation_failed';
391
- result = recoveryFailClosedResult(predicted, recoveryFailReasonFromSkip(skipReason), [
392
- skipReason,
393
- ]);
512
+ const failReason = skipReason === FILE_CHECKPOINT_ISOLATION_UNAVAILABLE
513
+ ? FILE_CHECKPOINT_ISOLATION_UNAVAILABLE
514
+ : recoveryFailReasonFromSkip(skipReason);
515
+ result = recoveryFailClosedResult(predicted, failReason, [skipReason]);
394
516
  transactionalLayer = {
395
517
  transactional: false,
518
+ transactionalBackend: txResult.transactionalBackend,
519
+ resourceKind: txResult.resourceKind,
520
+ baselineTreeHash: txResult.baselineTreeHash,
521
+ snapshotFileCount: txResult.snapshotFileCount,
522
+ snapshotSourceBytes: txResult.snapshotSourceBytes,
523
+ snapshotWorkspaceBytes: txResult.snapshotWorkspaceBytes,
524
+ snapshotCopyStrategy: txResult.snapshotCopyStrategy,
525
+ snapshotPrepareMs: txResult.snapshotPrepareMs,
396
526
  transactionalSkipReason: skipReason,
527
+ transactionalSkipDetail: txResult.skipDetail,
397
528
  recoveryFailClosed: true,
398
529
  };
399
530
  }
@@ -473,20 +604,19 @@ async function consumeCapabilityGrantIfUsed(ctx, deps, result) {
473
604
  result.authorizationDecision?.matchedRule !== 'grant.exact') {
474
605
  return true;
475
606
  }
476
- const request = result.capabilityRequests?.[0];
477
- if (!request) {
607
+ if (!canConsumeCapabilityGrantLease(result)) {
478
608
  return false;
479
609
  }
610
+ const requests = result.capabilityRequests ?? [];
611
+ if (!requests.length) {
612
+ return true;
613
+ }
480
614
  const consumed = await mutateApprovalStateWithRetry({
481
615
  load: () => deps.loadApprovals(ctx, 'approved-approvals.json'),
482
616
  write: (filePath, state) => deps.writeApprovals(filePath, state),
483
617
  mutate: (state) => {
484
618
  const compacted = compactApprovals(state);
485
- const grant = findMatchingGrant(grantsFromApprovedState(compacted, ctx.repoRoot), request);
486
- if (!grant) {
487
- return null;
488
- }
489
- const lease = consumeGrantLease(compacted, grant.grantId);
619
+ const lease = consumeGrantLeasesForRequests(compacted, requests);
490
620
  if (!lease.consumed) {
491
621
  return null;
492
622
  }
@@ -496,6 +626,18 @@ async function consumeCapabilityGrantIfUsed(ctx, deps, result) {
496
626
  return consumed === true;
497
627
  }
498
628
  async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {}) {
629
+ if (!result.effectPlan) {
630
+ result = {
631
+ ...result,
632
+ effectPlan: buildCapabilityEffectPlan({
633
+ actionKind: kind,
634
+ summary: result.normalizedCommand ?? result.summary ?? kind,
635
+ inputFingerprint: result.fingerprint,
636
+ requests: result.capabilityRequests ?? [],
637
+ effectFree: result.reason === 'read_only',
638
+ }),
639
+ };
640
+ }
499
641
  const replayContext = buildAuditReplayContext(kind, result, auditExtras.replayAction);
500
642
  const actionSnapshot = buildAuditActionSnapshot(kind, result, auditExtras.replayAction);
501
643
  const providerId = String(ctx.config.judge?.providerId ?? 'cursor');
@@ -520,11 +662,92 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
520
662
  mode: ctx.config.mode,
521
663
  schemaVersion: result.axes ? 2 : 1,
522
664
  ...(result.axes ?? {}),
523
- ...auditExtras.transactionalLayer,
524
665
  ...capabilityAudit,
666
+ ...auditExtras.transactionalLayer,
525
667
  ...(replayContext ? { replayContext } : {}),
526
668
  ...(actionSnapshot ? { actionSnapshot } : {}),
527
669
  };
670
+ const denyWithPendingApproval = async (failure) => {
671
+ const { approval, created } = await ensurePendingApproval(ctx, deps, kind, result, auditExtras.approvalInput, deriveWorkspaceRootScopeHint({
672
+ result,
673
+ replayAction: auditExtras.replayAction,
674
+ payloadForScopeHint: auditExtras.scopeHintPayload,
675
+ options: auditExtras.classifierOptions ?? {},
676
+ }));
677
+ if (created) {
678
+ await recordGateApprovalAsk(stateDir, result.reason, false);
679
+ }
680
+ let approvalToken;
681
+ try {
682
+ approvalToken = await issueApprovalToken({
683
+ approvalId: approval.approvalId,
684
+ fingerprint: approval.fingerprint,
685
+ repoRoot: approval.repoRoot,
686
+ issuedAt: approval.createdAt,
687
+ expiresAt: approval.expiresAt,
688
+ }, configuredControlPlaneDir(ctx.config));
689
+ }
690
+ catch {
691
+ approvalToken = undefined;
692
+ }
693
+ const denialReason = failure?.reason ?? result.reason;
694
+ if (ctx.config.notifications.webhookUrl || ctx.config.notifications.commandHook) {
695
+ await notifyDeny(ctx.config.notifications, {
696
+ approvalId: approval.approvalId,
697
+ reason: denialReason,
698
+ summary: result.normalizedCommand ?? result.summary ?? '',
699
+ repoRoot: ctx.repoRoot,
700
+ fingerprint: result.fingerprint,
701
+ approvalToken,
702
+ });
703
+ }
704
+ const failureAuditFields = typeof failure?.auditFields === 'function'
705
+ ? failure.auditFields(approval)
706
+ : failure?.auditFields;
707
+ await deps.appendAudit(ctx, {
708
+ ...gateBase,
709
+ verdict: failure ? 'deny_pending_approval' : result.verdict,
710
+ reason: denialReason,
711
+ approvalId: approval.approvalId,
712
+ ...(failure?.grantBundleFailureReason
713
+ ? { grantBundleFailureReason: failure.grantBundleFailureReason }
714
+ : {}),
715
+ ...failureAuditFields,
716
+ wouldBlock: true,
717
+ permission: 'deny',
718
+ });
719
+ const adapter = adapterIdFromContext(ctx);
720
+ const autoReplayShell = kind === 'shell' && canAutoReplay(ctx.config, kind, adapter);
721
+ return classifyResultToGateVerdict({
722
+ result: failure
723
+ ? { ...result, verdict: 'deny_pending_approval', reason: denialReason }
724
+ : result,
725
+ mode: ctx.config.mode,
726
+ permission: 'deny',
727
+ wouldBlock: true,
728
+ approvalId: approval.approvalId,
729
+ user_message: failure?.userMessage(approval.approvalId) ??
730
+ `Belay blocked this high-risk action. Approval ID: ${approval.approvalId}. ${buildRetryInstructionForConfig(ctx.config, ctx.config.tokenPrefix, approval.approvalId, kind, adapter)} For details, run belay explain or /belay why.`,
731
+ agent_message: failure?.agentMessage ??
732
+ (autoReplayShell
733
+ ? `Belay denied this action as ${result.reason}. Wait for approval; Belay will replay the exact shell action automatically. Do not retry unless replay fails.`
734
+ : `Belay denied this action as ${result.reason}. Wait for approval, then retry the exact same action once.`),
735
+ });
736
+ };
737
+ const denyReplayMismatch = async (approval, mismatchKind, messages) => {
738
+ await discardApprovedApproval(ctx, deps, approval.approvalId);
739
+ return denyWithPendingApproval({
740
+ reason: 'approval_replay_mismatch',
741
+ auditFields: (replacement) => ({
742
+ rejectedApprovalId: approval.approvalId,
743
+ rejectedApprovalCorrelationId: approvalCorrelationId(approval.approvalId),
744
+ replacementApprovalCorrelationId: approvalCorrelationId(replacement.approvalId),
745
+ replayMismatchKind: mismatchKind,
746
+ }),
747
+ userMessage: (approvalId) => `${messages.user} New approval ID: ${approvalId}.`,
748
+ agentMessage: `${messages.agent} Wait for the new exact approval, then retry once.`,
749
+ });
750
+ };
528
751
  if (result.reason === TRANSACTIONAL_ALREADY_APPLIED) {
529
752
  const userMessage = 'Belay executed this command safely in an isolated git worktree. Observed-safe file changes are already applied; do not retry the same command.';
530
753
  const agentMessage = 'Belay already applied the observed-safe effects of this shell command in isolation. Do not run it again.';
@@ -585,61 +808,47 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
585
808
  entry.fingerprint === result.fingerprint &&
586
809
  entry.repoRoot === ctx.repoRoot);
587
810
  if (!shouldSkipBrokerApprovedRecord(brokerActive, matchedApproval?.reason)) {
588
- if (matchedApproval &&
589
- matchedApproval.capabilityRequestHash &&
590
- result.capabilityRequests?.length &&
591
- matchedApproval.capabilityRequestHash !== hashCapabilityRequests(result.capabilityRequests)) {
592
- await deps.appendAudit(ctx, {
593
- ...gateBase,
594
- verdict: 'deny_pending_approval',
595
- reason: 'approval_replay_mismatch',
596
- approvalId: matchedApproval.approvalId,
597
- wouldBlock: true,
598
- permission: 'deny',
811
+ if (matchedApproval?.capabilityRequestHash &&
812
+ (!result.capabilityRequests?.length ||
813
+ matchedApproval.capabilityRequestHash !==
814
+ hashCapabilityRequests(result.capabilityRequests))) {
815
+ return denyReplayMismatch(matchedApproval, 'capability_requests', {
816
+ user: 'Belay denied this action because capability requests changed after approval. Re-approve the exact action or run belay explain.',
817
+ agent: 'Belay denied this action because capability requests changed after approval.',
599
818
  });
600
- return classifyResultToGateVerdict({
601
- result: {
602
- ...result,
603
- verdict: 'deny_pending_approval',
604
- reason: 'approval_replay_mismatch',
605
- },
606
- mode: ctx.config.mode,
607
- permission: 'deny',
608
- wouldBlock: true,
609
- approvalId: matchedApproval.approvalId,
610
- user_message: 'Belay denied this action because capability requests changed after approval. Re-approve the exact action or run belay explain.',
611
- agent_message: 'Belay denied this action because capability requests changed after approval.',
819
+ }
820
+ if (matchedApproval?.effectPlanHash &&
821
+ (!result.effectPlan || matchedApproval.effectPlanHash !== hashEffectPlan(result.effectPlan))) {
822
+ return denyReplayMismatch(matchedApproval, 'effect_plan', {
823
+ user: 'Belay denied this action because the effect plan changed after approval. Re-approve the exact action or run belay explain.',
824
+ agent: 'Belay denied this action because the effect plan changed after approval.',
612
825
  });
613
826
  }
614
827
  if (matchedApproval &&
615
828
  auditExtras.replayAction &&
616
829
  !validateReplayEnvelope(matchedApproval, auditExtras.replayAction)) {
617
- await deps.appendAudit(ctx, {
618
- ...gateBase,
619
- verdict: 'deny_pending_approval',
620
- reason: 'approval_replay_mismatch',
621
- approvalId: matchedApproval.approvalId,
622
- wouldBlock: true,
623
- permission: 'deny',
624
- });
625
- return classifyResultToGateVerdict({
626
- result: {
627
- ...result,
628
- verdict: 'deny_pending_approval',
629
- reason: 'approval_replay_mismatch',
630
- },
631
- mode: ctx.config.mode,
632
- permission: 'deny',
633
- wouldBlock: true,
634
- approvalId: matchedApproval.approvalId,
635
- user_message: 'Belay denied this action because it does not match the approved replay envelope. Re-approve the exact action or run belay explain.',
636
- agent_message: 'Belay denied this action because cwd, tool, or payload changed after approval.',
830
+ return denyReplayMismatch(matchedApproval, 'replay_envelope', {
831
+ user: 'Belay denied this action because it does not match the approved replay envelope. Re-approve the exact action or run belay explain.',
832
+ agent: 'Belay denied this action because cwd, tool, or payload changed after approval.',
637
833
  });
638
834
  }
639
- approved = await consumeApprovedApproval(ctx, deps, kind, result.fingerprint);
835
+ approved = await consumeApprovedApproval(ctx, deps, kind, result.fingerprint, result.capabilityRequests ?? []);
640
836
  }
641
837
  }
642
- if (approved) {
838
+ if (approved?.status === 'invalid_bundle') {
839
+ return denyWithPendingApproval({
840
+ reason: 'capability_grant_unavailable',
841
+ grantBundleFailureReason: approved.reason,
842
+ auditFields: (replacement) => ({
843
+ rejectedApprovalId: approved.approval.approvalId,
844
+ rejectedApprovalCorrelationId: approvalCorrelationId(approved.approval.approvalId),
845
+ replacementApprovalCorrelationId: approvalCorrelationId(replacement.approvalId),
846
+ }),
847
+ userMessage: (approvalId) => `Belay denied this action because its approved capability bundle did not exactly match the current request. New approval ID: ${approvalId}. Re-approve the exact action or run belay explain.`,
848
+ agentMessage: `Belay denied this action because the approved capability bundle failed exact validation (${approved.reason}). Wait for the new exact approval, then retry once.`,
849
+ });
850
+ }
851
+ if (approved?.status === 'consumed') {
643
852
  if (approved.firstExecution) {
644
853
  await recordGateApprovalAsk(stateDir, result.reason, true);
645
854
  }
@@ -648,6 +857,7 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
648
857
  verdict: 'allow',
649
858
  reason: 'approved_once',
650
859
  approvalId: approved.approval.approvalId,
860
+ authorizationMode: approved.approval.grantBundleVersion === 1 ? 'exact_bundle' : 'legacy_approval',
651
861
  wouldBlock: false,
652
862
  permission: 'allow',
653
863
  });
@@ -740,61 +950,33 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
740
950
  agent_message: denyMessages.agent_message,
741
951
  });
742
952
  }
743
- const { approval, created } = await ensurePendingApproval(ctx, deps, kind, result, auditExtras.approvalInput, deriveWorkspaceRootScopeHint({
744
- result,
745
- replayAction: auditExtras.replayAction,
746
- payloadForScopeHint: auditExtras.scopeHintPayload,
747
- options: auditExtras.classifierOptions ?? {},
748
- }));
749
- if (created) {
750
- await recordGateApprovalAsk(stateDir, result.reason, false);
751
- }
752
- let approvalToken;
753
- try {
754
- approvalToken = await issueApprovalToken({
755
- approvalId: approval.approvalId,
756
- fingerprint: approval.fingerprint,
757
- repoRoot: approval.repoRoot,
758
- issuedAt: approval.createdAt,
759
- expiresAt: approval.expiresAt,
760
- }, configuredControlPlaneDir(ctx.config));
761
- }
762
- catch {
763
- approvalToken = undefined;
764
- }
765
- if (ctx.config.notifications.webhookUrl || ctx.config.notifications.commandHook) {
766
- await notifyDeny(ctx.config.notifications, {
767
- approvalId: approval.approvalId,
953
+ if (result.reason === FILE_CHECKPOINT_ISOLATION_UNAVAILABLE) {
954
+ const userMessage = 'Belay cannot run file_checkpoint because attested workspace isolation is unavailable. Run `belay session start`, then retry.';
955
+ const agentMessage = 'Belay denied this action because file_checkpoint lacks a fresh matching workspace-isolation attestation. Ask the operator to run `belay session start`, then retry the exact action.';
956
+ await deps.appendAudit(ctx, {
957
+ ...gateBase,
958
+ verdict: result.verdict,
768
959
  reason: result.reason,
769
- summary: result.normalizedCommand ?? result.summary ?? '',
770
- repoRoot: ctx.repoRoot,
771
- fingerprint: result.fingerprint,
772
- approvalToken,
960
+ wouldBlock: true,
961
+ permission: 'deny',
962
+ });
963
+ return classifyResultToGateVerdict({
964
+ result,
965
+ mode: ctx.config.mode,
966
+ permission: 'deny',
967
+ wouldBlock: true,
968
+ user_message: userMessage,
969
+ agent_message: agentMessage,
773
970
  });
774
971
  }
775
- await deps.appendAudit(ctx, {
776
- ...gateBase,
777
- verdict: result.verdict,
778
- reason: result.reason,
779
- approvalId: approval.approvalId,
780
- wouldBlock: true,
781
- permission: 'deny',
782
- });
783
- return classifyResultToGateVerdict({
784
- result,
785
- mode: ctx.config.mode,
786
- permission: 'deny',
787
- wouldBlock: true,
788
- approvalId: approval.approvalId,
789
- user_message: `Belay blocked this high-risk action. Approval ID: ${approval.approvalId}. ${buildRetryInstructionForConfig(ctx.config, ctx.config.tokenPrefix, approval.approvalId)} For details, run belay explain or /belay why.`,
790
- agent_message: `Belay denied this action as ${result.reason}. Wait for approval, then retry the exact same action once.`,
791
- });
972
+ return denyWithPendingApproval();
792
973
  }
793
974
  export async function processApprovalPrompt(ctx, deps, prompt) {
794
975
  const approvalId = approvalCommandMatch(prompt, ctx.config.tokenPrefix);
795
976
  if (!approvalId) {
796
977
  return { continue: true };
797
978
  }
979
+ const hasFollowupInstruction = prompt.split(/\r?\n/).filter((line) => line.trim().length > 0).length > 1;
798
980
  const adapter = adapterIdFromContext(ctx);
799
981
  if (ctx.config.approvalSigning.required) {
800
982
  const message = `Signed approval token required for ${approvalId}. Editor prompt approval is disabled in this configuration. ` +
@@ -837,62 +1019,68 @@ export async function processApprovalPrompt(ctx, deps, prompt) {
837
1019
  };
838
1020
  }
839
1021
  const replay = recorded.approval ? buildReplayHint(ctx.config, recorded.approval, adapter) : null;
840
- const shouldFallbackReplay = adapter === 'cursor' &&
841
- replay?.kind === 'shell' &&
1022
+ const shouldReplayApprovedShell = replay?.kind === 'shell' &&
842
1023
  replay.autoReplay === true &&
843
1024
  Boolean(recorded.approval?.input) &&
844
1025
  (!process.env.VITEST || process.env.BELAY_TEST_APPROVAL_REPLAY === '1');
845
- if (shouldFallbackReplay && recorded.approval) {
1026
+ if (shouldReplayApprovedShell && recorded.approval) {
846
1027
  const approvalStore = gateApprovalStoreFromDeps({
847
1028
  loadApprovals: (fileName) => deps.loadApprovals(ctx, fileName),
848
1029
  writeApprovals: (filePath, state) => deps.writeApprovals(filePath, state),
849
1030
  });
1031
+ let claimed;
1032
+ try {
1033
+ claimed = await claimApprovedForReplay({ approvalId, store: approvalStore });
1034
+ }
1035
+ catch {
1036
+ return {
1037
+ continue: false,
1038
+ user_message: `Belay approval ${approvalId} could not be claimed for one-step replay. ` +
1039
+ 'The shell action was not started.',
1040
+ };
1041
+ }
1042
+ if (!claimed) {
1043
+ return {
1044
+ continue: false,
1045
+ user_message: `Belay approval ${approvalId} could not be claimed for one-step replay.`,
1046
+ };
1047
+ }
850
1048
  let replayResult;
851
1049
  try {
852
- replayResult = await replayShellCommand(recorded.approval.input ?? '', recorded.approval.cwd ?? ctx.repoRoot, getExecutionLeaseMs(ctx.config));
1050
+ replayResult = await deps.replayApprovedShell(ctx, claimed.input ?? '', claimed.cwd ?? ctx.repoRoot, getExecutionLeaseMs(ctx.config));
853
1051
  }
854
1052
  catch (error) {
855
- const detail = error instanceof Error ? error.message : String(error);
856
- await deps.appendAudit(ctx, {
1053
+ const cleanupUnconfirmed = isBoundaryCleanupError(error);
1054
+ const auditRecorded = await appendReplayAuditSafely(ctx, deps, {
857
1055
  event: 'approval',
858
1056
  kind: 'approval',
859
1057
  verdict: 'allow',
860
1058
  approvalId,
861
- reason: 'approval_replay_error',
1059
+ reason: cleanupUnconfirmed
1060
+ ? 'approval_replay_cleanup_unconfirmed'
1061
+ : 'approval_replay_error',
862
1062
  summary: recorded.approval.input ?? recorded.approval.summary ?? prompt,
863
1063
  });
864
- return {
865
- continue: false,
866
- user_message: `Belay approval recorded for ${approvalId}, but one-step shell replay could not start (${detail}). ` +
867
- 'Approval remains active for one hook retry.',
868
- };
869
- }
870
- const output = [replayResult.stdout, replayResult.stderr].filter(Boolean).join('\n').trim();
871
- if (replayResult.exitCode === 0) {
872
- try {
873
- await consumeApprovedAfterCliReplay({
874
- approvalId: approvalId,
875
- store: approvalStore,
876
- });
877
- }
878
- catch (error) {
879
- const detail = error instanceof Error ? error.message : String(error);
880
- await deps.appendAudit(ctx, {
881
- event: 'approval',
882
- kind: 'approval',
883
- verdict: 'allow',
884
- approvalId,
885
- reason: 'approval_replay_consume_error',
886
- summary: recorded.approval.input ?? recorded.approval.summary ?? prompt,
887
- });
1064
+ if (cleanupUnconfirmed) {
1065
+ const resourceId = safeBoundaryCleanupResourceId(error);
888
1066
  return {
889
1067
  continue: false,
890
- user_message: `Belay approval recorded for ${approvalId}, and one-step shell replay succeeded, but approval finalization failed (${detail}). ` +
891
- 'Approval remains active for one hook retry.' +
892
- (output ? `\n${output}` : ''),
1068
+ user_message: `Belay approval recorded for ${approvalId}. The one-step shell replay started, but cleanup could not be confirmed; the container may still be running.` +
1069
+ (resourceId ? ` Container: ${resourceId}.` : '') +
1070
+ ' Inspect Docker and remove the container manually if it is still present. ' +
1071
+ 'The one-shot approval was consumed and was not re-armed.' +
1072
+ (auditRecorded ? '' : REPLAY_AUDIT_FAILURE_NOTE),
893
1073
  };
894
1074
  }
895
- await deps.appendAudit(ctx, {
1075
+ return {
1076
+ continue: false,
1077
+ user_message: `Belay approval recorded for ${approvalId}, but one-step shell replay could not start. ` +
1078
+ 'The one-shot approval was consumed before replay and was not re-armed.' +
1079
+ (auditRecorded ? '' : REPLAY_AUDIT_FAILURE_NOTE),
1080
+ };
1081
+ }
1082
+ if (replayResult.exitCode === 0 && !replayResult.timedOut) {
1083
+ const auditRecorded = await appendReplayAuditSafely(ctx, deps, {
896
1084
  event: 'approval',
897
1085
  kind: 'approval',
898
1086
  verdict: 'allow',
@@ -901,12 +1089,12 @@ export async function processApprovalPrompt(ctx, deps, prompt) {
901
1089
  summary: recorded.approval.input ?? recorded.approval.summary ?? prompt,
902
1090
  });
903
1091
  return {
904
- continue: false,
1092
+ continue: true,
905
1093
  user_message: `Belay approval recorded for ${approvalId}. One-step shell replay succeeded; no manual retry required.` +
906
- (output ? `\n${output}` : ''),
1094
+ (auditRecorded ? '' : REPLAY_AUDIT_FAILURE_NOTE),
907
1095
  };
908
1096
  }
909
- await deps.appendAudit(ctx, {
1097
+ const auditRecorded = await appendReplayAuditSafely(ctx, deps, {
910
1098
  event: 'approval',
911
1099
  kind: 'approval',
912
1100
  verdict: 'allow',
@@ -914,16 +1102,18 @@ export async function processApprovalPrompt(ctx, deps, prompt) {
914
1102
  reason: 'approval_replay_failed',
915
1103
  summary: recorded.approval.input ?? recorded.approval.summary ?? prompt,
916
1104
  });
917
- const timeoutNote = replayResult.timedOut ? ' Replay timed out.' : '';
1105
+ const failureSummary = replayResult.timedOut
1106
+ ? ' One-step shell replay timed out.'
1107
+ : ` One-step shell replay failed (exit ${replayResult.exitCode}).`;
918
1108
  return {
919
1109
  continue: false,
920
- user_message: `Belay approval recorded for ${approvalId}, but one-step shell replay failed (exit ${replayResult.exitCode}).${timeoutNote} ` +
921
- `Approval remains active for one hook retry.` +
922
- (output ? `\n${output}` : ''),
1110
+ user_message: `Belay approval recorded for ${approvalId}.${failureSummary} ` +
1111
+ 'The one-shot approval was consumed before replay and was not re-armed.' +
1112
+ (auditRecorded ? '' : REPLAY_AUDIT_FAILURE_NOTE),
923
1113
  };
924
1114
  }
925
1115
  return {
926
- continue: false,
1116
+ continue: replay !== null || hasFollowupInstruction,
927
1117
  user_message: recorded.message,
928
1118
  ...(replay ? { replay } : {}),
929
1119
  };
@@ -951,15 +1141,18 @@ export function gateVerdictToClaudePreToolUseResponse(verdict) {
951
1141
  }
952
1142
  export function gateVerdictToClaudeUserPromptResponse(verdict) {
953
1143
  if (verdict.continue) {
954
- return {};
1144
+ return verdict.user_message
1145
+ ? {
1146
+ hookSpecificOutput: {
1147
+ hookEventName: 'UserPromptSubmit',
1148
+ additionalContext: verdict.user_message,
1149
+ },
1150
+ }
1151
+ : {};
955
1152
  }
956
1153
  return {
957
- hookSpecificOutput: {
958
- hookEventName: 'UserPromptSubmit',
959
- continue: false,
960
- user_message: verdict.user_message,
961
- ...(verdict.replay ? { replay: verdict.replay } : {}),
962
- },
1154
+ decision: 'block',
1155
+ reason: verdict.user_message,
963
1156
  };
964
1157
  }
965
1158
  // Codex PreToolUse deny contract is identical to Claude's
@@ -970,7 +1163,14 @@ export function gateVerdictToCodexPreToolUseResponse(verdict) {
970
1163
  // Codex UserPromptSubmit blocks via `decision: "block"` (per developers.openai.com/codex/hooks).
971
1164
  export function gateVerdictToCodexUserPromptResponse(verdict) {
972
1165
  if (verdict.continue) {
973
- return {};
1166
+ return verdict.user_message
1167
+ ? {
1168
+ hookSpecificOutput: {
1169
+ hookEventName: 'UserPromptSubmit',
1170
+ additionalContext: verdict.user_message,
1171
+ },
1172
+ }
1173
+ : {};
974
1174
  }
975
1175
  return {
976
1176
  decision: 'block',