@guilz-dev/belay 0.4.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 (240) hide show
  1. package/README.md +27 -2
  2. package/dist/adapters/claude/runtime-entry.js +54 -25
  3. package/dist/adapters/layouts/scope.js +3 -2
  4. package/dist/adapters/shared/gate-runtime.d.ts +8 -2
  5. package/dist/adapters/shared/gate-runtime.js +579 -181
  6. package/dist/bundle/claude-runtime.mjs +13574 -6334
  7. package/dist/bundle/codex-runtime.mjs +13108 -5903
  8. package/dist/bundle/cursor-runtime.mjs +12676 -5476
  9. package/dist/cli.js +80 -2
  10. package/dist/commands/approve.js +34 -10
  11. package/dist/commands/classify-for-report.js +1 -1
  12. package/dist/commands/config.d.ts +8 -0
  13. package/dist/commands/config.js +134 -11
  14. package/dist/commands/doctor.js +87 -1
  15. package/dist/commands/explain.js +11 -1
  16. package/dist/commands/judge.js +3 -2
  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/session.d.ts +20 -0
  21. package/dist/commands/session.js +51 -0
  22. package/dist/commands/status.js +7 -0
  23. package/dist/config-io.js +91 -23
  24. package/dist/conformance/guarantee-posture.d.ts +21 -0
  25. package/dist/conformance/guarantee-posture.js +46 -0
  26. package/dist/conformance/guarantee-table.d.ts +1 -0
  27. package/dist/conformance/guarantee-table.js +20 -9
  28. package/dist/conformance/types.d.ts +1 -0
  29. package/dist/core/approval-replay.d.ts +1 -1
  30. package/dist/core/approval-replay.js +4 -1
  31. package/dist/core/approval-service.d.ts +6 -1
  32. package/dist/core/approval-service.js +87 -24
  33. package/dist/core/approval.d.ts +4 -0
  34. package/dist/core/approval.js +19 -5
  35. package/dist/core/audit-types.d.ts +13 -0
  36. package/dist/core/capability/approval-state-mutation.d.ts +32 -0
  37. package/dist/core/capability/approval-state-mutation.js +165 -0
  38. package/dist/core/capability/approval-v3.d.ts +32 -0
  39. package/dist/core/capability/approval-v3.js +114 -0
  40. package/dist/core/capability/attestation.d.ts +16 -0
  41. package/dist/core/capability/attestation.js +62 -0
  42. package/dist/core/capability/boundary-attestation-sign.d.ts +21 -0
  43. package/dist/core/capability/boundary-attestation-sign.js +84 -0
  44. package/dist/core/capability/boundary-driver-container.d.ts +19 -0
  45. package/dist/core/capability/boundary-driver-container.js +189 -0
  46. package/dist/core/capability/boundary-driver.d.ts +29 -0
  47. package/dist/core/capability/boundary-driver.js +44 -0
  48. package/dist/core/capability/boundary-egress.d.ts +21 -0
  49. package/dist/core/capability/boundary-egress.js +109 -0
  50. package/dist/core/capability/boundary-grant-materialize.d.ts +14 -0
  51. package/dist/core/capability/boundary-grant-materialize.js +93 -0
  52. package/dist/core/capability/boundary-profile.d.ts +15 -0
  53. package/dist/core/capability/boundary-profile.js +25 -0
  54. package/dist/core/capability/boundary-run.d.ts +48 -0
  55. package/dist/core/capability/boundary-run.js +51 -0
  56. package/dist/core/capability/boundary-session.d.ts +61 -0
  57. package/dist/core/capability/boundary-session.js +138 -0
  58. package/dist/core/capability/boundary-workspace-mount.d.ts +5 -0
  59. package/dist/core/capability/boundary-workspace-mount.js +68 -0
  60. package/dist/core/capability/capability-request-hash.d.ts +2 -0
  61. package/dist/core/capability/capability-request-hash.js +8 -0
  62. package/dist/core/capability/gate-latency-ratchet.d.ts +7 -0
  63. package/dist/core/capability/gate-latency-ratchet.js +18 -0
  64. package/dist/core/capability/gate-policy-shadow.d.ts +25 -0
  65. package/dist/core/capability/gate-policy-shadow.js +130 -0
  66. package/dist/core/capability/gate-shadow-audit.d.ts +1 -0
  67. package/dist/core/capability/gate-shadow-audit.js +1 -0
  68. package/dist/core/capability/gate-shadow-ratchet.d.ts +14 -0
  69. package/dist/core/capability/gate-shadow-ratchet.js +81 -0
  70. package/dist/core/capability/grant-consumption.d.ts +4 -0
  71. package/dist/core/capability/grant-consumption.js +11 -0
  72. package/dist/core/capability/grant-lease.d.ts +44 -0
  73. package/dist/core/capability/grant-lease.js +243 -0
  74. package/dist/core/capability/grant-loader.d.ts +12 -0
  75. package/dist/core/capability/grant-loader.js +23 -0
  76. package/dist/core/capability/grant-match.d.ts +6 -0
  77. package/dist/core/capability/grant-match.js +96 -0
  78. package/dist/core/capability/grant.d.ts +16 -0
  79. package/dist/core/capability/grant.js +16 -0
  80. package/dist/core/capability/index.d.ts +8 -0
  81. package/dist/core/capability/index.js +7 -0
  82. package/dist/core/capability/limits.d.ts +7 -0
  83. package/dist/core/capability/limits.js +55 -0
  84. package/dist/core/capability/policy-bridge.d.ts +18 -0
  85. package/dist/core/capability/policy-bridge.js +51 -0
  86. package/dist/core/capability/policy-engine.d.ts +77 -0
  87. package/dist/core/capability/policy-engine.js +663 -0
  88. package/dist/core/capability/policy-types.d.ts +20 -0
  89. package/dist/core/capability/policy-types.js +1 -0
  90. package/dist/core/capability/request.d.ts +48 -0
  91. package/dist/core/capability/request.js +1 -0
  92. package/dist/core/capability/resolver.d.ts +4 -0
  93. package/dist/core/capability/resolver.js +4 -0
  94. package/dist/core/classify-subagent.d.ts +2 -1
  95. package/dist/core/classify-subagent.js +38 -2
  96. package/dist/core/classify-tool.js +126 -62
  97. package/dist/core/config.d.ts +34 -3
  98. package/dist/core/config.js +118 -3
  99. package/dist/core/effect-ir/audit.d.ts +9 -0
  100. package/dist/core/effect-ir/audit.js +86 -0
  101. package/dist/core/effect-ir/build.d.ts +31 -0
  102. package/dist/core/effect-ir/build.js +277 -0
  103. package/dist/core/effect-ir/index.d.ts +8 -0
  104. package/dist/core/effect-ir/index.js +7 -0
  105. package/dist/core/effect-ir/normalize.d.ts +4 -0
  106. package/dist/core/effect-ir/normalize.js +104 -0
  107. package/dist/core/effect-ir/package-exec-eval.d.ts +8 -0
  108. package/dist/core/effect-ir/package-exec-eval.js +152 -0
  109. package/dist/core/effect-ir/package-exec.d.ts +29 -0
  110. package/dist/core/effect-ir/package-exec.js +292 -0
  111. package/dist/core/effect-ir/policy.d.ts +13 -0
  112. package/dist/core/effect-ir/policy.js +77 -0
  113. package/dist/core/effect-ir/types.d.ts +55 -0
  114. package/dist/core/effect-ir/types.js +1 -0
  115. package/dist/core/egress/allowlist.d.ts +1 -0
  116. package/dist/core/egress/allowlist.js +8 -0
  117. package/dist/core/egress-approval.js +45 -51
  118. package/dist/core/gate-contract.d.ts +5 -0
  119. package/dist/core/gate-contract.js +5 -0
  120. package/dist/core/gate-engine.js +30 -8
  121. package/dist/core/index.d.ts +3 -1
  122. package/dist/core/index.js +2 -1
  123. package/dist/core/judge-cloud-consent.js +29 -15
  124. package/dist/core/judge-doctor.js +39 -14
  125. package/dist/core/judge-fallback-hints.d.ts +13 -0
  126. package/dist/core/judge-fallback-hints.js +108 -0
  127. package/dist/core/network-endpoint.d.ts +10 -0
  128. package/dist/core/network-endpoint.js +51 -0
  129. package/dist/core/path-utils.d.ts +1 -0
  130. package/dist/core/path-utils.js +5 -2
  131. package/dist/core/process-runner.d.ts +10 -0
  132. package/dist/core/process-runner.js +124 -0
  133. package/dist/core/recover-advice.js +2 -1
  134. package/dist/core/recovery/artifact-store.d.ts +29 -0
  135. package/dist/core/recovery/artifact-store.js +336 -0
  136. package/dist/core/recovery/capability.d.ts +3 -0
  137. package/dist/core/recovery/capability.js +22 -0
  138. package/dist/core/recovery/checkpoint.d.ts +25 -0
  139. package/dist/core/recovery/checkpoint.js +260 -0
  140. package/dist/core/recovery/fail-closed.d.ts +6 -0
  141. package/dist/core/recovery/fail-closed.js +25 -0
  142. package/dist/core/recovery/index.d.ts +5 -0
  143. package/dist/core/recovery/index.js +4 -0
  144. package/dist/core/recovery/operator-guidance.d.ts +7 -0
  145. package/dist/core/recovery/operator-guidance.js +39 -0
  146. package/dist/core/recovery/reconcile.d.ts +3 -0
  147. package/dist/core/recovery/reconcile.js +48 -0
  148. package/dist/core/recovery/resource-identity.d.ts +4 -0
  149. package/dist/core/recovery/resource-identity.js +34 -0
  150. package/dist/core/recovery/restore.d.ts +13 -0
  151. package/dist/core/recovery/restore.js +114 -0
  152. package/dist/core/recovery/snapshot-node.d.ts +27 -0
  153. package/dist/core/recovery/snapshot-node.js +261 -0
  154. package/dist/core/recovery/types.d.ts +104 -0
  155. package/dist/core/recovery/types.js +1 -0
  156. package/dist/core/standing-allow.js +3 -0
  157. package/dist/core/transactional/apply-observed-changes.d.ts +13 -0
  158. package/dist/core/transactional/apply-observed-changes.js +275 -0
  159. package/dist/core/transactional/backend-selector.d.ts +8 -0
  160. package/dist/core/transactional/backend-selector.js +118 -0
  161. package/dist/core/transactional/backend.d.ts +55 -0
  162. package/dist/core/transactional/backend.js +1 -0
  163. package/dist/core/transactional/diff-evaluator.js +4 -1
  164. package/dist/core/transactional/eligibility.js +4 -0
  165. package/dist/core/transactional/file-checkpoint-backend.d.ts +3 -0
  166. package/dist/core/transactional/file-checkpoint-backend.js +278 -0
  167. package/dist/core/transactional/file-checkpoint-git.d.ts +11 -0
  168. package/dist/core/transactional/file-checkpoint-git.js +217 -0
  169. package/dist/core/transactional/file-checkpoint-staging.d.ts +13 -0
  170. package/dist/core/transactional/file-checkpoint-staging.js +54 -0
  171. package/dist/core/transactional/file-clone.d.ts +15 -0
  172. package/dist/core/transactional/file-clone.js +139 -0
  173. package/dist/core/transactional/file-tree-path.d.ts +7 -0
  174. package/dist/core/transactional/file-tree-path.js +50 -0
  175. package/dist/core/transactional/file-tree.d.ts +41 -0
  176. package/dist/core/transactional/file-tree.js +186 -0
  177. package/dist/core/transactional/git-worktree-backend.d.ts +2 -0
  178. package/dist/core/transactional/git-worktree-backend.js +50 -0
  179. package/dist/core/transactional/git-worktree.d.ts +14 -9
  180. package/dist/core/transactional/git-worktree.js +62 -87
  181. package/dist/core/transactional/index.d.ts +11 -0
  182. package/dist/core/transactional/index.js +10 -0
  183. package/dist/core/transactional/reasons.d.ts +1 -0
  184. package/dist/core/transactional/reasons.js +5 -0
  185. package/dist/core/transactional/runner.js +171 -23
  186. package/dist/core/transactional/snapshot-node.d.ts +26 -0
  187. package/dist/core/transactional/snapshot-node.js +87 -0
  188. package/dist/core/transactional/types.d.ts +21 -1
  189. package/dist/core/types.d.ts +30 -3
  190. package/dist/core/verdict/adapter.d.ts +3 -4
  191. package/dist/core/verdict/adapter.js +24 -12
  192. package/dist/core/verdict/cursor-acp-client.d.ts +67 -0
  193. package/dist/core/verdict/cursor-acp-client.js +345 -0
  194. package/dist/core/verdict/egress-classify.d.ts +2 -2
  195. package/dist/core/verdict/egress-classify.js +2 -2
  196. package/dist/core/verdict/git-classifier.d.ts +26 -0
  197. package/dist/core/verdict/git-classifier.js +533 -0
  198. package/dist/core/verdict/judge-audit.js +1 -0
  199. package/dist/core/verdict/judge-baseline.d.ts +1 -1
  200. package/dist/core/verdict/judge-baseline.js +1 -0
  201. package/dist/core/verdict/judge-broker-service.js +12 -0
  202. package/dist/core/verdict/judge-catalog.js +1 -0
  203. package/dist/core/verdict/judge-cli-fingerprint.d.ts +1 -0
  204. package/dist/core/verdict/judge-cli-fingerprint.js +6 -0
  205. package/dist/core/verdict/judge-cli.js +5 -3
  206. package/dist/core/verdict/judge-provider-matrix.js +1 -1
  207. package/dist/core/verdict/judge-runtime-config.d.ts +2 -0
  208. package/dist/core/verdict/judge-runtime-config.js +10 -6
  209. package/dist/core/verdict/judge-session-broker.d.ts +1 -1
  210. package/dist/core/verdict/judge-session-broker.js +10 -5
  211. package/dist/core/verdict/judge-transport.d.ts +5 -1
  212. package/dist/core/verdict/judge-transport.js +24 -14
  213. package/dist/core/verdict/judge.d.ts +2 -15
  214. package/dist/core/verdict/judge.js +5 -68
  215. package/dist/core/verdict/launcher-resolve.d.ts +2 -0
  216. package/dist/core/verdict/launcher-resolve.js +14 -16
  217. package/dist/core/verdict/prescan.d.ts +15 -0
  218. package/dist/core/verdict/prescan.js +68 -0
  219. package/dist/core/verdict/shell-policy.d.ts +26 -0
  220. package/dist/core/verdict/shell-policy.js +40 -0
  221. package/dist/core/verdict/shell-semantics.d.ts +24 -0
  222. package/dist/core/verdict/shell-semantics.js +225 -0
  223. package/dist/core/verdict/types.d.ts +18 -1
  224. package/dist/core/verdict/verdict.js +313 -178
  225. package/dist/corpus/evaluate.js +3 -7
  226. package/dist/corpus/gate-latency-budget.d.ts +39 -0
  227. package/dist/corpus/gate-latency-budget.js +33 -0
  228. package/dist/corpus/mutators.d.ts +1 -1
  229. package/dist/corpus/mutators.js +11 -0
  230. package/dist/corpus/runtime-match.js +1 -3
  231. package/dist/corpus/standing-allow-catalog.generated.js +29 -9
  232. package/dist/judge-broker-daemon.js +15 -1
  233. package/dist/services/sandbox-service.d.ts +4 -0
  234. package/dist/services/sandbox-service.js +28 -2
  235. package/dist/types.d.ts +13 -2
  236. package/dist/version.d.ts +1 -1
  237. package/dist/version.js +1 -1
  238. package/package.json +1 -1
  239. package/skills/belay/SKILL.md +8 -3
  240. package/skills/belay/belay-approve.md +10 -7
