@guilz-dev/belay 0.6.0 → 0.8.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 (183) hide show
  1. package/README.md +39 -20
  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 +443 -225
  5. package/dist/bundle/claude-runtime.mjs +13027 -7271
  6. package/dist/bundle/codex-runtime.mjs +12700 -6977
  7. package/dist/bundle/cursor-runtime.mjs +12695 -6977
  8. package/dist/cli.js +37 -1
  9. package/dist/commands/approve.js +34 -10
  10. package/dist/commands/doctor.js +58 -0
  11. package/dist/commands/dogfood.js +1 -1
  12. package/dist/commands/explain.js +2 -2
  13. package/dist/commands/harvest.d.ts +0 -1
  14. package/dist/commands/harvest.js +1 -4
  15. package/dist/commands/metrics.js +6 -0
  16. package/dist/commands/quality.js +1 -3
  17. package/dist/commands/recovery-checkpoints.d.ts +164 -0
  18. package/dist/commands/recovery-checkpoints.js +347 -0
  19. package/dist/commands/revoke.js +19 -7
  20. package/dist/commands/status.js +7 -0
  21. package/dist/config-io.js +49 -18
  22. package/dist/conformance/guarantee-table.js +137 -9
  23. package/dist/conformance/types.d.ts +2 -0
  24. package/dist/core/approval-replay.d.ts +1 -1
  25. package/dist/core/approval-replay.js +4 -1
  26. package/dist/core/approval-service.d.ts +6 -1
  27. package/dist/core/approval-service.js +86 -27
  28. package/dist/core/approval.d.ts +2 -0
  29. package/dist/core/approval.js +6 -3
  30. package/dist/core/audit-metrics.d.ts +1 -0
  31. package/dist/core/audit-metrics.js +12 -4
  32. package/dist/core/audit-types.d.ts +16 -0
  33. package/dist/core/capability/approval-state-mutation.d.ts +19 -0
  34. package/dist/core/capability/approval-state-mutation.js +76 -3
  35. package/dist/core/capability/approval-v3.d.ts +18 -1
  36. package/dist/core/capability/approval-v3.js +63 -23
  37. package/dist/core/capability/attestation.d.ts +3 -0
  38. package/dist/core/capability/attestation.js +7 -0
  39. package/dist/core/capability/boundary-driver-container.d.ts +12 -0
  40. package/dist/core/capability/boundary-driver-container.js +101 -34
  41. package/dist/core/capability/boundary-driver.js +1 -0
  42. package/dist/core/capability/boundary-grant-materialize.d.ts +1 -0
  43. package/dist/core/capability/boundary-grant-materialize.js +15 -0
  44. package/dist/core/capability/boundary-run.d.ts +25 -0
  45. package/dist/core/capability/boundary-run.js +37 -9
  46. package/dist/core/capability/boundary-session.d.ts +4 -0
  47. package/dist/core/capability/boundary-session.js +9 -0
  48. package/dist/core/capability/boundary-workspace-mount.d.ts +5 -0
  49. package/dist/core/capability/boundary-workspace-mount.js +68 -0
  50. package/dist/core/capability/gate-policy-shadow.js +23 -1
  51. package/dist/core/capability/grant-consumption.d.ts +4 -0
  52. package/dist/core/capability/grant-consumption.js +11 -0
  53. package/dist/core/capability/grant-lease.d.ts +36 -1
  54. package/dist/core/capability/grant-lease.js +213 -16
  55. package/dist/core/capability/grant-match.js +3 -0
  56. package/dist/core/capability/index.d.ts +2 -2
  57. package/dist/core/capability/index.js +1 -1
  58. package/dist/core/capability/policy-engine.d.ts +38 -1
  59. package/dist/core/capability/policy-engine.js +388 -15
  60. package/dist/core/capability/request.d.ts +12 -0
  61. package/dist/core/capability/resolver.d.ts +0 -1
  62. package/dist/core/capability/resolver.js +0 -1
  63. package/dist/core/config.d.ts +20 -0
  64. package/dist/core/config.js +62 -2
  65. package/dist/core/effect-ir/audit.d.ts +9 -0
  66. package/dist/core/effect-ir/audit.js +87 -0
  67. package/dist/core/effect-ir/build.d.ts +31 -0
  68. package/dist/core/effect-ir/build.js +281 -0
  69. package/dist/core/effect-ir/index.d.ts +13 -0
  70. package/dist/core/effect-ir/index.js +9 -0
  71. package/dist/core/effect-ir/normalize.d.ts +4 -0
  72. package/dist/core/effect-ir/normalize.js +110 -0
  73. package/dist/core/effect-ir/package-exec-eval.d.ts +8 -0
  74. package/dist/core/effect-ir/package-exec-eval.js +152 -0
  75. package/dist/core/effect-ir/package-exec.d.ts +29 -0
  76. package/dist/core/effect-ir/package-exec.js +292 -0
  77. package/dist/core/effect-ir/policy.d.ts +14 -0
  78. package/dist/core/effect-ir/policy.js +164 -0
  79. package/dist/core/effect-ir/shell-build.d.ts +43 -0
  80. package/dist/core/effect-ir/shell-build.js +77 -0
  81. package/dist/core/effect-ir/shell-lower.d.ts +14 -0
  82. package/dist/core/effect-ir/shell-lower.js +1509 -0
  83. package/dist/core/effect-ir/types.d.ts +62 -0
  84. package/dist/core/effect-ir/types.js +1 -0
  85. package/dist/core/egress/allowlist.d.ts +1 -0
  86. package/dist/core/egress/allowlist.js +8 -0
  87. package/dist/core/egress-approval.js +45 -51
  88. package/dist/core/gate-contract.d.ts +2 -0
  89. package/dist/core/gate-contract.js +2 -0
  90. package/dist/core/gate-engine.js +82 -25
  91. package/dist/core/git-resource-identity.d.ts +26 -0
  92. package/dist/core/git-resource-identity.js +284 -0
  93. package/dist/core/harvest.d.ts +3 -7
  94. package/dist/core/harvest.js +4 -27
  95. package/dist/core/index.d.ts +5 -1
  96. package/dist/core/index.js +3 -1
  97. package/dist/core/judge-cloud-consent.js +29 -15
  98. package/dist/core/network-endpoint.d.ts +10 -0
  99. package/dist/core/network-endpoint.js +51 -0
  100. package/dist/core/path-utils.js +42 -23
  101. package/dist/core/process-runner.d.ts +10 -0
  102. package/dist/core/process-runner.js +124 -0
  103. package/dist/core/recover-advice.js +2 -1
  104. package/dist/core/recovery/artifact-store.d.ts +29 -0
  105. package/dist/core/recovery/artifact-store.js +336 -0
  106. package/dist/core/recovery/checkpoint.d.ts +25 -0
  107. package/dist/core/recovery/checkpoint.js +260 -0
  108. package/dist/core/recovery/index.d.ts +3 -1
  109. package/dist/core/recovery/index.js +2 -0
  110. package/dist/core/recovery/operator-guidance.d.ts +7 -0
  111. package/dist/core/recovery/operator-guidance.js +39 -0
  112. package/dist/core/recovery/reconcile.d.ts +3 -0
  113. package/dist/core/recovery/reconcile.js +48 -0
  114. package/dist/core/recovery/resource-identity.d.ts +4 -0
  115. package/dist/core/recovery/resource-identity.js +34 -0
  116. package/dist/core/recovery/restore.d.ts +13 -0
  117. package/dist/core/recovery/restore.js +114 -0
  118. package/dist/core/recovery/snapshot-node.d.ts +27 -0
  119. package/dist/core/recovery/snapshot-node.js +261 -0
  120. package/dist/core/recovery/types.d.ts +85 -1
  121. package/dist/core/shell-substitution.d.ts +1 -0
  122. package/dist/core/shell-substitution.js +61 -0
  123. package/dist/core/standing-allow.d.ts +1 -2
  124. package/dist/core/standing-allow.js +3 -20
  125. package/dist/core/transactional/apply-observed-changes.d.ts +13 -0
  126. package/dist/core/transactional/apply-observed-changes.js +275 -0
  127. package/dist/core/transactional/backend-selector.d.ts +8 -0
  128. package/dist/core/transactional/backend-selector.js +118 -0
  129. package/dist/core/transactional/backend.d.ts +55 -0
  130. package/dist/core/transactional/backend.js +1 -0
  131. package/dist/core/transactional/file-checkpoint-backend.d.ts +3 -0
  132. package/dist/core/transactional/file-checkpoint-backend.js +278 -0
  133. package/dist/core/transactional/file-checkpoint-git.d.ts +11 -0
  134. package/dist/core/transactional/file-checkpoint-git.js +217 -0
  135. package/dist/core/transactional/file-checkpoint-staging.d.ts +13 -0
  136. package/dist/core/transactional/file-checkpoint-staging.js +54 -0
  137. package/dist/core/transactional/file-clone.d.ts +15 -0
  138. package/dist/core/transactional/file-clone.js +139 -0
  139. package/dist/core/transactional/file-tree-path.d.ts +7 -0
  140. package/dist/core/transactional/file-tree-path.js +50 -0
  141. package/dist/core/transactional/file-tree.d.ts +41 -0
  142. package/dist/core/transactional/file-tree.js +186 -0
  143. package/dist/core/transactional/git-worktree-backend.d.ts +2 -0
  144. package/dist/core/transactional/git-worktree-backend.js +50 -0
  145. package/dist/core/transactional/git-worktree.d.ts +9 -11
  146. package/dist/core/transactional/git-worktree.js +24 -119
  147. package/dist/core/transactional/index.d.ts +11 -0
  148. package/dist/core/transactional/index.js +10 -0
  149. package/dist/core/transactional/reasons.js +1 -0
  150. package/dist/core/transactional/runner.js +157 -25
  151. package/dist/core/transactional/snapshot-node.d.ts +26 -0
  152. package/dist/core/transactional/snapshot-node.js +87 -0
  153. package/dist/core/transactional/types.d.ts +17 -1
  154. package/dist/core/types.d.ts +13 -1
  155. package/dist/core/verdict/adapter.js +23 -15
  156. package/dist/core/verdict/containment.js +4 -0
  157. package/dist/core/verdict/egress-classify.d.ts +12 -0
  158. package/dist/core/verdict/egress-classify.js +746 -0
  159. package/dist/core/verdict/git-classifier.d.ts +12 -0
  160. package/dist/core/verdict/git-classifier.js +320 -0
  161. package/dist/core/verdict/launcher-resolve.js +79 -37
  162. package/dist/core/verdict/parser.d.ts +2 -0
  163. package/dist/core/verdict/parser.js +95 -1
  164. package/dist/core/verdict/types.d.ts +6 -2
  165. package/dist/core/verdict/verdict.js +103 -935
  166. package/dist/corpus/must-allow-commands.d.ts +2 -1
  167. package/dist/corpus/must-allow-commands.js +2 -1
  168. package/dist/corpus/runtime-match.d.ts +2 -1
  169. package/dist/corpus/runtime-match.js +2 -1
  170. package/dist/corpus/types.d.ts +2 -2
  171. package/dist/templates.js +7 -2
  172. package/dist/types.d.ts +11 -0
  173. package/dist/version.d.ts +1 -1
  174. package/dist/version.js +1 -1
  175. package/package.json +2 -2
  176. package/skills/belay/SKILL.md +8 -3
  177. package/skills/belay/belay-approve.md +10 -7
  178. package/dist/core/verdict/overrides.d.ts +0 -7
  179. package/dist/core/verdict/overrides.js +0 -37
  180. package/dist/core/verdict/shell-policy.d.ts +0 -25
  181. package/dist/core/verdict/shell-policy.js +0 -40
  182. package/dist/corpus/standing-allow-catalog.generated.d.ts +0 -13
  183. package/dist/corpus/standing-allow-catalog.generated.js +0 -99