@@ -1,22 +1,37 @@
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
+ import { mutateApprovalStateWithRetry } from '../../core/capability/approval-state-mutation.js';
11
+ import { APPROVAL_STATE_VERSION_V3 } from '../../core/capability/approval-v3.js';
12
+ import { isEgressProxyActive } from '../../core/capability/boundary-egress.js';
13
+ import { isBoundaryCleanupError, safeBoundaryCleanupResourceId, } from '../../core/capability/boundary-run.js';
14
+ import { resolveBoundaryDriverContext, runBoundaryAgentCommand, } from '../../core/capability/boundary-session.js';
15
+ import { hashCapabilityRequests } from '../../core/capability/capability-request-hash.js';
16
+ import { recordGateApprovalAsk, scheduleGateShadowAudit, } from '../../core/capability/gate-shadow-audit.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';
19
+ import { loadClassifierAuthorization } from '../../core/capability/grant-loader.js';
10
20
  import { collectOutsideRepoPaths, collectOutsideRepoPathsFromToolPayload, fsScopeAllowlistPath, isCapabilityBrokerDemotionActive, loadFsScopeAllowlistSync, loadTrustedWorkspaceRootsSync, shouldSkipBrokerApprovedOnce, shouldSkipBrokerApprovedRecord, trustedWorkspaceRootsPath, validateTrustedWorkspaceRootCandidate, } from '../../core/capability/index.js';
11
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';
12
24
  import { classifyResultToGateVerdict, unnormalizedGateVerdict, } from '../../core/gate-contract.js';
13
25
  import { classifyGatedActionAsync, extractAgentAssessment, GateNormalizationError, gateEnabledForAction, normalizeGatedAction, } from '../../core/gate-engine.js';
14
- import { approvalCommandMatch, approvedApprovalsFile, 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';
27
+ import { extractJudgeFallbackReason, formatJudgeInfrastructureDenyMessage, inferProviderIdFromFallbackReason, isJudgeInfrastructureFailure, } from '../../core/judge-fallback-hints.js';
15
28
  import { notifyDeny } from '../../core/notify.js';
16
29
  import { canonicalPath } from '../../core/path-utils.js';
30
+ import { recoveryFailClosedResult, recoveryFailReasonFromSkip, } from '../../core/recovery/fail-closed.js';
17
31
  import { fingerprintReplayPayload } from '../../core/replay-scrub.js';
18
32
  import { loadStandingAllow, resolveStandingAllowMatch, standingAllowFile, } from '../../core/standing-allow.js';
19
- 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';
34
+ import { egressStatus } from '../../services/egress-service.js';
20
35
  import { protectedArtifactRoots } from '../layouts/protected-paths.js';
21
36
  const EMPTY_APPROVALS = {
22
37
  version: 1,
@@ -33,6 +48,16 @@ function adapterIdFromContext(ctx) {
33
48
  }
34
49
  return undefined;
35
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
+ }
36
61
  async function loadJsonFile(filePath, fallback) {
37
62
  try {
38
63
  const raw = await readFile(filePath, 'utf8');
@@ -66,10 +91,12 @@ export function createDefaultGateRuntimeDeps() {
66
91
  ? pendingApprovalsFile(ctx.config, repoLocalStateDir)
67
92
  : approvedApprovalsFile(ctx.config, repoLocalStateDir);
68
93
  const loaded = await loadJsonFile(filePath, EMPTY_APPROVALS);
94
+ const version = loaded.version === 3 ? 3 : loaded.version === 2 ? 2 : 1;
69
95
  return {
70
96
  filePath,
71
97
  state: {
72
- version: loaded.version === 2 ? 2 : 1,
98
+ version,
99
+ ...(loaded.revision !== undefined ? { revision: loaded.revision } : {}),
73
100
  approvals: Array.isArray(loaded.approvals) ? loaded.approvals : [],
74
101
  },
75
102
  };
@@ -78,6 +105,16 @@ export function createDefaultGateRuntimeDeps() {
78
105
  await mkdir(path.dirname(filePath), { recursive: true });
79
106
  await writeFile(filePath, `${JSON.stringify(compactApprovals(state), null, 2)}\n`, 'utf8');
80
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
+ },
81
118
  };
82
119
  }
83
120
  export async function resolveGateConfig(ctx, deps) {
@@ -126,16 +163,7 @@ function gateAuditEventName(kind) {
126
163
  return 'subagentGate';
127
164
  }
128
165
  async function ensurePendingApproval(ctx, deps, kind, result, approvalInput, scopeHint) {
129
- const pending = await deps.loadApprovals(ctx, 'pending-approvals.json');
130
- pending.state = compactApprovals(pending.state);
131
- const existing = pending.state.approvals.find((approval) => approval.kind === kind &&
132
- approval.fingerprint === result.fingerprint &&
133
- approval.repoRoot === ctx.repoRoot);
134
- if (existing) {
135
- await deps.writeApprovals(pending.filePath, pending.state);
136
- return existing;
137
- }
138
- const approval = createApprovalRecordWithEnvelope({
166
+ const candidate = createApprovalRecordWithEnvelope({
139
167
  kind,
140
168
  fingerprint: result.fingerprint,
141
169
  repoRoot: ctx.repoRoot,
@@ -145,11 +173,32 @@ async function ensurePendingApproval(ctx, deps, kind, result, approvalInput, sco
145
173
  approvalId: `belay_${randomUUID().replaceAll('-', '').slice(0, 12)}`,
146
174
  approvalInput,
147
175
  scopeHint,
176
+ capabilityRequests: result.capabilityRequests,
177
+ effectPlanHash: result.effectPlan ? hashEffectPlan(result.effectPlan) : undefined,
178
+ });
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
+ },
148
194
  });
149
- pending.state.version = 2;
150
- pending.state.approvals.push(approval);
151
- await deps.writeApprovals(pending.filePath, pending.state);
152
- return approval;
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);
153
202
  }
154
203
  function extractShellCommandFromPayload(payload) {
155
204
  const toolInput = payload.tool_input;
@@ -207,27 +256,93 @@ function deriveWorkspaceRootScopeHint(params) {
207
256
  }
208
257
  return { scope: 'workspace-root', path: validation.normalizedPath };
209
258
  }
210
- async function consumeApprovedApproval(ctx, deps, kind, fingerprint) {
211
- const approved = await deps.loadApprovals(ctx, 'approved-approvals.json');
212
- approved.state = compactApprovals(approved.state);
213
- const index = approved.state.approvals.findIndex((approval) => approval.kind === kind &&
214
- approval.fingerprint === fingerprint &&
215
- approval.repoRoot === ctx.repoRoot);
216
- if (index === -1) {
217
- await deps.writeApprovals(approved.filePath, approved.state);
259
+ async function consumeApprovedApproval(ctx, deps, kind, fingerprint, requests) {
260
+ const consumed = await mutateApprovalStateWithRetry({
261
+ load: () => deps.loadApprovals(ctx, 'approved-approvals.json'),
262
+ write: (filePath, state) => deps.writeApprovals(filePath, state),
263
+ mutate: (state) => {
264
+ const compacted = compactApprovals(state);
265
+ const matchIndex = compacted.approvals.findIndex((approval) => approval.kind === kind &&
266
+ approval.fingerprint === fingerprint &&
267
+ approval.repoRoot === ctx.repoRoot);
268
+ if (matchIndex === -1) {
269
+ return { state: compacted, result: null };
270
+ }
271
+ const approval = compacted.approvals[matchIndex];
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) {
289
+ compacted.approvals.splice(matchIndex, 1);
290
+ return { state: compacted, result: null };
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
+ }
315
+ compacted.approvals[matchIndex] = {
316
+ ...updatedApproval,
317
+ executionLeaseExpiresAt: new Date(Date.now() + getExecutionLeaseMs(ctx.config)).toISOString(),
318
+ };
319
+ return {
320
+ state: compacted,
321
+ result: { status: 'consumed', approval: updatedApproval, firstExecution: true },
322
+ };
323
+ },
324
+ });
325
+ if (!consumed) {
218
326
  return null;
219
327
  }
220
- const approval = approved.state.approvals[index];
221
- if (approval.executionLeaseExpiresAt) {
222
- await deps.writeApprovals(approved.filePath, approved.state);
223
- return approval;
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}`);
224
345
  }
225
- approved.state.approvals[index] = {
226
- ...approval,
227
- executionLeaseExpiresAt: new Date(Date.now() + getExecutionLeaseMs(ctx.config)).toISOString(),
228
- };
229
- await deps.writeApprovals(approved.filePath, approved.state);
230
- return approval;
231
346
  }
232
347
  export async function evaluateGatedAction(ctx, deps, params) {
233
348
  let action;
@@ -243,65 +358,124 @@ export async function evaluateGatedAction(ctx, deps, params) {
243
358
  });
244
359
  }
245
360
  catch {
246
- const verdict = unnormalizedGateVerdict({
247
- reason: 'normalization_failed',
248
- mode: ctx.config.mode,
249
- user_message: 'belay could not normalize this gated action. Run belay doctor, then retry.',
250
- agent_message: 'Belay denied this action because the hook payload could not be normalized.',
251
- });
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
+ };
252
385
  await deps.appendAudit(ctx, {
253
386
  event: gateAuditEventName(params.kind),
254
387
  kind: params.kind,
388
+ fingerprint: verdict.fingerprint,
255
389
  verdict: verdict.verdict,
256
390
  reason: verdict.reason,
257
391
  mode: ctx.config.mode,
392
+ ...effectPlanAuditFields(verdict.effectPlan),
258
393
  wouldBlock: true,
259
394
  permission: 'deny',
260
395
  });
261
396
  return verdict;
262
397
  }
263
398
  if (!gateEnabledForAction(ctx.config, action)) {
264
- return classifyResultToGateVerdict({
265
- result: {
266
- verdict: 'allow',
267
- reason: 'gate_disabled',
268
- fingerprint: 'gate_disabled',
269
- assessment: {
270
- reversibility: 'reversible',
271
- external: false,
272
- blastRadius: 'none',
273
- confidence: 1,
274
- signals: ['gate_disabled'],
275
- },
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'],
276
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,
277
420
  mode: ctx.config.mode,
278
421
  permission: 'allow',
279
422
  wouldBlock: false,
280
423
  });
281
424
  }
282
425
  const classifierOptions = runtimeClassifierOptions(ctx, ctx.config);
283
- const predicted = await classifyGatedActionAsync(action, ctx.config, classifierOptions);
426
+ const approvedForAuth = await deps.loadApprovals(ctx, 'approved-approvals.json');
427
+ const authorization = await loadClassifierAuthorization({
428
+ repoRoot: ctx.repoRoot,
429
+ config: ctx.config,
430
+ approvedState: compactApprovals(approvedForAuth.state),
431
+ });
432
+ const egress = await egressStatus({ targetDir: ctx.repoRoot });
433
+ const egressProxyActive = isEgressProxyActive({
434
+ config: ctx.config,
435
+ running: egress.running,
436
+ foreignProxy: egress.foreignProxy,
437
+ repoRootMismatch: egress.repoRootMismatch,
438
+ });
439
+ const enrichedClassifierOptions = {
440
+ ...classifierOptions,
441
+ ...authorization,
442
+ egressProxyActive,
443
+ };
444
+ const predicted = await classifyGatedActionAsync(action, ctx.config, enrichedClassifierOptions);
284
445
  let result = predicted;
285
446
  let predictedAssessment;
286
447
  let observedAssessment;
287
448
  let transactionalLayer;
288
- if (isTransactionalEligible(ctx.config, params.kind, predicted) &&
449
+ const recoveryCandidate = isTransactionalEligible(ctx.config, params.kind, predicted) &&
289
450
  params.kind === 'shell' &&
290
- params.command) {
451
+ Boolean(params.command);
452
+ if (recoveryCandidate && params.command) {
291
453
  const transactional = ctx.config.policy.transactional;
454
+ const boundaryDriverId = ctx.config.capability?.boundaryDriver ??
455
+ (ctx.config.sandbox.runtime === 'container' ? 'container' : 'host-integration');
456
+ const boundaryContext = await resolveBoundaryDriverContext({
457
+ repoRoot: ctx.repoRoot,
458
+ config: ctx.config,
459
+ driverId: boundaryDriverId,
460
+ egressProxyRunning: egressProxyActive,
461
+ });
292
462
  const txResult = await runTransactionalExecution({
293
463
  command: params.command,
294
464
  cwd: params.cwd,
295
465
  repoRoot: ctx.repoRoot,
296
- stateDir: path.join(ctx.layout.repoLocalStateDir(ctx.repoRoot), 'transactional'),
466
+ stateDir: belayStateDir(ctx.config, ctx.layout.repoLocalStateDir(ctx.repoRoot)),
297
467
  timeoutMs: transactional.timeoutMs,
298
468
  predicted,
299
469
  diffContext: {
300
470
  repoRoot: ctx.repoRoot,
301
471
  sensitivePaths: ctx.config.classifier.sensitivePaths,
302
- protectedRoots: classifierOptions.protectedArtifactRoots ?? [],
472
+ protectedRoots: enrichedClassifierOptions.protectedArtifactRoots ?? [],
303
473
  maxDeletionCount: transactional.maxDeletionCount,
304
474
  },
475
+ boundaryContext,
476
+ dirtyIgnoreRoots: protectedArtifactRoots(ctx.layout, ctx.repoRoot, ctx.config.controlPlane.enabled ? configuredControlPlaneDir(ctx.config) : null),
477
+ fileCheckpoint: transactional.fileCheckpoint,
478
+ checkpoint: transactional.checkpoint,
305
479
  });
306
480
  if (!txResult.skipped && txResult.observed) {
307
481
  result = txResult.result;
@@ -309,16 +483,49 @@ export async function evaluateGatedAction(ctx, deps, params) {
309
483
  observedAssessment = txResult.observed.assessment;
310
484
  transactionalLayer = {
311
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',
312
496
  transactionalReason: txResult.observed.reason,
313
497
  transactionalCategories: txResult.observed.categories,
314
498
  transactionalChangeCount: txResult.observed.changes.length,
315
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
+ : {}),
316
508
  };
317
509
  }
318
- else if (txResult.skipReason) {
510
+ else {
511
+ const skipReason = txResult.skipReason ?? 'recovery_observation_failed';
512
+ const failReason = skipReason === FILE_CHECKPOINT_ISOLATION_UNAVAILABLE
513
+ ? FILE_CHECKPOINT_ISOLATION_UNAVAILABLE
514
+ : recoveryFailReasonFromSkip(skipReason);
515
+ result = recoveryFailClosedResult(predicted, failReason, [skipReason]);
319
516
  transactionalLayer = {
320
517
  transactional: false,
321
- transactionalSkipReason: txResult.skipReason,
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,
526
+ transactionalSkipReason: skipReason,
527
+ transactionalSkipDetail: txResult.skipDetail,
528
+ recoveryFailClosed: true,
322
529
  };
323
530
  }
324
531
  }
@@ -353,7 +560,7 @@ export async function evaluateGatedAction(ctx, deps, params) {
353
560
  fingerprint: result.fingerprint,
354
561
  repoRoot: ctx.repoRoot,
355
562
  },
356
- classifierOptions,
563
+ classifierOptions: enrichedClassifierOptions,
357
564
  scopeHintPayload: params.payload,
358
565
  });
359
566
  }
@@ -392,9 +599,58 @@ export async function gateUnmappedToolVerdict(ctx, deps, toolName, payload) {
392
599
  },
393
600
  });
394
601
  }
602
+ async function consumeCapabilityGrantIfUsed(ctx, deps, result) {
603
+ if (result.reason !== 'capability_grant' &&
604
+ result.authorizationDecision?.matchedRule !== 'grant.exact') {
605
+ return true;
606
+ }
607
+ if (!canConsumeCapabilityGrantLease(result)) {
608
+ return false;
609
+ }
610
+ const requests = result.capabilityRequests ?? [];
611
+ if (!requests.length) {
612
+ return true;
613
+ }
614
+ const consumed = await mutateApprovalStateWithRetry({
615
+ load: () => deps.loadApprovals(ctx, 'approved-approvals.json'),
616
+ write: (filePath, state) => deps.writeApprovals(filePath, state),
617
+ mutate: (state) => {
618
+ const compacted = compactApprovals(state);
619
+ const lease = consumeGrantLeasesForRequests(compacted, requests);
620
+ if (!lease.consumed) {
621
+ return null;
622
+ }
623
+ return { state: lease.state, result: true };
624
+ },
625
+ });
626
+ return consumed === true;
627
+ }
395
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
+ }
396
641
  const replayContext = buildAuditReplayContext(kind, result, auditExtras.replayAction);
397
642
  const actionSnapshot = buildAuditActionSnapshot(kind, result, auditExtras.replayAction);
643
+ const providerId = String(ctx.config.judge?.providerId ?? 'cursor');
644
+ const stateDir = belayStateDir(ctx.config, ctx.layout.repoLocalStateDir(ctx.repoRoot));
645
+ const capabilityAudit = scheduleGateShadowAudit({
646
+ repoRoot: ctx.repoRoot,
647
+ config: ctx.config,
648
+ providerId,
649
+ result,
650
+ command: auditExtras.replayAction?.command ??
651
+ (kind === 'shell' ? auditExtras.approvalInput?.input : undefined),
652
+ stateDir,
653
+ });
398
654
  const gateBase = {
399
655
  event: gateAuditEventName(kind),
400
656
  kind,
@@ -406,10 +662,92 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
406
662
  mode: ctx.config.mode,
407
663
  schemaVersion: result.axes ? 2 : 1,
408
664
  ...(result.axes ?? {}),
665
+ ...capabilityAudit,
409
666
  ...auditExtras.transactionalLayer,
410
667
  ...(replayContext ? { replayContext } : {}),
411
668
  ...(actionSnapshot ? { actionSnapshot } : {}),
412
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
+ };
413
751
  if (result.reason === TRANSACTIONAL_ALREADY_APPLIED) {
414
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.';
415
753
  const agentMessage = 'Belay already applied the observed-safe effects of this shell command in isolation. Do not run it again.';
@@ -470,40 +808,56 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
470
808
  entry.fingerprint === result.fingerprint &&
471
809
  entry.repoRoot === ctx.repoRoot);
472
810
  if (!shouldSkipBrokerApprovedRecord(brokerActive, matchedApproval?.reason)) {
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.',
818
+ });
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.',
825
+ });
826
+ }
473
827
  if (matchedApproval &&
474
828
  auditExtras.replayAction &&
475
829
  !validateReplayEnvelope(matchedApproval, auditExtras.replayAction)) {
476
- await deps.appendAudit(ctx, {
477
- ...gateBase,
478
- verdict: 'deny_pending_approval',
479
- reason: 'approval_replay_mismatch',
480
- approvalId: matchedApproval.approvalId,
481
- wouldBlock: true,
482
- permission: 'deny',
483
- });
484
- return classifyResultToGateVerdict({
485
- result: {
486
- ...result,
487
- verdict: 'deny_pending_approval',
488
- reason: 'approval_replay_mismatch',
489
- },
490
- mode: ctx.config.mode,
491
- permission: 'deny',
492
- wouldBlock: true,
493
- approvalId: matchedApproval.approvalId,
494
- user_message: 'Belay denied this action because it does not match the approved replay envelope. Re-approve the exact action or run belay explain.',
495
- 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.',
496
833
  });
497
834
  }
498
- approved = await consumeApprovedApproval(ctx, deps, kind, result.fingerprint);
835
+ approved = await consumeApprovedApproval(ctx, deps, kind, result.fingerprint, result.capabilityRequests ?? []);
499
836
  }
500
837
  }
501
- 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') {
852
+ if (approved.firstExecution) {
853
+ await recordGateApprovalAsk(stateDir, result.reason, true);
854
+ }
502
855
  await deps.appendAudit(ctx, {
503
856
  ...gateBase,
504
857
  verdict: 'allow',
505
858
  reason: 'approved_once',
506
- approvalId: approved.approvalId,
859
+ approvalId: approved.approval.approvalId,
860
+ authorizationMode: approved.approval.grantBundleVersion === 1 ? 'exact_bundle' : 'legacy_approval',
507
861
  wouldBlock: false,
508
862
  permission: 'allow',
509
863
  });
@@ -512,10 +866,32 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
512
866
  mode: ctx.config.mode,
513
867
  permission: 'allow',
514
868
  wouldBlock: false,
515
- approvalId: approved.approvalId,
869
+ approvalId: approved.approval.approvalId,
516
870
  });
517
871
  }
518
872
  if (result.verdict === 'allow' || result.verdict === 'allow_flagged') {
873
+ const grantConsumed = await consumeCapabilityGrantIfUsed(ctx, deps, result);
874
+ if (!grantConsumed) {
875
+ await deps.appendAudit(ctx, {
876
+ ...gateBase,
877
+ verdict: 'deny_pending_approval',
878
+ reason: 'capability_grant_unavailable',
879
+ wouldBlock: true,
880
+ permission: 'deny',
881
+ });
882
+ return classifyResultToGateVerdict({
883
+ result: {
884
+ ...result,
885
+ verdict: 'deny_pending_approval',
886
+ reason: 'capability_grant_unavailable',
887
+ },
888
+ mode: ctx.config.mode,
889
+ permission: 'deny',
890
+ wouldBlock: true,
891
+ user_message: 'Belay denied this action because the capability grant could not be consumed. Re-approve or run belay explain.',
892
+ agent_message: 'Belay denied this action because the capability grant was missing, expired, or already used.',
893
+ });
894
+ }
519
895
  await deps.appendAudit(ctx, {
520
896
  ...gateBase,
521
897
  verdict: result.verdict,
@@ -545,58 +921,62 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
545
921
  wouldBlock: true,
546
922
  });
547
923
  }
548
- const approval = await ensurePendingApproval(ctx, deps, kind, result, auditExtras.approvalInput, deriveWorkspaceRootScopeHint({
549
- result,
550
- replayAction: auditExtras.replayAction,
551
- payloadForScopeHint: auditExtras.scopeHintPayload,
552
- options: auditExtras.classifierOptions ?? {},
553
- }));
554
- let approvalToken;
555
- try {
556
- approvalToken = await issueApprovalToken({
557
- approvalId: approval.approvalId,
558
- fingerprint: approval.fingerprint,
559
- repoRoot: approval.repoRoot,
560
- issuedAt: approval.createdAt,
561
- expiresAt: approval.expiresAt,
562
- }, configuredControlPlaneDir(ctx.config));
563
- }
564
- catch {
565
- approvalToken = undefined;
924
+ if (isJudgeInfrastructureFailure(result)) {
925
+ const fallbackReason = extractJudgeFallbackReason(result);
926
+ const providerId = inferProviderIdFromFallbackReason(fallbackReason, String(ctx.config.judge.providerId ?? 'cursor'));
927
+ const denyMessages = formatJudgeInfrastructureDenyMessage({
928
+ providerId,
929
+ fallbackReason,
930
+ command: result.normalizedCommand ?? result.summary,
931
+ });
932
+ await deps.appendAudit(ctx, {
933
+ ...gateBase,
934
+ verdict: result.verdict,
935
+ reason: 'judge_transport_unavailable',
936
+ judgeFallbackReason: fallbackReason,
937
+ wouldBlock: true,
938
+ permission: 'deny',
939
+ });
940
+ return classifyResultToGateVerdict({
941
+ result: {
942
+ ...result,
943
+ verdict: 'deny_pending_approval',
944
+ reason: 'judge_transport_unavailable',
945
+ },
946
+ mode: ctx.config.mode,
947
+ permission: 'deny',
948
+ wouldBlock: true,
949
+ user_message: denyMessages.user_message,
950
+ agent_message: denyMessages.agent_message,
951
+ });
566
952
  }
567
- if (ctx.config.notifications.webhookUrl || ctx.config.notifications.commandHook) {
568
- await notifyDeny(ctx.config.notifications, {
569
- 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,
570
959
  reason: result.reason,
571
- summary: result.normalizedCommand ?? result.summary ?? '',
572
- repoRoot: ctx.repoRoot,
573
- fingerprint: result.fingerprint,
574
- 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,
575
970
  });
576
971
  }
577
- await deps.appendAudit(ctx, {
578
- ...gateBase,
579
- verdict: result.verdict,
580
- reason: result.reason,
581
- approvalId: approval.approvalId,
582
- wouldBlock: true,
583
- permission: 'deny',
584
- });
585
- return classifyResultToGateVerdict({
586
- result,
587
- mode: ctx.config.mode,
588
- permission: 'deny',
589
- wouldBlock: true,
590
- approvalId: approval.approvalId,
591
- 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.`,
592
- agent_message: `Belay denied this action as ${result.reason}. Wait for approval, then retry the exact same action once.`,
593
- });
972
+ return denyWithPendingApproval();
594
973
  }
595
974
  export async function processApprovalPrompt(ctx, deps, prompt) {
596
975
  const approvalId = approvalCommandMatch(prompt, ctx.config.tokenPrefix);
597
976
  if (!approvalId) {
598
977
  return { continue: true };
599
978
  }
979
+ const hasFollowupInstruction = prompt.split(/\r?\n/).filter((line) => line.trim().length > 0).length > 1;
600
980
  const adapter = adapterIdFromContext(ctx);
601
981
  if (ctx.config.approvalSigning.required) {
602
982
  const message = `Signed approval token required for ${approvalId}. Editor prompt approval is disabled in this configuration. ` +
@@ -639,62 +1019,68 @@ export async function processApprovalPrompt(ctx, deps, prompt) {
639
1019
  };
640
1020
  }
641
1021
  const replay = recorded.approval ? buildReplayHint(ctx.config, recorded.approval, adapter) : null;
642
- const shouldFallbackReplay = adapter === 'cursor' &&
643
- replay?.kind === 'shell' &&
1022
+ const shouldReplayApprovedShell = replay?.kind === 'shell' &&
644
1023
  replay.autoReplay === true &&
645
1024
  Boolean(recorded.approval?.input) &&
646
1025
  (!process.env.VITEST || process.env.BELAY_TEST_APPROVAL_REPLAY === '1');
647
- if (shouldFallbackReplay && recorded.approval) {
1026
+ if (shouldReplayApprovedShell && recorded.approval) {
648
1027
  const approvalStore = gateApprovalStoreFromDeps({
649
1028
  loadApprovals: (fileName) => deps.loadApprovals(ctx, fileName),
650
1029
  writeApprovals: (filePath, state) => deps.writeApprovals(filePath, state),
651
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
+ }
652
1048
  let replayResult;
653
1049
  try {
654
- 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));
655
1051
  }
656
1052
  catch (error) {
657
- const detail = error instanceof Error ? error.message : String(error);
658
- await deps.appendAudit(ctx, {
1053
+ const cleanupUnconfirmed = isBoundaryCleanupError(error);
1054
+ const auditRecorded = await appendReplayAuditSafely(ctx, deps, {
659
1055
  event: 'approval',
660
1056
  kind: 'approval',
661
1057
  verdict: 'allow',
662
1058
  approvalId,
663
- reason: 'approval_replay_error',
1059
+ reason: cleanupUnconfirmed
1060
+ ? 'approval_replay_cleanup_unconfirmed'
1061
+ : 'approval_replay_error',
664
1062
  summary: recorded.approval.input ?? recorded.approval.summary ?? prompt,
665
1063
  });
666
- return {
667
- continue: false,
668
- user_message: `Belay approval recorded for ${approvalId}, but one-step shell replay could not start (${detail}). ` +
669
- 'Approval remains active for one hook retry.',
670
- };
671
- }
672
- const output = [replayResult.stdout, replayResult.stderr].filter(Boolean).join('\n').trim();
673
- if (replayResult.exitCode === 0) {
674
- try {
675
- await consumeApprovedAfterCliReplay({
676
- approvalId: approvalId,
677
- store: approvalStore,
678
- });
679
- }
680
- catch (error) {
681
- const detail = error instanceof Error ? error.message : String(error);
682
- await deps.appendAudit(ctx, {
683
- event: 'approval',
684
- kind: 'approval',
685
- verdict: 'allow',
686
- approvalId,
687
- reason: 'approval_replay_consume_error',
688
- summary: recorded.approval.input ?? recorded.approval.summary ?? prompt,
689
- });
1064
+ if (cleanupUnconfirmed) {
1065
+ const resourceId = safeBoundaryCleanupResourceId(error);
690
1066
  return {
691
1067
  continue: false,
692
- user_message: `Belay approval recorded for ${approvalId}, and one-step shell replay succeeded, but approval finalization failed (${detail}). ` +
693
- 'Approval remains active for one hook retry.' +
694
- (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),
695
1073
  };
696
1074
  }
697
- 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, {
698
1084
  event: 'approval',
699
1085
  kind: 'approval',
700
1086
  verdict: 'allow',
@@ -703,12 +1089,12 @@ export async function processApprovalPrompt(ctx, deps, prompt) {
703
1089
  summary: recorded.approval.input ?? recorded.approval.summary ?? prompt,
704
1090
  });
705
1091
  return {
706
- continue: false,
1092
+ continue: true,
707
1093
  user_message: `Belay approval recorded for ${approvalId}. One-step shell replay succeeded; no manual retry required.` +
708
- (output ? `\n${output}` : ''),
1094
+ (auditRecorded ? '' : REPLAY_AUDIT_FAILURE_NOTE),
709
1095
  };
710
1096
  }
711
- await deps.appendAudit(ctx, {
1097
+ const auditRecorded = await appendReplayAuditSafely(ctx, deps, {
712
1098
  event: 'approval',
713
1099
  kind: 'approval',
714
1100
  verdict: 'allow',
@@ -716,16 +1102,18 @@ export async function processApprovalPrompt(ctx, deps, prompt) {
716
1102
  reason: 'approval_replay_failed',
717
1103
  summary: recorded.approval.input ?? recorded.approval.summary ?? prompt,
718
1104
  });
719
- 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}).`;
720
1108
  return {
721
1109
  continue: false,
722
- user_message: `Belay approval recorded for ${approvalId}, but one-step shell replay failed (exit ${replayResult.exitCode}).${timeoutNote} ` +
723
- `Approval remains active for one hook retry.` +
724
- (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),
725
1113
  };
726
1114
  }
727
1115
  return {
728
- continue: false,
1116
+ continue: replay !== null || hasFollowupInstruction,
729
1117
  user_message: recorded.message,
730
1118
  ...(replay ? { replay } : {}),
731
1119
  };
@@ -753,15 +1141,18 @@ export function gateVerdictToClaudePreToolUseResponse(verdict) {
753
1141
  }
754
1142
  export function gateVerdictToClaudeUserPromptResponse(verdict) {
755
1143
  if (verdict.continue) {
756
- return {};
1144
+ return verdict.user_message
1145
+ ? {
1146
+ hookSpecificOutput: {
1147
+ hookEventName: 'UserPromptSubmit',
1148
+ additionalContext: verdict.user_message,
1149
+ },
1150
+ }
1151
+ : {};
757
1152
  }
758
1153
  return {
759
- hookSpecificOutput: {
760
- hookEventName: 'UserPromptSubmit',
761
- continue: false,
762
- user_message: verdict.user_message,
763
- ...(verdict.replay ? { replay: verdict.replay } : {}),
764
- },
1154
+ decision: 'block',
1155
+ reason: verdict.user_message,
765
1156
  };
766
1157
  }
767
1158
  // Codex PreToolUse deny contract is identical to Claude's
@@ -772,7 +1163,14 @@ export function gateVerdictToCodexPreToolUseResponse(verdict) {
772
1163
  // Codex UserPromptSubmit blocks via `decision: "block"` (per developers.openai.com/codex/hooks).
773
1164
  export function gateVerdictToCodexUserPromptResponse(verdict) {
774
1165
  if (verdict.continue) {
775
- return {};
1166
+ return verdict.user_message
1167
+ ? {
1168
+ hookSpecificOutput: {
1169
+ hookEventName: 'UserPromptSubmit',
1170
+ additionalContext: verdict.user_message,
1171
+ },
1172
+ }
1173
+ : {};
776
1174
  }
777
1175
  return {
778
1176
  decision: 'block',