@@ -1,38 +1,56 @@
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';
35
+ import { PACKAGE_VERSION } from '../../version.js';
31
36
  import { protectedArtifactRoots } from '../layouts/protected-paths.js';
32
37
  const EMPTY_APPROVALS = {
33
38
  version: 1,
34
39
  approvals: [],
35
40
  };
41
+ const RUNTIME_PROVENANCE_KEY = Symbol.for('agent-belay.runtime-provenance');
42
+ function auditProvenance(config) {
43
+ const runtime = globalThis[RUNTIME_PROVENANCE_KEY];
44
+ const runtimeVersion = typeof runtime?.runtimeVersion === 'string' ? runtime.runtimeVersion : PACKAGE_VERSION;
45
+ const runtimeBuildStamp = typeof runtime?.runtimeBuildStamp === 'string'
46
+ ? runtime.runtimeBuildStamp
47
+ : `${PACKAGE_VERSION}@source`;
48
+ return {
49
+ runtimeVersion,
50
+ runtimeBuildStamp,
51
+ configFingerprint: hashValue(canonicalStringify(config)),
52
+ };
53
+ }
36
54
  function adapterIdFromContext(ctx) {
37
55
  if (ctx.config.adapter === 'cursor' ||
38
56
  ctx.config.adapter === 'claude' ||
@@ -44,6 +62,16 @@ function adapterIdFromContext(ctx) {
44
62
  }
45
63
  return undefined;
46
64
  }
65
+ const REPLAY_AUDIT_FAILURE_NOTE = ' Audit recording failed; inspect audit storage before the next approval.';
66
+ async function appendReplayAuditSafely(ctx, deps, event) {
67
+ try {
68
+ await deps.appendAudit(ctx, event);
69
+ return true;
70
+ }
71
+ catch {
72
+ return false;
73
+ }
74
+ }
47
75
  async function loadJsonFile(filePath, fallback) {
48
76
  try {
49
77
  const raw = await readFile(filePath, 'utf8');
@@ -61,11 +89,17 @@ export function createDefaultGateRuntimeDeps() {
61
89
  async appendAudit(ctx, event) {
62
90
  const auditPath = path.join(ctx.repoRoot, ctx.config.audit.logPath);
63
91
  await mkdir(path.dirname(auditPath), { recursive: true });
64
- const record = { timestamp: new Date().toISOString(), ...event };
92
+ const provenance = auditProvenance(ctx.config);
93
+ const record = {
94
+ timestamp: new Date().toISOString(),
95
+ ...event,
96
+ ...provenance,
97
+ };
65
98
  if (!ctx.config.audit.includeAssessment) {
66
99
  delete record.assessment;
67
100
  }
68
101
  const scrubbed = scrubValue(record, scrubOptionsFromConfig(ctx.config));
102
+ Object.assign(scrubbed, provenance);
69
103
  await writeFile(auditPath, `${JSON.stringify(scrubbed)}\n`, {
70
104
  encoding: 'utf8',
71
105
  flag: 'a',
@@ -91,6 +125,16 @@ export function createDefaultGateRuntimeDeps() {
91
125
  await mkdir(path.dirname(filePath), { recursive: true });
92
126
  await writeFile(filePath, `${JSON.stringify(compactApprovals(state), null, 2)}\n`, 'utf8');
93
127
  },
128
+ async replayApprovedShell(ctx, command, cwd, timeoutMs) {
129
+ return runBoundaryAgentCommand({
130
+ repoRoot: ctx.repoRoot,
131
+ config: ctx.config,
132
+ command,
133
+ cwd,
134
+ timeoutMs,
135
+ runOptions: { mountReadOnly: false },
136
+ });
137
+ },
94
138
  };
95
139
  }
96
140
  export async function resolveGateConfig(ctx, deps) {
@@ -139,16 +183,7 @@ function gateAuditEventName(kind) {
139
183
  return 'subagentGate';
140
184
  }
141
185
  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({
186
+ const candidate = createApprovalRecordWithEnvelope({
152
187
  kind,
153
188
  fingerprint: result.fingerprint,
154
189
  repoRoot: ctx.repoRoot,
@@ -159,12 +194,31 @@ async function ensurePendingApproval(ctx, deps, kind, result, approvalInput, sco
159
194
  approvalInput,
160
195
  scopeHint,
161
196
  capabilityRequests: result.capabilityRequests,
197
+ effectPlanHash: result.effectPlan ? hashEffectPlan(result.effectPlan) : undefined,
162
198
  });
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 };
199
+ const outcome = await mutateApprovalStateWithRetry({
200
+ load: () => deps.loadApprovals(ctx, 'pending-approvals.json'),
201
+ write: (filePath, state) => deps.writeApprovals(filePath, state),
202
+ mutate: (state) => {
203
+ const compacted = compactApprovals(state);
204
+ const existing = compacted.approvals.find((approval) => approval.kind === kind &&
205
+ approval.fingerprint === result.fingerprint &&
206
+ approval.repoRoot === ctx.repoRoot);
207
+ if (existing) {
208
+ return { state: compacted, result: { approval: existing, created: false } };
209
+ }
210
+ compacted.version = APPROVAL_STATE_VERSION_V3;
211
+ compacted.approvals.push(candidate);
212
+ return { state: compacted, result: { approval: candidate, created: true } };
213
+ },
214
+ });
215
+ if (!outcome) {
216
+ throw new Error('Failed to persist pending approval');
217
+ }
218
+ return outcome;
219
+ }
220
+ function approvalCorrelationId(approvalId) {
221
+ return createHash('sha256').update(approvalId).digest('hex').slice(0, 16);
168
222
  }
169
223
  function extractShellCommandFromPayload(payload) {
170
224
  const toolInput = payload.tool_input;
@@ -222,21 +276,7 @@ function deriveWorkspaceRootScopeHint(params) {
222
276
  }
223
277
  return { scope: 'workspace-root', path: validation.normalizedPath };
224
278
  }
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
- }
279
+ async function consumeApprovedApproval(ctx, deps, kind, fingerprint, requests) {
240
280
  const consumed = await mutateApprovalStateWithRetry({
241
281
  load: () => deps.loadApprovals(ctx, 'approved-approvals.json'),
242
282
  write: (filePath, state) => deps.writeApprovals(filePath, state),
@@ -246,30 +286,83 @@ async function consumeApprovedApproval(ctx, deps, kind, fingerprint) {
246
286
  approval.fingerprint === fingerprint &&
247
287
  approval.repoRoot === ctx.repoRoot);
248
288
  if (matchIndex === -1) {
249
- return null;
289
+ return { state: compacted, result: null };
250
290
  }
251
291
  const approval = compacted.approvals[matchIndex];
252
- if (approval.grant && approval.grant.usesRemaining <= 0) {
292
+ if (approval.executionLeaseExpiresAt) {
293
+ if (approval.grantBundleVersion === 1) {
294
+ const validated = validateGrantBundleForLeaseReuse(approval, requests);
295
+ if (!validated.ok) {
296
+ compacted.approvals.splice(matchIndex, 1);
297
+ return {
298
+ state: compacted,
299
+ result: { status: 'invalid_bundle', approval, reason: validated.reason },
300
+ };
301
+ }
302
+ }
303
+ return {
304
+ state: compacted,
305
+ result: { status: 'consumed', approval, firstExecution: false },
306
+ };
307
+ }
308
+ if (approvalGrantBundleExhausted(approval) && !approval.executionLeaseExpiresAt) {
253
309
  compacted.approvals.splice(matchIndex, 1);
254
310
  return { state: compacted, result: null };
255
311
  }
312
+ let updatedApproval = approval;
313
+ const bundle = grantsFromApproval(approval);
314
+ if (approval.grantBundleVersion === 1) {
315
+ const validated = validateAndConsumeGrantBundle(approval, requests);
316
+ if (!validated.ok) {
317
+ compacted.approvals.splice(matchIndex, 1);
318
+ return {
319
+ state: compacted,
320
+ result: { status: 'invalid_bundle', approval, reason: validated.reason },
321
+ };
322
+ }
323
+ updatedApproval = validated.approval;
324
+ }
325
+ else if (bundle.length > 0) {
326
+ const consumed = consumeApprovedRecordGrantBundle(approval);
327
+ if (!consumed.consumed) {
328
+ return { state: compacted, result: null };
329
+ }
330
+ updatedApproval = consumed.approval;
331
+ }
332
+ else if (approval.grant) {
333
+ updatedApproval = decrementApprovalLegacyGrant(approval);
334
+ }
256
335
  compacted.approvals[matchIndex] = {
257
- ...approval,
336
+ ...updatedApproval,
258
337
  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
338
  };
266
- return { state: compacted, result: approval };
339
+ return {
340
+ state: compacted,
341
+ result: { status: 'consumed', approval: updatedApproval, firstExecution: true },
342
+ };
267
343
  },
268
344
  });
269
345
  if (!consumed) {
270
346
  return null;
271
347
  }
272
- return { approval: consumed, firstExecution: true };
348
+ return consumed;
349
+ }
350
+ async function discardApprovedApproval(ctx, deps, approvalId) {
351
+ const discarded = await mutateApprovalStateWithRetry({
352
+ load: () => deps.loadApprovals(ctx, 'approved-approvals.json'),
353
+ write: (filePath, state) => deps.writeApprovals(filePath, state),
354
+ mutate: (state) => {
355
+ const compacted = compactApprovals(state);
356
+ const index = compacted.approvals.findIndex((approval) => approval.approvalId === approvalId);
357
+ if (index !== -1) {
358
+ compacted.approvals.splice(index, 1);
359
+ }
360
+ return { state: compacted, result: true };
361
+ },
362
+ });
363
+ if (discarded !== true) {
364
+ throw new Error(`Failed to discard rejected approval ${approvalId}`);
365
+ }
273
366
  }
274
367
  export async function evaluateGatedAction(ctx, deps, params) {
275
368
  let action;
@@ -285,37 +378,65 @@ export async function evaluateGatedAction(ctx, deps, params) {
285
378
  });
286
379
  }
287
380
  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
- });
381
+ const scrubbedInput = scrubValue({
382
+ kind: params.kind,
383
+ cwd: params.cwd,
384
+ command: params.command,
385
+ payload: params.payload,
386
+ toolName: params.toolName,
387
+ }, scrubOptionsFromConfig(ctx.config));
388
+ const inputFingerprint = hashValue(`unnormalized-gated-action:v1:${canonicalStringify(scrubbedInput)}`);
389
+ const verdict = {
390
+ ...unnormalizedGateVerdict({
391
+ reason: 'normalization_failed',
392
+ mode: ctx.config.mode,
393
+ user_message: 'belay could not normalize this gated action. Run belay doctor, then retry.',
394
+ agent_message: 'Belay denied this action because the hook payload could not be normalized.',
395
+ }),
396
+ fingerprint: inputFingerprint,
397
+ effectPlan: buildCapabilityEffectPlan({
398
+ actionKind: params.kind,
399
+ summary: params.command ?? params.toolName ?? params.kind,
400
+ inputFingerprint,
401
+ requests: [],
402
+ effectFree: false,
403
+ }),
404
+ };
294
405
  await deps.appendAudit(ctx, {
295
406
  event: gateAuditEventName(params.kind),
296
407
  kind: params.kind,
408
+ fingerprint: verdict.fingerprint,
297
409
  verdict: verdict.verdict,
298
410
  reason: verdict.reason,
299
411
  mode: ctx.config.mode,
412
+ ...effectPlanAuditFields(verdict.effectPlan),
300
413
  wouldBlock: true,
301
414
  permission: 'deny',
302
415
  });
303
416
  return verdict;
304
417
  }
305
418
  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
- },
419
+ const result = {
420
+ verdict: 'allow',
421
+ reason: 'gate_disabled',
422
+ fingerprint: 'gate_disabled',
423
+ assessment: {
424
+ reversibility: 'reversible',
425
+ external: false,
426
+ blastRadius: 'none',
427
+ confidence: 1,
428
+ signals: ['gate_disabled'],
318
429
  },
430
+ effectPlan: buildCapabilityEffectPlan({
431
+ actionKind: action.kind,
432
+ summary: action.command ?? action.toolName ?? action.kind,
433
+ inputFingerprint: 'gate_disabled',
434
+ requests: [],
435
+ effectFree: false,
436
+ }),
437
+ };
438
+ return classifyResultToGateVerdict({
439
+ result,
319
440
  mode: ctx.config.mode,
320
441
  permission: 'allow',
321
442
  wouldBlock: false,
@@ -362,7 +483,7 @@ export async function evaluateGatedAction(ctx, deps, params) {
362
483
  command: params.command,
363
484
  cwd: params.cwd,
364
485
  repoRoot: ctx.repoRoot,
365
- stateDir: path.join(ctx.layout.repoLocalStateDir(ctx.repoRoot), 'transactional'),
486
+ stateDir: belayStateDir(ctx.config, ctx.layout.repoLocalStateDir(ctx.repoRoot)),
366
487
  timeoutMs: transactional.timeoutMs,
367
488
  predicted,
368
489
  diffContext: {
@@ -373,6 +494,8 @@ export async function evaluateGatedAction(ctx, deps, params) {
373
494
  },
374
495
  boundaryContext,
375
496
  dirtyIgnoreRoots: protectedArtifactRoots(ctx.layout, ctx.repoRoot, ctx.config.controlPlane.enabled ? configuredControlPlaneDir(ctx.config) : null),
497
+ fileCheckpoint: transactional.fileCheckpoint,
498
+ checkpoint: transactional.checkpoint,
376
499
  });
377
500
  if (!txResult.skipped && txResult.observed) {
378
501
  result = txResult.result;
@@ -380,20 +503,48 @@ export async function evaluateGatedAction(ctx, deps, params) {
380
503
  observedAssessment = txResult.observed.assessment;
381
504
  transactionalLayer = {
382
505
  transactional: true,
506
+ transactionalBackend: txResult.transactionalBackend,
507
+ resourceKind: txResult.resourceKind,
508
+ baselineTreeHash: txResult.baselineTreeHash,
509
+ snapshotFileCount: txResult.snapshotFileCount,
510
+ snapshotSourceBytes: txResult.snapshotSourceBytes,
511
+ snapshotWorkspaceBytes: txResult.snapshotWorkspaceBytes,
512
+ snapshotCopyStrategy: txResult.snapshotCopyStrategy,
513
+ snapshotPrepareMs: txResult.snapshotPrepareMs,
514
+ executionRoute: `boundary:${boundaryContext.driverId}`,
515
+ enforcementStatus: 'mediated_unattested',
383
516
  transactionalReason: txResult.observed.reason,
384
517
  transactionalCategories: txResult.observed.categories,
385
518
  transactionalChangeCount: txResult.observed.changes.length,
386
519
  transactionalTimedOut: txResult.timedOut === true,
520
+ ...(txResult.recoveryCheckpointId
521
+ ? {
522
+ recoveryCheckpointId: txResult.recoveryCheckpointId,
523
+ recoveryBackend: txResult.recoveryBackend,
524
+ recoveryProofHash: txResult.recoveryProofHash,
525
+ recoveryState: txResult.recoveryState,
526
+ }
527
+ : {}),
387
528
  };
388
529
  }
389
530
  else {
390
531
  const skipReason = txResult.skipReason ?? 'recovery_observation_failed';
391
- result = recoveryFailClosedResult(predicted, recoveryFailReasonFromSkip(skipReason), [
392
- skipReason,
393
- ]);
532
+ const failReason = skipReason === FILE_CHECKPOINT_ISOLATION_UNAVAILABLE
533
+ ? FILE_CHECKPOINT_ISOLATION_UNAVAILABLE
534
+ : recoveryFailReasonFromSkip(skipReason);
535
+ result = recoveryFailClosedResult(predicted, failReason, [skipReason]);
394
536
  transactionalLayer = {
395
537
  transactional: false,
538
+ transactionalBackend: txResult.transactionalBackend,
539
+ resourceKind: txResult.resourceKind,
540
+ baselineTreeHash: txResult.baselineTreeHash,
541
+ snapshotFileCount: txResult.snapshotFileCount,
542
+ snapshotSourceBytes: txResult.snapshotSourceBytes,
543
+ snapshotWorkspaceBytes: txResult.snapshotWorkspaceBytes,
544
+ snapshotCopyStrategy: txResult.snapshotCopyStrategy,
545
+ snapshotPrepareMs: txResult.snapshotPrepareMs,
396
546
  transactionalSkipReason: skipReason,
547
+ transactionalSkipDetail: txResult.skipDetail,
397
548
  recoveryFailClosed: true,
398
549
  };
399
550
  }
@@ -473,20 +624,19 @@ async function consumeCapabilityGrantIfUsed(ctx, deps, result) {
473
624
  result.authorizationDecision?.matchedRule !== 'grant.exact') {
474
625
  return true;
475
626
  }
476
- const request = result.capabilityRequests?.[0];
477
- if (!request) {
627
+ if (!canConsumeCapabilityGrantLease(result)) {
478
628
  return false;
479
629
  }
630
+ const requests = result.capabilityRequests ?? [];
631
+ if (!requests.length) {
632
+ return true;
633
+ }
480
634
  const consumed = await mutateApprovalStateWithRetry({
481
635
  load: () => deps.loadApprovals(ctx, 'approved-approvals.json'),
482
636
  write: (filePath, state) => deps.writeApprovals(filePath, state),
483
637
  mutate: (state) => {
484
638
  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);
639
+ const lease = consumeGrantLeasesForRequests(compacted, requests);
490
640
  if (!lease.consumed) {
491
641
  return null;
492
642
  }
@@ -496,6 +646,18 @@ async function consumeCapabilityGrantIfUsed(ctx, deps, result) {
496
646
  return consumed === true;
497
647
  }
498
648
  async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {}) {
649
+ if (!result.effectPlan) {
650
+ result = {
651
+ ...result,
652
+ effectPlan: buildCapabilityEffectPlan({
653
+ actionKind: kind,
654
+ summary: result.normalizedCommand ?? result.summary ?? kind,
655
+ inputFingerprint: result.fingerprint,
656
+ requests: result.capabilityRequests ?? [],
657
+ effectFree: result.reason === 'read_only',
658
+ }),
659
+ };
660
+ }
499
661
  const replayContext = buildAuditReplayContext(kind, result, auditExtras.replayAction);
500
662
  const actionSnapshot = buildAuditActionSnapshot(kind, result, auditExtras.replayAction);
501
663
  const providerId = String(ctx.config.judge?.providerId ?? 'cursor');
@@ -520,11 +682,92 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
520
682
  mode: ctx.config.mode,
521
683
  schemaVersion: result.axes ? 2 : 1,
522
684
  ...(result.axes ?? {}),
523
- ...auditExtras.transactionalLayer,
524
685
  ...capabilityAudit,
686
+ ...auditExtras.transactionalLayer,
525
687
  ...(replayContext ? { replayContext } : {}),
526
688
  ...(actionSnapshot ? { actionSnapshot } : {}),
527
689
  };
690
+ const denyWithPendingApproval = async (failure) => {
691
+ const { approval, created } = await ensurePendingApproval(ctx, deps, kind, result, auditExtras.approvalInput, deriveWorkspaceRootScopeHint({
692
+ result,
693
+ replayAction: auditExtras.replayAction,
694
+ payloadForScopeHint: auditExtras.scopeHintPayload,
695
+ options: auditExtras.classifierOptions ?? {},
696
+ }));
697
+ if (created) {
698
+ await recordGateApprovalAsk(stateDir, result.reason, false);
699
+ }
700
+ let approvalToken;
701
+ try {
702
+ approvalToken = await issueApprovalToken({
703
+ approvalId: approval.approvalId,
704
+ fingerprint: approval.fingerprint,
705
+ repoRoot: approval.repoRoot,
706
+ issuedAt: approval.createdAt,
707
+ expiresAt: approval.expiresAt,
708
+ }, configuredControlPlaneDir(ctx.config));
709
+ }
710
+ catch {
711
+ approvalToken = undefined;
712
+ }
713
+ const denialReason = failure?.reason ?? result.reason;
714
+ if (ctx.config.notifications.webhookUrl || ctx.config.notifications.commandHook) {
715
+ await notifyDeny(ctx.config.notifications, {
716
+ approvalId: approval.approvalId,
717
+ reason: denialReason,
718
+ summary: result.normalizedCommand ?? result.summary ?? '',
719
+ repoRoot: ctx.repoRoot,
720
+ fingerprint: result.fingerprint,
721
+ approvalToken,
722
+ });
723
+ }
724
+ const failureAuditFields = typeof failure?.auditFields === 'function'
725
+ ? failure.auditFields(approval)
726
+ : failure?.auditFields;
727
+ await deps.appendAudit(ctx, {
728
+ ...gateBase,
729
+ verdict: failure ? 'deny_pending_approval' : result.verdict,
730
+ reason: denialReason,
731
+ approvalId: approval.approvalId,
732
+ ...(failure?.grantBundleFailureReason
733
+ ? { grantBundleFailureReason: failure.grantBundleFailureReason }
734
+ : {}),
735
+ ...failureAuditFields,
736
+ wouldBlock: true,
737
+ permission: 'deny',
738
+ });
739
+ const adapter = adapterIdFromContext(ctx);
740
+ const autoReplayShell = kind === 'shell' && canAutoReplay(ctx.config, kind, adapter);
741
+ return classifyResultToGateVerdict({
742
+ result: failure
743
+ ? { ...result, verdict: 'deny_pending_approval', reason: denialReason }
744
+ : result,
745
+ mode: ctx.config.mode,
746
+ permission: 'deny',
747
+ wouldBlock: true,
748
+ approvalId: approval.approvalId,
749
+ user_message: failure?.userMessage(approval.approvalId) ??
750
+ `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.`,
751
+ agent_message: failure?.agentMessage ??
752
+ (autoReplayShell
753
+ ? `Belay denied this action as ${result.reason}. Wait for approval; Belay will replay the exact shell action automatically. Do not retry unless replay fails.`
754
+ : `Belay denied this action as ${result.reason}. Wait for approval, then retry the exact same action once.`),
755
+ });
756
+ };
757
+ const denyReplayMismatch = async (approval, mismatchKind, messages) => {
758
+ await discardApprovedApproval(ctx, deps, approval.approvalId);
759
+ return denyWithPendingApproval({
760
+ reason: 'approval_replay_mismatch',
761
+ auditFields: (replacement) => ({
762
+ rejectedApprovalId: approval.approvalId,
763
+ rejectedApprovalCorrelationId: approvalCorrelationId(approval.approvalId),
764
+ replacementApprovalCorrelationId: approvalCorrelationId(replacement.approvalId),
765
+ replayMismatchKind: mismatchKind,
766
+ }),
767
+ userMessage: (approvalId) => `${messages.user} New approval ID: ${approvalId}.`,
768
+ agentMessage: `${messages.agent} Wait for the new exact approval, then retry once.`,
769
+ });
770
+ };
528
771
  if (result.reason === TRANSACTIONAL_ALREADY_APPLIED) {
529
772
  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
773
  const agentMessage = 'Belay already applied the observed-safe effects of this shell command in isolation. Do not run it again.';
@@ -544,7 +787,8 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
544
787
  agent_message: agentMessage,
545
788
  });
546
789
  }
547
- if (result.verdict === 'deny_pending_approval' &&
790
+ if (kind !== 'shell' &&
791
+ result.verdict === 'deny_pending_approval' &&
548
792
  ctx.config.mode === 'enforce' &&
549
793
  !TRANSACTIONAL_APPROVAL_BYPASS_REASONS.has(result.reason)) {
550
794
  const standingAllowPath = standingAllowFile(ctx.config, ctx.layout.repoLocalStateDir(ctx.repoRoot));
@@ -561,9 +805,6 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
561
805
  verdict: 'allow',
562
806
  reason: 'standing_allow',
563
807
  standingAllowSource: standingMatch.source,
564
- ...(standingMatch.catalogCommand
565
- ? { standingAllowCatalogCommand: standingMatch.catalogCommand }
566
- : {}),
567
808
  wouldBlock: false,
568
809
  permission: 'allow',
569
810
  });
@@ -585,61 +826,47 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
585
826
  entry.fingerprint === result.fingerprint &&
586
827
  entry.repoRoot === ctx.repoRoot);
587
828
  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',
829
+ if (matchedApproval?.capabilityRequestHash &&
830
+ (!result.capabilityRequests?.length ||
831
+ matchedApproval.capabilityRequestHash !==
832
+ hashCapabilityRequests(result.capabilityRequests))) {
833
+ return denyReplayMismatch(matchedApproval, 'capability_requests', {
834
+ user: 'Belay denied this action because capability requests changed after approval. Re-approve the exact action or run belay explain.',
835
+ agent: 'Belay denied this action because capability requests changed after approval.',
599
836
  });
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.',
837
+ }
838
+ if (matchedApproval?.effectPlanHash &&
839
+ (!result.effectPlan || matchedApproval.effectPlanHash !== hashEffectPlan(result.effectPlan))) {
840
+ return denyReplayMismatch(matchedApproval, 'effect_plan', {
841
+ user: 'Belay denied this action because the effect plan changed after approval. Re-approve the exact action or run belay explain.',
842
+ agent: 'Belay denied this action because the effect plan changed after approval.',
612
843
  });
613
844
  }
614
845
  if (matchedApproval &&
615
846
  auditExtras.replayAction &&
616
847
  !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.',
848
+ return denyReplayMismatch(matchedApproval, 'replay_envelope', {
849
+ user: 'Belay denied this action because it does not match the approved replay envelope. Re-approve the exact action or run belay explain.',
850
+ agent: 'Belay denied this action because cwd, tool, or payload changed after approval.',
637
851
  });
638
852
  }
639
- approved = await consumeApprovedApproval(ctx, deps, kind, result.fingerprint);
853
+ approved = await consumeApprovedApproval(ctx, deps, kind, result.fingerprint, result.capabilityRequests ?? []);
640
854
  }
641
855
  }
642
- if (approved) {
856
+ if (approved?.status === 'invalid_bundle') {
857
+ return denyWithPendingApproval({
858
+ reason: 'capability_grant_unavailable',
859
+ grantBundleFailureReason: approved.reason,
860
+ auditFields: (replacement) => ({
861
+ rejectedApprovalId: approved.approval.approvalId,
862
+ rejectedApprovalCorrelationId: approvalCorrelationId(approved.approval.approvalId),
863
+ replacementApprovalCorrelationId: approvalCorrelationId(replacement.approvalId),
864
+ }),
865
+ 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.`,
866
+ agentMessage: `Belay denied this action because the approved capability bundle failed exact validation (${approved.reason}). Wait for the new exact approval, then retry once.`,
867
+ });
868
+ }
869
+ if (approved?.status === 'consumed') {
643
870
  if (approved.firstExecution) {
644
871
  await recordGateApprovalAsk(stateDir, result.reason, true);
645
872
  }
@@ -648,6 +875,7 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
648
875
  verdict: 'allow',
649
876
  reason: 'approved_once',
650
877
  approvalId: approved.approval.approvalId,
878
+ authorizationMode: approved.approval.grantBundleVersion === 1 ? 'exact_bundle' : 'legacy_approval',
651
879
  wouldBlock: false,
652
880
  permission: 'allow',
653
881
  });
@@ -740,61 +968,33 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
740
968
  agent_message: denyMessages.agent_message,
741
969
  });
742
970
  }
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,
971
+ if (result.reason === FILE_CHECKPOINT_ISOLATION_UNAVAILABLE) {
972
+ const userMessage = 'Belay cannot run file_checkpoint because attested workspace isolation is unavailable. Run `belay session start`, then retry.';
973
+ 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.';
974
+ await deps.appendAudit(ctx, {
975
+ ...gateBase,
976
+ verdict: result.verdict,
768
977
  reason: result.reason,
769
- summary: result.normalizedCommand ?? result.summary ?? '',
770
- repoRoot: ctx.repoRoot,
771
- fingerprint: result.fingerprint,
772
- approvalToken,
978
+ wouldBlock: true,
979
+ permission: 'deny',
980
+ });
981
+ return classifyResultToGateVerdict({
982
+ result,
983
+ mode: ctx.config.mode,
984
+ permission: 'deny',
985
+ wouldBlock: true,
986
+ user_message: userMessage,
987
+ agent_message: agentMessage,
773
988
  });
774
989
  }
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
- });
990
+ return denyWithPendingApproval();
792
991
  }
793
992
  export async function processApprovalPrompt(ctx, deps, prompt) {
794
993
  const approvalId = approvalCommandMatch(prompt, ctx.config.tokenPrefix);
795
994
  if (!approvalId) {
796
995
  return { continue: true };
797
996
  }
997
+ const hasFollowupInstruction = prompt.split(/\r?\n/).filter((line) => line.trim().length > 0).length > 1;
798
998
  const adapter = adapterIdFromContext(ctx);
799
999
  if (ctx.config.approvalSigning.required) {
800
1000
  const message = `Signed approval token required for ${approvalId}. Editor prompt approval is disabled in this configuration. ` +
@@ -837,62 +1037,68 @@ export async function processApprovalPrompt(ctx, deps, prompt) {
837
1037
  };
838
1038
  }
839
1039
  const replay = recorded.approval ? buildReplayHint(ctx.config, recorded.approval, adapter) : null;
840
- const shouldFallbackReplay = adapter === 'cursor' &&
841
- replay?.kind === 'shell' &&
1040
+ const shouldReplayApprovedShell = replay?.kind === 'shell' &&
842
1041
  replay.autoReplay === true &&
843
1042
  Boolean(recorded.approval?.input) &&
844
1043
  (!process.env.VITEST || process.env.BELAY_TEST_APPROVAL_REPLAY === '1');
845
- if (shouldFallbackReplay && recorded.approval) {
1044
+ if (shouldReplayApprovedShell && recorded.approval) {
846
1045
  const approvalStore = gateApprovalStoreFromDeps({
847
1046
  loadApprovals: (fileName) => deps.loadApprovals(ctx, fileName),
848
1047
  writeApprovals: (filePath, state) => deps.writeApprovals(filePath, state),
849
1048
  });
1049
+ let claimed;
1050
+ try {
1051
+ claimed = await claimApprovedForReplay({ approvalId, store: approvalStore });
1052
+ }
1053
+ catch {
1054
+ return {
1055
+ continue: false,
1056
+ user_message: `Belay approval ${approvalId} could not be claimed for one-step replay. ` +
1057
+ 'The shell action was not started.',
1058
+ };
1059
+ }
1060
+ if (!claimed) {
1061
+ return {
1062
+ continue: false,
1063
+ user_message: `Belay approval ${approvalId} could not be claimed for one-step replay.`,
1064
+ };
1065
+ }
850
1066
  let replayResult;
851
1067
  try {
852
- replayResult = await replayShellCommand(recorded.approval.input ?? '', recorded.approval.cwd ?? ctx.repoRoot, getExecutionLeaseMs(ctx.config));
1068
+ replayResult = await deps.replayApprovedShell(ctx, claimed.input ?? '', claimed.cwd ?? ctx.repoRoot, getExecutionLeaseMs(ctx.config));
853
1069
  }
854
1070
  catch (error) {
855
- const detail = error instanceof Error ? error.message : String(error);
856
- await deps.appendAudit(ctx, {
1071
+ const cleanupUnconfirmed = isBoundaryCleanupError(error);
1072
+ const auditRecorded = await appendReplayAuditSafely(ctx, deps, {
857
1073
  event: 'approval',
858
1074
  kind: 'approval',
859
1075
  verdict: 'allow',
860
1076
  approvalId,
861
- reason: 'approval_replay_error',
1077
+ reason: cleanupUnconfirmed
1078
+ ? 'approval_replay_cleanup_unconfirmed'
1079
+ : 'approval_replay_error',
862
1080
  summary: recorded.approval.input ?? recorded.approval.summary ?? prompt,
863
1081
  });
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
- });
1082
+ if (cleanupUnconfirmed) {
1083
+ const resourceId = safeBoundaryCleanupResourceId(error);
888
1084
  return {
889
1085
  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}` : ''),
1086
+ 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.` +
1087
+ (resourceId ? ` Container: ${resourceId}.` : '') +
1088
+ ' Inspect Docker and remove the container manually if it is still present. ' +
1089
+ 'The one-shot approval was consumed and was not re-armed.' +
1090
+ (auditRecorded ? '' : REPLAY_AUDIT_FAILURE_NOTE),
893
1091
  };
894
1092
  }
895
- await deps.appendAudit(ctx, {
1093
+ return {
1094
+ continue: false,
1095
+ user_message: `Belay approval recorded for ${approvalId}, but one-step shell replay could not start. ` +
1096
+ 'The one-shot approval was consumed before replay and was not re-armed.' +
1097
+ (auditRecorded ? '' : REPLAY_AUDIT_FAILURE_NOTE),
1098
+ };
1099
+ }
1100
+ if (replayResult.exitCode === 0 && !replayResult.timedOut) {
1101
+ const auditRecorded = await appendReplayAuditSafely(ctx, deps, {
896
1102
  event: 'approval',
897
1103
  kind: 'approval',
898
1104
  verdict: 'allow',
@@ -901,12 +1107,12 @@ export async function processApprovalPrompt(ctx, deps, prompt) {
901
1107
  summary: recorded.approval.input ?? recorded.approval.summary ?? prompt,
902
1108
  });
903
1109
  return {
904
- continue: false,
1110
+ continue: true,
905
1111
  user_message: `Belay approval recorded for ${approvalId}. One-step shell replay succeeded; no manual retry required.` +
906
- (output ? `\n${output}` : ''),
1112
+ (auditRecorded ? '' : REPLAY_AUDIT_FAILURE_NOTE),
907
1113
  };
908
1114
  }
909
- await deps.appendAudit(ctx, {
1115
+ const auditRecorded = await appendReplayAuditSafely(ctx, deps, {
910
1116
  event: 'approval',
911
1117
  kind: 'approval',
912
1118
  verdict: 'allow',
@@ -914,16 +1120,18 @@ export async function processApprovalPrompt(ctx, deps, prompt) {
914
1120
  reason: 'approval_replay_failed',
915
1121
  summary: recorded.approval.input ?? recorded.approval.summary ?? prompt,
916
1122
  });
917
- const timeoutNote = replayResult.timedOut ? ' Replay timed out.' : '';
1123
+ const failureSummary = replayResult.timedOut
1124
+ ? ' One-step shell replay timed out.'
1125
+ : ` One-step shell replay failed (exit ${replayResult.exitCode}).`;
918
1126
  return {
919
1127
  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}` : ''),
1128
+ user_message: `Belay approval recorded for ${approvalId}.${failureSummary} ` +
1129
+ 'The one-shot approval was consumed before replay and was not re-armed.' +
1130
+ (auditRecorded ? '' : REPLAY_AUDIT_FAILURE_NOTE),
923
1131
  };
924
1132
  }
925
1133
  return {
926
- continue: false,
1134
+ continue: replay !== null || hasFollowupInstruction,
927
1135
  user_message: recorded.message,
928
1136
  ...(replay ? { replay } : {}),
929
1137
  };
@@ -951,15 +1159,18 @@ export function gateVerdictToClaudePreToolUseResponse(verdict) {
951
1159
  }
952
1160
  export function gateVerdictToClaudeUserPromptResponse(verdict) {
953
1161
  if (verdict.continue) {
954
- return {};
1162
+ return verdict.user_message
1163
+ ? {
1164
+ hookSpecificOutput: {
1165
+ hookEventName: 'UserPromptSubmit',
1166
+ additionalContext: verdict.user_message,
1167
+ },
1168
+ }
1169
+ : {};
955
1170
  }
956
1171
  return {
957
- hookSpecificOutput: {
958
- hookEventName: 'UserPromptSubmit',
959
- continue: false,
960
- user_message: verdict.user_message,
961
- ...(verdict.replay ? { replay: verdict.replay } : {}),
962
- },
1172
+ decision: 'block',
1173
+ reason: verdict.user_message,
963
1174
  };
964
1175
  }
965
1176
  // Codex PreToolUse deny contract is identical to Claude's
@@ -970,7 +1181,14 @@ export function gateVerdictToCodexPreToolUseResponse(verdict) {
970
1181
  // Codex UserPromptSubmit blocks via `decision: "block"` (per developers.openai.com/codex/hooks).
971
1182
  export function gateVerdictToCodexUserPromptResponse(verdict) {
972
1183
  if (verdict.continue) {
973
- return {};
1184
+ return verdict.user_message
1185
+ ? {
1186
+ hookSpecificOutput: {
1187
+ hookEventName: 'UserPromptSubmit',
1188
+ additionalContext: verdict.user_message,
1189
+ },
1190
+ }
1191
+ : {};
974
1192
  }
975
1193
  return {
976
1194
  decision: 'block',