@guilz-dev/belay 0.3.0 → 0.6.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 (242) hide show
  1. package/README.md +7 -1
  2. package/dist/adapters/cursor/runtime-entry.js +1 -0
  3. package/dist/adapters/layouts/scope.js +3 -2
  4. package/dist/adapters/shared/gate-runtime.d.ts +12 -16
  5. package/dist/adapters/shared/gate-runtime.js +499 -43
  6. package/dist/bundle/claude-runtime.mjs +8004 -2676
  7. package/dist/bundle/codex-runtime.mjs +8021 -2683
  8. package/dist/bundle/cursor-runtime.mjs +7993 -2667
  9. package/dist/cli.js +180 -6
  10. package/dist/commands/approve.d.ts +1 -0
  11. package/dist/commands/approve.js +45 -26
  12. package/dist/commands/classify-for-report.js +1 -1
  13. package/dist/commands/config.d.ts +8 -0
  14. package/dist/commands/config.js +264 -58
  15. package/dist/commands/doctor.js +47 -2
  16. package/dist/commands/explain.js +17 -2
  17. package/dist/commands/harvest.d.ts +29 -0
  18. package/dist/commands/harvest.js +72 -0
  19. package/dist/commands/judge.d.ts +11 -1
  20. package/dist/commands/judge.js +24 -4
  21. package/dist/commands/metrics.js +34 -2
  22. package/dist/commands/quality.d.ts +38 -0
  23. package/dist/commands/quality.js +105 -0
  24. package/dist/commands/session.d.ts +20 -0
  25. package/dist/commands/session.js +51 -0
  26. package/dist/commands/simulate.d.ts +1 -0
  27. package/dist/commands/simulate.js +7 -2
  28. package/dist/commands/standing-allow.d.ts +10 -0
  29. package/dist/commands/standing-allow.js +26 -0
  30. package/dist/commands/tui.d.ts +48 -0
  31. package/dist/commands/tui.js +150 -0
  32. package/dist/config-io.js +59 -17
  33. package/dist/conformance/guarantee-posture.d.ts +21 -0
  34. package/dist/conformance/guarantee-posture.js +46 -0
  35. package/dist/conformance/guarantee-table.d.ts +1 -0
  36. package/dist/conformance/guarantee-table.js +14 -9
  37. package/dist/core/approval-replay-cli.d.ts +7 -0
  38. package/dist/core/approval-replay-cli.js +36 -0
  39. package/dist/core/approval-replay.d.ts +45 -0
  40. package/dist/core/approval-replay.js +141 -0
  41. package/dist/core/approval-service.d.ts +15 -0
  42. package/dist/core/approval-service.js +53 -4
  43. package/dist/core/approval.d.ts +26 -3
  44. package/dist/core/approval.js +61 -7
  45. package/dist/core/audit-analysis.d.ts +7 -1
  46. package/dist/core/audit-analysis.js +111 -0
  47. package/dist/core/audit-metrics.d.ts +7 -0
  48. package/dist/core/audit-metrics.js +24 -4
  49. package/dist/core/audit-query.d.ts +1 -0
  50. package/dist/core/audit-query.js +3 -0
  51. package/dist/core/audit-replay-context.d.ts +35 -0
  52. package/dist/core/audit-replay-context.js +88 -0
  53. package/dist/core/audit-types.d.ts +24 -1
  54. package/dist/core/audit-types.js +1 -1
  55. package/dist/core/capability/approval-state-mutation.d.ts +13 -0
  56. package/dist/core/capability/approval-state-mutation.js +92 -0
  57. package/dist/core/capability/approval-v3.d.ts +15 -0
  58. package/dist/core/capability/approval-v3.js +74 -0
  59. package/dist/core/capability/attestation.d.ts +13 -0
  60. package/dist/core/capability/attestation.js +55 -0
  61. package/dist/core/capability/boundary-attestation-sign.d.ts +21 -0
  62. package/dist/core/capability/boundary-attestation-sign.js +84 -0
  63. package/dist/core/capability/boundary-driver-container.d.ts +7 -0
  64. package/dist/core/capability/boundary-driver-container.js +122 -0
  65. package/dist/core/capability/boundary-driver.d.ts +29 -0
  66. package/dist/core/capability/boundary-driver.js +43 -0
  67. package/dist/core/capability/boundary-egress.d.ts +21 -0
  68. package/dist/core/capability/boundary-egress.js +109 -0
  69. package/dist/core/capability/boundary-grant-materialize.d.ts +13 -0
  70. package/dist/core/capability/boundary-grant-materialize.js +78 -0
  71. package/dist/core/capability/boundary-profile.d.ts +15 -0
  72. package/dist/core/capability/boundary-profile.js +25 -0
  73. package/dist/core/capability/boundary-run.d.ts +23 -0
  74. package/dist/core/capability/boundary-run.js +23 -0
  75. package/dist/core/capability/boundary-session.d.ts +57 -0
  76. package/dist/core/capability/boundary-session.js +129 -0
  77. package/dist/core/capability/capability-request-hash.d.ts +2 -0
  78. package/dist/core/capability/capability-request-hash.js +8 -0
  79. package/dist/core/capability/gate-latency-ratchet.d.ts +7 -0
  80. package/dist/core/capability/gate-latency-ratchet.js +18 -0
  81. package/dist/core/capability/gate-policy-shadow.d.ts +25 -0
  82. package/dist/core/capability/gate-policy-shadow.js +108 -0
  83. package/dist/core/capability/gate-shadow-audit.d.ts +1 -0
  84. package/dist/core/capability/gate-shadow-audit.js +1 -0
  85. package/dist/core/capability/gate-shadow-ratchet.d.ts +14 -0
  86. package/dist/core/capability/gate-shadow-ratchet.js +81 -0
  87. package/dist/core/capability/grant-lease.d.ts +9 -0
  88. package/dist/core/capability/grant-lease.js +46 -0
  89. package/dist/core/capability/grant-loader.d.ts +12 -0
  90. package/dist/core/capability/grant-loader.js +23 -0
  91. package/dist/core/capability/grant-match.d.ts +6 -0
  92. package/dist/core/capability/grant-match.js +93 -0
  93. package/dist/core/capability/grant.d.ts +16 -0
  94. package/dist/core/capability/grant.js +16 -0
  95. package/dist/core/capability/index.d.ts +10 -1
  96. package/dist/core/capability/index.js +8 -0
  97. package/dist/core/capability/limits.d.ts +7 -0
  98. package/dist/core/capability/limits.js +55 -0
  99. package/dist/core/capability/paths.d.ts +2 -2
  100. package/dist/core/capability/paths.js +9 -9
  101. package/dist/core/capability/policy-bridge.d.ts +18 -0
  102. package/dist/core/capability/policy-bridge.js +51 -0
  103. package/dist/core/capability/policy-engine.d.ts +70 -0
  104. package/dist/core/capability/policy-engine.js +476 -0
  105. package/dist/core/capability/policy-types.d.ts +20 -0
  106. package/dist/core/capability/policy-types.js +1 -0
  107. package/dist/core/capability/request.d.ts +45 -0
  108. package/dist/core/capability/request.js +1 -0
  109. package/dist/core/capability/resolver.d.ts +4 -0
  110. package/dist/core/capability/resolver.js +4 -0
  111. package/dist/core/capability/trusted-workspace-roots.d.ts +25 -0
  112. package/dist/core/capability/trusted-workspace-roots.js +149 -0
  113. package/dist/core/capability/types.d.ts +11 -1
  114. package/dist/core/capability-approval.d.ts +2 -1
  115. package/dist/core/capability-approval.js +100 -2
  116. package/dist/core/classify-subagent.d.ts +2 -1
  117. package/dist/core/classify-subagent.js +40 -22
  118. package/dist/core/classify-tool.js +157 -64
  119. package/dist/core/config.d.ts +32 -3
  120. package/dist/core/config.js +105 -3
  121. package/dist/core/gate-contract.d.ts +3 -0
  122. package/dist/core/gate-contract.js +3 -0
  123. package/dist/core/gate-engine.js +12 -7
  124. package/dist/core/harvest.d.ts +53 -0
  125. package/dist/core/harvest.js +276 -0
  126. package/dist/core/index.d.ts +9 -3
  127. package/dist/core/index.js +6 -2
  128. package/dist/core/judge-doctor.d.ts +3 -0
  129. package/dist/core/judge-doctor.js +90 -11
  130. package/dist/core/judge-fallback-hints.d.ts +13 -0
  131. package/dist/core/judge-fallback-hints.js +108 -0
  132. package/dist/core/path-utils.d.ts +12 -0
  133. package/dist/core/path-utils.js +60 -7
  134. package/dist/core/reclassify.d.ts +3 -0
  135. package/dist/core/reclassify.js +44 -14
  136. package/dist/core/recovery/capability.d.ts +3 -0
  137. package/dist/core/recovery/capability.js +22 -0
  138. package/dist/core/recovery/fail-closed.d.ts +6 -0
  139. package/dist/core/recovery/fail-closed.js +25 -0
  140. package/dist/core/recovery/index.d.ts +3 -0
  141. package/dist/core/recovery/index.js +2 -0
  142. package/dist/core/recovery/types.d.ts +20 -0
  143. package/dist/core/recovery/types.js +1 -0
  144. package/dist/core/replay-scrub.d.ts +9 -0
  145. package/dist/core/replay-scrub.js +43 -0
  146. package/dist/core/shell-tokenizer.d.ts +2 -0
  147. package/dist/core/shell-tokenizer.js +82 -23
  148. package/dist/core/standing-allow.d.ts +50 -0
  149. package/dist/core/standing-allow.js +178 -0
  150. package/dist/core/transactional/diff-evaluator.js +4 -1
  151. package/dist/core/transactional/eligibility.js +4 -0
  152. package/dist/core/transactional/git-worktree.d.ts +10 -3
  153. package/dist/core/transactional/git-worktree.js +81 -11
  154. package/dist/core/transactional/reasons.d.ts +1 -0
  155. package/dist/core/transactional/reasons.js +4 -0
  156. package/dist/core/transactional/runner.js +22 -6
  157. package/dist/core/transactional/types.d.ts +4 -0
  158. package/dist/core/types.d.ts +36 -3
  159. package/dist/core/verdict/adapter.d.ts +3 -4
  160. package/dist/core/verdict/adapter.js +19 -6
  161. package/dist/core/verdict/containment.d.ts +4 -3
  162. package/dist/core/verdict/containment.js +17 -12
  163. package/dist/core/verdict/cursor-acp-client.d.ts +67 -0
  164. package/dist/core/verdict/cursor-acp-client.js +345 -0
  165. package/dist/core/verdict/egress-classify.d.ts +2 -2
  166. package/dist/core/verdict/egress-classify.js +2 -2
  167. package/dist/core/verdict/git-classifier.d.ts +26 -0
  168. package/dist/core/verdict/git-classifier.js +533 -0
  169. package/dist/core/verdict/judge-audit.d.ts +1 -0
  170. package/dist/core/verdict/judge-audit.js +33 -1
  171. package/dist/core/verdict/judge-baseline.d.ts +20 -0
  172. package/dist/core/verdict/judge-baseline.js +49 -0
  173. package/dist/core/verdict/judge-broker-service.d.ts +42 -0
  174. package/dist/core/verdict/judge-broker-service.js +291 -0
  175. package/dist/core/verdict/judge-catalog.js +1 -0
  176. package/dist/core/verdict/judge-cli-fingerprint.d.ts +6 -0
  177. package/dist/core/verdict/judge-cli-fingerprint.js +67 -0
  178. package/dist/core/verdict/judge-cli.d.ts +23 -0
  179. package/dist/core/verdict/judge-cli.js +139 -16
  180. package/dist/core/verdict/judge-factory.js +13 -4
  181. package/dist/core/verdict/judge-provider-matrix.d.ts +20 -0
  182. package/dist/core/verdict/judge-provider-matrix.js +66 -0
  183. package/dist/core/verdict/judge-runtime-config.d.ts +44 -0
  184. package/dist/core/verdict/judge-runtime-config.js +96 -0
  185. package/dist/core/verdict/judge-session-broker.d.ts +48 -0
  186. package/dist/core/verdict/judge-session-broker.js +200 -0
  187. package/dist/core/verdict/judge-session-guard.d.ts +27 -0
  188. package/dist/core/verdict/judge-session-guard.js +91 -0
  189. package/dist/core/verdict/judge-session-kill-switch.d.ts +11 -0
  190. package/dist/core/verdict/judge-session-kill-switch.js +43 -0
  191. package/dist/core/verdict/judge-session-mutex.d.ts +9 -0
  192. package/dist/core/verdict/judge-session-mutex.js +23 -0
  193. package/dist/core/verdict/judge-shadow.d.ts +24 -0
  194. package/dist/core/verdict/judge-shadow.js +116 -0
  195. package/dist/core/verdict/judge-transport.d.ts +44 -0
  196. package/dist/core/verdict/judge-transport.js +268 -0
  197. package/dist/core/verdict/judge.d.ts +14 -14
  198. package/dist/core/verdict/judge.js +5 -67
  199. package/dist/core/verdict/launcher-resolve.d.ts +2 -0
  200. package/dist/core/verdict/launcher-resolve.js +16 -0
  201. package/dist/core/verdict/parser.js +6 -1
  202. package/dist/core/verdict/prescan.d.ts +15 -0
  203. package/dist/core/verdict/prescan.js +68 -0
  204. package/dist/core/verdict/shell-policy.d.ts +25 -0
  205. package/dist/core/verdict/shell-policy.js +40 -0
  206. package/dist/core/verdict/shell-semantics.d.ts +24 -0
  207. package/dist/core/verdict/shell-semantics.js +225 -0
  208. package/dist/core/verdict/types.d.ts +26 -1
  209. package/dist/core/verdict/verdict.js +300 -179
  210. package/dist/corpus/adversarial-probe.d.ts +86 -0
  211. package/dist/corpus/adversarial-probe.js +220 -0
  212. package/dist/corpus/evaluate.d.ts +45 -12
  213. package/dist/corpus/evaluate.js +61 -8
  214. package/dist/corpus/gate-latency-budget.d.ts +39 -0
  215. package/dist/corpus/gate-latency-budget.js +33 -0
  216. package/dist/corpus/gates.d.ts +35 -0
  217. package/dist/corpus/gates.js +81 -0
  218. package/dist/corpus/judge-accuracy.d.ts +8 -0
  219. package/dist/corpus/judge-accuracy.js +39 -0
  220. package/dist/corpus/must-allow-commands.d.ts +5 -0
  221. package/dist/corpus/must-allow-commands.js +13 -0
  222. package/dist/corpus/mutators.d.ts +28 -0
  223. package/dist/corpus/mutators.js +162 -0
  224. package/dist/corpus/ratchet.d.ts +42 -0
  225. package/dist/corpus/ratchet.js +116 -0
  226. package/dist/corpus/runtime-match.d.ts +19 -0
  227. package/dist/corpus/runtime-match.js +43 -0
  228. package/dist/corpus/standing-allow-catalog.generated.d.ts +13 -0
  229. package/dist/corpus/standing-allow-catalog.generated.js +99 -0
  230. package/dist/corpus/types.d.ts +47 -0
  231. package/dist/corpus/types.js +157 -0
  232. package/dist/installer.js +4 -1
  233. package/dist/judge-broker-daemon.d.ts +1 -0
  234. package/dist/judge-broker-daemon.js +137 -0
  235. package/dist/services/sandbox-service.d.ts +5 -0
  236. package/dist/services/sandbox-service.js +30 -2
  237. package/dist/types.d.ts +2 -2
  238. package/dist/version.d.ts +1 -1
  239. package/dist/version.js +1 -1
  240. package/package.json +7 -3
  241. package/skills/belay/SKILL.md +7 -1
  242. package/skills/belay/belay-approve.md +17 -0
@@ -2,20 +2,48 @@ import { 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
- import { recordApproval } from '../../core/approval-service.js';
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
8
  import { issueApprovalToken } from '../../core/approval-token.js';
7
- import { fsScopeAllowlistPath, isCapabilityBrokerDemotionActive, loadFsScopeAllowlistSync, shouldSkipBrokerApprovedOnce, shouldSkipBrokerApprovedRecord, } from '../../core/capability/index.js';
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 { resolveBoundaryDriverContext } from '../../core/capability/boundary-session.js';
14
+ import { hashCapabilityRequests } from '../../core/capability/capability-request-hash.js';
15
+ import { recordGateApprovalAsk, scheduleGateShadowAudit, } from '../../core/capability/gate-shadow-audit.js';
16
+ import { consumeGrantLease, findMatchingGrant, grantsFromApprovedState, } from '../../core/capability/grant-lease.js';
17
+ import { loadClassifierAuthorization } from '../../core/capability/grant-loader.js';
18
+ import { collectOutsideRepoPaths, collectOutsideRepoPathsFromToolPayload, fsScopeAllowlistPath, isCapabilityBrokerDemotionActive, loadFsScopeAllowlistSync, loadTrustedWorkspaceRootsSync, shouldSkipBrokerApprovedOnce, shouldSkipBrokerApprovedRecord, trustedWorkspaceRootsPath, validateTrustedWorkspaceRootCandidate, } from '../../core/capability/index.js';
8
19
  import { resolveLayeredConfig, teamConfigPath } from '../../core/config-layers.js';
9
20
  import { classifyResultToGateVerdict, unnormalizedGateVerdict, } from '../../core/gate-contract.js';
10
21
  import { classifyGatedActionAsync, extractAgentAssessment, GateNormalizationError, gateEnabledForAction, normalizeGatedAction, } from '../../core/gate-engine.js';
11
- import { APPROVAL_EXECUTION_LEASE_MS, approvalCommandMatch, approvedApprovalsFile, buildRetryInstruction, canonicalStringify, classifierOptionsFromConfig, compactApprovals, configuredControlPlaneDir, createApprovalRecord, pendingApprovalsFile, resolveControlPlaneDir, scrubOptionsFromConfig, scrubValue, toolFingerprint, } from '../../core/index.js';
22
+ import { approvalCommandMatch, approvedApprovalsFile, belayStateDir, canonicalStringify, classifierOptionsFromConfig, configuredControlPlaneDir, pendingApprovalsFile, resolveControlPlaneDir, scrubOptionsFromConfig, scrubValue, toolFingerprint, } from '../../core/index.js';
23
+ import { extractJudgeFallbackReason, formatJudgeInfrastructureDenyMessage, inferProviderIdFromFallbackReason, isJudgeInfrastructureFailure, } from '../../core/judge-fallback-hints.js';
12
24
  import { notifyDeny } from '../../core/notify.js';
25
+ import { canonicalPath } from '../../core/path-utils.js';
26
+ import { recoveryFailClosedResult, recoveryFailReasonFromSkip, } from '../../core/recovery/fail-closed.js';
27
+ import { fingerprintReplayPayload } from '../../core/replay-scrub.js';
28
+ import { loadStandingAllow, resolveStandingAllowMatch, standingAllowFile, } from '../../core/standing-allow.js';
13
29
  import { isTransactionalEligible, runTransactionalExecution, TRANSACTIONAL_ALREADY_APPLIED, TRANSACTIONAL_APPROVAL_BYPASS_REASONS, } from '../../core/transactional/index.js';
30
+ import { egressStatus } from '../../services/egress-service.js';
14
31
  import { protectedArtifactRoots } from '../layouts/protected-paths.js';
15
32
  const EMPTY_APPROVALS = {
16
33
  version: 1,
17
34
  approvals: [],
18
35
  };
36
+ function adapterIdFromContext(ctx) {
37
+ if (ctx.config.adapter === 'cursor' ||
38
+ ctx.config.adapter === 'claude' ||
39
+ ctx.config.adapter === 'codex') {
40
+ return ctx.config.adapter;
41
+ }
42
+ if (ctx.layout.name === 'cursor' || ctx.layout.name === 'claude' || ctx.layout.name === 'codex') {
43
+ return ctx.layout.name;
44
+ }
45
+ return undefined;
46
+ }
19
47
  async function loadJsonFile(filePath, fallback) {
20
48
  try {
21
49
  const raw = await readFile(filePath, 'utf8');
@@ -49,10 +77,12 @@ export function createDefaultGateRuntimeDeps() {
49
77
  ? pendingApprovalsFile(ctx.config, repoLocalStateDir)
50
78
  : approvedApprovalsFile(ctx.config, repoLocalStateDir);
51
79
  const loaded = await loadJsonFile(filePath, EMPTY_APPROVALS);
80
+ const version = loaded.version === 3 ? 3 : loaded.version === 2 ? 2 : 1;
52
81
  return {
53
82
  filePath,
54
83
  state: {
55
- version: loaded.version === 2 ? 2 : 1,
84
+ version,
85
+ ...(loaded.revision !== undefined ? { revision: loaded.revision } : {}),
56
86
  approvals: Array.isArray(loaded.approvals) ? loaded.approvals : [],
57
87
  },
58
88
  };
@@ -90,11 +120,13 @@ export function repoShellClassifierOptions(config, repoRoot, layout, extras = {}
90
120
  export function runtimeClassifierOptions(ctx, config) {
91
121
  const repoLocalStateDir = ctx.layout.repoLocalStateDir(ctx.repoRoot);
92
122
  const brokerFsScope = isCapabilityBrokerDemotionActive(config);
123
+ const trustedRoots = loadTrustedWorkspaceRootsSync(trustedWorkspaceRootsPath(config, repoLocalStateDir));
93
124
  return repoShellClassifierOptions(config, ctx.repoRoot, ctx.layout, {
94
125
  brokerFsScope,
95
126
  fsScopeAllowlist: brokerFsScope
96
127
  ? loadFsScopeAllowlistSync(fsScopeAllowlistPath(config, repoLocalStateDir))
97
128
  : undefined,
129
+ trustedWorkspaceRoots: trustedRoots.roots.map((entry) => entry.path),
98
130
  });
99
131
  }
100
132
  function gateAuditEventName(kind) {
@@ -106,7 +138,7 @@ function gateAuditEventName(kind) {
106
138
  }
107
139
  return 'subagentGate';
108
140
  }
109
- async function ensurePendingApproval(ctx, deps, kind, result, approvalInput) {
141
+ async function ensurePendingApproval(ctx, deps, kind, result, approvalInput, scopeHint) {
110
142
  const pending = await deps.loadApprovals(ctx, 'pending-approvals.json');
111
143
  pending.state = compactApprovals(pending.state);
112
144
  const existing = pending.state.approvals.find((approval) => approval.kind === kind &&
@@ -114,9 +146,9 @@ async function ensurePendingApproval(ctx, deps, kind, result, approvalInput) {
114
146
  approval.repoRoot === ctx.repoRoot);
115
147
  if (existing) {
116
148
  await deps.writeApprovals(pending.filePath, pending.state);
117
- return existing;
149
+ return { approval: existing, created: false };
118
150
  }
119
- const approval = createApprovalRecord({
151
+ const approval = createApprovalRecordWithEnvelope({
120
152
  kind,
121
153
  fingerprint: result.fingerprint,
122
154
  repoRoot: ctx.repoRoot,
@@ -124,35 +156,120 @@ async function ensurePendingApproval(ctx, deps, kind, result, approvalInput) {
124
156
  summary: result.normalizedCommand ?? result.summary ?? '',
125
157
  approvalTtlMinutes: ctx.config.approvalTtlMinutes,
126
158
  approvalId: `belay_${randomUUID().replaceAll('-', '').slice(0, 12)}`,
127
- input: approvalInput?.input,
128
- inputKind: approvalInput?.inputKind,
159
+ approvalInput,
160
+ scopeHint,
161
+ capabilityRequests: result.capabilityRequests,
129
162
  });
130
- pending.state.version = 2;
163
+ pending.state.version = APPROVAL_STATE_VERSION_V3;
164
+ pending.state.revision = (pending.state.revision ?? 0) + 1;
131
165
  pending.state.approvals.push(approval);
132
166
  await deps.writeApprovals(pending.filePath, pending.state);
133
- return approval;
167
+ return { approval, created: true };
168
+ }
169
+ function extractShellCommandFromPayload(payload) {
170
+ const toolInput = payload.tool_input;
171
+ if (!toolInput || typeof toolInput !== 'object') {
172
+ return '';
173
+ }
174
+ const input = toolInput;
175
+ return typeof input.command === 'string' ? input.command : '';
176
+ }
177
+ function deriveWorkspaceRootScopeHint(params) {
178
+ if (params.result.reason !== 'outside_repo_mutation' &&
179
+ params.result.reason !== 'outside_repo_redirect') {
180
+ return undefined;
181
+ }
182
+ const action = params.replayAction;
183
+ if (!action?.cwd) {
184
+ return undefined;
185
+ }
186
+ const outsidePaths = new Set();
187
+ if (action.kind === 'shell' && action.command) {
188
+ for (const resolved of collectOutsideRepoPaths(action.command, action.cwd, action.repoRoot, params.options.trustedWorkspaceRoots)) {
189
+ outsidePaths.add(resolved);
190
+ }
191
+ }
192
+ else if (action.kind === 'tool') {
193
+ const toolPayload = params.payloadForScopeHint ?? action.payload ?? {};
194
+ for (const resolved of collectOutsideRepoPathsFromToolPayload(toolPayload, action.cwd, action.repoRoot, params.options.trustedWorkspaceRoots)) {
195
+ outsidePaths.add(resolved);
196
+ }
197
+ const command = extractShellCommandFromPayload(toolPayload);
198
+ if (command) {
199
+ for (const resolved of collectOutsideRepoPaths(command, action.cwd, action.repoRoot, params.options.trustedWorkspaceRoots)) {
200
+ outsidePaths.add(resolved);
201
+ }
202
+ }
203
+ }
204
+ if (outsidePaths.size !== 1) {
205
+ return undefined;
206
+ }
207
+ const targetPath = [...outsidePaths][0];
208
+ if (!targetPath) {
209
+ return undefined;
210
+ }
211
+ const candidateRoot = canonicalPath(path.dirname(targetPath));
212
+ const validation = validateTrustedWorkspaceRootCandidate({
213
+ candidatePath: candidateRoot,
214
+ repoRoot: action.repoRoot,
215
+ controlPlaneDir: params.options.controlPlaneDir ?? undefined,
216
+ protectedRoots: params.options.protectedArtifactRoots ?? [],
217
+ requireExistingDirectory: true,
218
+ requireNonGit: true,
219
+ });
220
+ if (!validation.ok) {
221
+ return undefined;
222
+ }
223
+ return { scope: 'workspace-root', path: validation.normalizedPath };
134
224
  }
135
225
  async function consumeApprovedApproval(ctx, deps, kind, fingerprint) {
136
- const approved = await deps.loadApprovals(ctx, 'approved-approvals.json');
137
- approved.state = compactApprovals(approved.state);
138
- const index = approved.state.approvals.findIndex((approval) => approval.kind === kind &&
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 &&
139
229
  approval.fingerprint === fingerprint &&
140
230
  approval.repoRoot === ctx.repoRoot);
141
231
  if (index === -1) {
142
- await deps.writeApprovals(approved.filePath, approved.state);
232
+ await deps.writeApprovals(loaded.filePath, loaded.state);
143
233
  return null;
144
234
  }
145
- const approval = approved.state.approvals[index];
235
+ const approval = loaded.state.approvals[index];
146
236
  if (approval.executionLeaseExpiresAt) {
147
- await deps.writeApprovals(approved.filePath, approved.state);
148
- return approval;
237
+ await deps.writeApprovals(loaded.filePath, loaded.state);
238
+ return { approval, firstExecution: false };
149
239
  }
150
- approved.state.approvals[index] = {
151
- ...approval,
152
- executionLeaseExpiresAt: new Date(Date.now() + APPROVAL_EXECUTION_LEASE_MS).toISOString(),
153
- };
154
- await deps.writeApprovals(approved.filePath, approved.state);
155
- return approval;
240
+ const consumed = await mutateApprovalStateWithRetry({
241
+ load: () => deps.loadApprovals(ctx, 'approved-approvals.json'),
242
+ write: (filePath, state) => deps.writeApprovals(filePath, state),
243
+ mutate: (state) => {
244
+ const compacted = compactApprovals(state);
245
+ const matchIndex = compacted.approvals.findIndex((approval) => approval.kind === kind &&
246
+ approval.fingerprint === fingerprint &&
247
+ approval.repoRoot === ctx.repoRoot);
248
+ if (matchIndex === -1) {
249
+ return null;
250
+ }
251
+ const approval = compacted.approvals[matchIndex];
252
+ if (approval.grant && approval.grant.usesRemaining <= 0) {
253
+ compacted.approvals.splice(matchIndex, 1);
254
+ return { state: compacted, result: null };
255
+ }
256
+ compacted.approvals[matchIndex] = {
257
+ ...approval,
258
+ 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
+ };
266
+ return { state: compacted, result: approval };
267
+ },
268
+ });
269
+ if (!consumed) {
270
+ return null;
271
+ }
272
+ return { approval: consumed, firstExecution: true };
156
273
  }
157
274
  export async function evaluateGatedAction(ctx, deps, params) {
158
275
  let action;
@@ -205,15 +322,42 @@ export async function evaluateGatedAction(ctx, deps, params) {
205
322
  });
206
323
  }
207
324
  const classifierOptions = runtimeClassifierOptions(ctx, ctx.config);
208
- const predicted = await classifyGatedActionAsync(action, ctx.config, classifierOptions);
325
+ const approvedForAuth = await deps.loadApprovals(ctx, 'approved-approvals.json');
326
+ const authorization = await loadClassifierAuthorization({
327
+ repoRoot: ctx.repoRoot,
328
+ config: ctx.config,
329
+ approvedState: compactApprovals(approvedForAuth.state),
330
+ });
331
+ const egress = await egressStatus({ targetDir: ctx.repoRoot });
332
+ const egressProxyActive = isEgressProxyActive({
333
+ config: ctx.config,
334
+ running: egress.running,
335
+ foreignProxy: egress.foreignProxy,
336
+ repoRootMismatch: egress.repoRootMismatch,
337
+ });
338
+ const enrichedClassifierOptions = {
339
+ ...classifierOptions,
340
+ ...authorization,
341
+ egressProxyActive,
342
+ };
343
+ const predicted = await classifyGatedActionAsync(action, ctx.config, enrichedClassifierOptions);
209
344
  let result = predicted;
210
345
  let predictedAssessment;
211
346
  let observedAssessment;
212
347
  let transactionalLayer;
213
- if (isTransactionalEligible(ctx.config, params.kind, predicted) &&
348
+ const recoveryCandidate = isTransactionalEligible(ctx.config, params.kind, predicted) &&
214
349
  params.kind === 'shell' &&
215
- params.command) {
350
+ Boolean(params.command);
351
+ if (recoveryCandidate && params.command) {
216
352
  const transactional = ctx.config.policy.transactional;
353
+ const boundaryDriverId = ctx.config.capability?.boundaryDriver ??
354
+ (ctx.config.sandbox.runtime === 'container' ? 'container' : 'host-integration');
355
+ const boundaryContext = await resolveBoundaryDriverContext({
356
+ repoRoot: ctx.repoRoot,
357
+ config: ctx.config,
358
+ driverId: boundaryDriverId,
359
+ egressProxyRunning: egressProxyActive,
360
+ });
217
361
  const txResult = await runTransactionalExecution({
218
362
  command: params.command,
219
363
  cwd: params.cwd,
@@ -224,9 +368,11 @@ export async function evaluateGatedAction(ctx, deps, params) {
224
368
  diffContext: {
225
369
  repoRoot: ctx.repoRoot,
226
370
  sensitivePaths: ctx.config.classifier.sensitivePaths,
227
- protectedRoots: classifierOptions.protectedArtifactRoots ?? [],
371
+ protectedRoots: enrichedClassifierOptions.protectedArtifactRoots ?? [],
228
372
  maxDeletionCount: transactional.maxDeletionCount,
229
373
  },
374
+ boundaryContext,
375
+ dirtyIgnoreRoots: protectedArtifactRoots(ctx.layout, ctx.repoRoot, ctx.config.controlPlane.enabled ? configuredControlPlaneDir(ctx.config) : null),
230
376
  });
231
377
  if (!txResult.skipped && txResult.observed) {
232
378
  result = txResult.result;
@@ -240,13 +386,20 @@ export async function evaluateGatedAction(ctx, deps, params) {
240
386
  transactionalTimedOut: txResult.timedOut === true,
241
387
  };
242
388
  }
243
- else if (txResult.skipReason) {
389
+ else {
390
+ const skipReason = txResult.skipReason ?? 'recovery_observation_failed';
391
+ result = recoveryFailClosedResult(predicted, recoveryFailReasonFromSkip(skipReason), [
392
+ skipReason,
393
+ ]);
244
394
  transactionalLayer = {
245
395
  transactional: false,
246
- transactionalSkipReason: txResult.skipReason,
396
+ transactionalSkipReason: skipReason,
397
+ recoveryFailClosed: true,
247
398
  };
248
399
  }
249
400
  }
401
+ const scrubOpts = scrubOptionsFromConfig(ctx.config);
402
+ const scrubbedPayload = fingerprintReplayPayload(params.kind, params.payload, scrubOpts);
250
403
  return gateDecisionToVerdict(ctx, deps, params.kind, result, {
251
404
  predictedAssessment,
252
405
  observedAssessment,
@@ -255,24 +408,40 @@ export async function evaluateGatedAction(ctx, deps, params) {
255
408
  ? {
256
409
  input: params.command ?? result.normalizedCommand ?? result.summary ?? '',
257
410
  inputKind: 'shell',
411
+ cwd: params.cwd,
258
412
  }
259
413
  : {
260
414
  input: params.command ??
261
415
  result.normalizedCommand ??
262
416
  result.summary ??
263
- canonicalStringify(params.payload ?? {}),
417
+ canonicalStringify(scrubbedPayload ?? {}),
264
418
  inputKind: params.kind,
419
+ cwd: params.cwd,
420
+ toolName: params.toolName,
421
+ payload: scrubbedPayload,
265
422
  },
423
+ replayAction: {
424
+ kind: params.kind,
425
+ cwd: params.cwd,
426
+ toolName: params.toolName,
427
+ command: params.command,
428
+ payload: scrubbedPayload,
429
+ fingerprint: result.fingerprint,
430
+ repoRoot: ctx.repoRoot,
431
+ },
432
+ classifierOptions: enrichedClassifierOptions,
433
+ scopeHintPayload: params.payload,
266
434
  });
267
435
  }
268
436
  /** R39: unmapped Codex tools ask via pending approval — not hard deny without approval path. */
269
437
  export async function gateUnmappedToolVerdict(ctx, deps, toolName, payload) {
270
- const scrubbed = scrubValue(payload, scrubOptionsFromConfig(ctx.config));
438
+ const scrubOpts = scrubOptionsFromConfig(ctx.config);
439
+ const replayPayload = fingerprintReplayPayload('tool', payload, scrubOpts) ?? {};
271
440
  const result = {
272
441
  verdict: 'deny_pending_approval',
273
442
  reason: 'unmapped_tool',
274
443
  summary: toolName,
275
- fingerprint: toolFingerprint(toolName, scrubbed, ctx.repoRoot),
444
+ fingerprint: toolFingerprint(toolName, replayPayload, ctx.repoRoot),
276
445
  assessment: {
277
446
  reversibility: 'irreversible',
278
447
  external: false,
@@ -285,10 +454,61 @@ export async function gateUnmappedToolVerdict(ctx, deps, toolName, payload) {
285
454
  approvalInput: {
286
455
  input: toolName,
287
456
  inputKind: 'tool',
457
+ cwd: ctx.repoRoot,
458
+ toolName,
459
+ payload: replayPayload,
460
+ },
461
+ replayAction: {
462
+ kind: 'tool',
463
+ cwd: ctx.repoRoot,
464
+ toolName,
465
+ payload: replayPayload,
466
+ fingerprint: result.fingerprint,
467
+ repoRoot: ctx.repoRoot,
468
+ },
469
+ });
470
+ }
471
+ async function consumeCapabilityGrantIfUsed(ctx, deps, result) {
472
+ if (result.reason !== 'capability_grant' &&
473
+ result.authorizationDecision?.matchedRule !== 'grant.exact') {
474
+ return true;
475
+ }
476
+ const request = result.capabilityRequests?.[0];
477
+ if (!request) {
478
+ return false;
479
+ }
480
+ const consumed = await mutateApprovalStateWithRetry({
481
+ load: () => deps.loadApprovals(ctx, 'approved-approvals.json'),
482
+ write: (filePath, state) => deps.writeApprovals(filePath, state),
483
+ mutate: (state) => {
484
+ 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);
490
+ if (!lease.consumed) {
491
+ return null;
492
+ }
493
+ return { state: lease.state, result: true };
288
494
  },
289
495
  });
496
+ return consumed === true;
290
497
  }
291
498
  async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {}) {
499
+ const replayContext = buildAuditReplayContext(kind, result, auditExtras.replayAction);
500
+ const actionSnapshot = buildAuditActionSnapshot(kind, result, auditExtras.replayAction);
501
+ const providerId = String(ctx.config.judge?.providerId ?? 'cursor');
502
+ const stateDir = belayStateDir(ctx.config, ctx.layout.repoLocalStateDir(ctx.repoRoot));
503
+ const capabilityAudit = scheduleGateShadowAudit({
504
+ repoRoot: ctx.repoRoot,
505
+ config: ctx.config,
506
+ providerId,
507
+ result,
508
+ command: auditExtras.replayAction?.command ??
509
+ (kind === 'shell' ? auditExtras.approvalInput?.input : undefined),
510
+ stateDir,
511
+ });
292
512
  const gateBase = {
293
513
  event: gateAuditEventName(kind),
294
514
  kind,
@@ -301,6 +521,9 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
301
521
  schemaVersion: result.axes ? 2 : 1,
302
522
  ...(result.axes ?? {}),
303
523
  ...auditExtras.transactionalLayer,
524
+ ...capabilityAudit,
525
+ ...(replayContext ? { replayContext } : {}),
526
+ ...(actionSnapshot ? { actionSnapshot } : {}),
304
527
  };
305
528
  if (result.reason === TRANSACTIONAL_ALREADY_APPLIED) {
306
529
  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.';
@@ -321,6 +544,37 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
321
544
  agent_message: agentMessage,
322
545
  });
323
546
  }
547
+ if (result.verdict === 'deny_pending_approval' &&
548
+ ctx.config.mode === 'enforce' &&
549
+ !TRANSACTIONAL_APPROVAL_BYPASS_REASONS.has(result.reason)) {
550
+ const standingAllowPath = standingAllowFile(ctx.config, ctx.layout.repoLocalStateDir(ctx.repoRoot));
551
+ const standingState = await loadStandingAllow(standingAllowPath);
552
+ const standingMatch = resolveStandingAllowMatch({
553
+ kind,
554
+ result,
555
+ repoRoot: ctx.repoRoot,
556
+ state: standingState,
557
+ });
558
+ if (standingMatch) {
559
+ await deps.appendAudit(ctx, {
560
+ ...gateBase,
561
+ verdict: 'allow',
562
+ reason: 'standing_allow',
563
+ standingAllowSource: standingMatch.source,
564
+ ...(standingMatch.catalogCommand
565
+ ? { standingAllowCatalogCommand: standingMatch.catalogCommand }
566
+ : {}),
567
+ wouldBlock: false,
568
+ permission: 'allow',
569
+ });
570
+ return classifyResultToGateVerdict({
571
+ result: { ...result, verdict: 'allow', reason: 'standing_allow' },
572
+ mode: ctx.config.mode,
573
+ permission: 'allow',
574
+ wouldBlock: false,
575
+ });
576
+ }
577
+ }
324
578
  const brokerActive = isCapabilityBrokerDemotionActive(ctx.config);
325
579
  let approved = null;
326
580
  if (!TRANSACTIONAL_APPROVAL_BYPASS_REASONS.has(result.reason) &&
@@ -331,15 +585,69 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
331
585
  entry.fingerprint === result.fingerprint &&
332
586
  entry.repoRoot === ctx.repoRoot);
333
587
  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',
599
+ });
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.',
612
+ });
613
+ }
614
+ if (matchedApproval &&
615
+ auditExtras.replayAction &&
616
+ !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.',
637
+ });
638
+ }
334
639
  approved = await consumeApprovedApproval(ctx, deps, kind, result.fingerprint);
335
640
  }
336
641
  }
337
642
  if (approved) {
643
+ if (approved.firstExecution) {
644
+ await recordGateApprovalAsk(stateDir, result.reason, true);
645
+ }
338
646
  await deps.appendAudit(ctx, {
339
647
  ...gateBase,
340
648
  verdict: 'allow',
341
649
  reason: 'approved_once',
342
- approvalId: approved.approvalId,
650
+ approvalId: approved.approval.approvalId,
343
651
  wouldBlock: false,
344
652
  permission: 'allow',
345
653
  });
@@ -348,10 +656,32 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
348
656
  mode: ctx.config.mode,
349
657
  permission: 'allow',
350
658
  wouldBlock: false,
351
- approvalId: approved.approvalId,
659
+ approvalId: approved.approval.approvalId,
352
660
  });
353
661
  }
354
662
  if (result.verdict === 'allow' || result.verdict === 'allow_flagged') {
663
+ const grantConsumed = await consumeCapabilityGrantIfUsed(ctx, deps, result);
664
+ if (!grantConsumed) {
665
+ await deps.appendAudit(ctx, {
666
+ ...gateBase,
667
+ verdict: 'deny_pending_approval',
668
+ reason: 'capability_grant_unavailable',
669
+ wouldBlock: true,
670
+ permission: 'deny',
671
+ });
672
+ return classifyResultToGateVerdict({
673
+ result: {
674
+ ...result,
675
+ verdict: 'deny_pending_approval',
676
+ reason: 'capability_grant_unavailable',
677
+ },
678
+ mode: ctx.config.mode,
679
+ permission: 'deny',
680
+ wouldBlock: true,
681
+ user_message: 'Belay denied this action because the capability grant could not be consumed. Re-approve or run belay explain.',
682
+ agent_message: 'Belay denied this action because the capability grant was missing, expired, or already used.',
683
+ });
684
+ }
355
685
  await deps.appendAudit(ctx, {
356
686
  ...gateBase,
357
687
  verdict: result.verdict,
@@ -381,7 +711,44 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
381
711
  wouldBlock: true,
382
712
  });
383
713
  }
384
- const approval = await ensurePendingApproval(ctx, deps, kind, result, auditExtras.approvalInput);
714
+ if (isJudgeInfrastructureFailure(result)) {
715
+ const fallbackReason = extractJudgeFallbackReason(result);
716
+ const providerId = inferProviderIdFromFallbackReason(fallbackReason, String(ctx.config.judge.providerId ?? 'cursor'));
717
+ const denyMessages = formatJudgeInfrastructureDenyMessage({
718
+ providerId,
719
+ fallbackReason,
720
+ command: result.normalizedCommand ?? result.summary,
721
+ });
722
+ await deps.appendAudit(ctx, {
723
+ ...gateBase,
724
+ verdict: result.verdict,
725
+ reason: 'judge_transport_unavailable',
726
+ judgeFallbackReason: fallbackReason,
727
+ wouldBlock: true,
728
+ permission: 'deny',
729
+ });
730
+ return classifyResultToGateVerdict({
731
+ result: {
732
+ ...result,
733
+ verdict: 'deny_pending_approval',
734
+ reason: 'judge_transport_unavailable',
735
+ },
736
+ mode: ctx.config.mode,
737
+ permission: 'deny',
738
+ wouldBlock: true,
739
+ user_message: denyMessages.user_message,
740
+ agent_message: denyMessages.agent_message,
741
+ });
742
+ }
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
+ }
385
752
  let approvalToken;
386
753
  try {
387
754
  approvalToken = await issueApprovalToken({
@@ -419,7 +786,7 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
419
786
  permission: 'deny',
420
787
  wouldBlock: true,
421
788
  approvalId: approval.approvalId,
422
- user_message: `Belay blocked this high-risk action. Approval ID: ${approval.approvalId}. ${buildRetryInstruction(ctx.config.tokenPrefix, approval.approvalId)} For details, run belay explain or /belay why.`,
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.`,
423
790
  agent_message: `Belay denied this action as ${result.reason}. Wait for approval, then retry the exact same action once.`,
424
791
  });
425
792
  }
@@ -428,6 +795,7 @@ export async function processApprovalPrompt(ctx, deps, prompt) {
428
795
  if (!approvalId) {
429
796
  return { continue: true };
430
797
  }
798
+ const adapter = adapterIdFromContext(ctx);
431
799
  if (ctx.config.approvalSigning.required) {
432
800
  const message = `Signed approval token required for ${approvalId}. Editor prompt approval is disabled in this configuration. ` +
433
801
  `Use belay approve --approval-id ${approvalId} --token <signed-token>.`;
@@ -448,12 +816,11 @@ export async function processApprovalPrompt(ctx, deps, prompt) {
448
816
  approvalId,
449
817
  config: ctx.config,
450
818
  requireSignedToken: ctx.config.approvalSigning.required,
451
- store: {
452
- loadPending: () => deps.loadApprovals(ctx, 'pending-approvals.json'),
453
- loadApproved: () => deps.loadApprovals(ctx, 'approved-approvals.json'),
454
- writePending: (filePath, state) => deps.writeApprovals(filePath, state),
455
- writeApproved: (filePath, state) => deps.writeApprovals(filePath, state),
456
- },
819
+ adapter,
820
+ store: gateApprovalStoreFromDeps({
821
+ loadApprovals: (fileName) => deps.loadApprovals(ctx, fileName),
822
+ writeApprovals: (filePath, state) => deps.writeApprovals(filePath, state),
823
+ }),
457
824
  });
458
825
  await deps.appendAudit(ctx, {
459
826
  event: 'approval',
@@ -469,9 +836,96 @@ export async function processApprovalPrompt(ctx, deps, prompt) {
469
836
  user_message: recorded.message,
470
837
  };
471
838
  }
839
+ const replay = recorded.approval ? buildReplayHint(ctx.config, recorded.approval, adapter) : null;
840
+ const shouldFallbackReplay = adapter === 'cursor' &&
841
+ replay?.kind === 'shell' &&
842
+ replay.autoReplay === true &&
843
+ Boolean(recorded.approval?.input) &&
844
+ (!process.env.VITEST || process.env.BELAY_TEST_APPROVAL_REPLAY === '1');
845
+ if (shouldFallbackReplay && recorded.approval) {
846
+ const approvalStore = gateApprovalStoreFromDeps({
847
+ loadApprovals: (fileName) => deps.loadApprovals(ctx, fileName),
848
+ writeApprovals: (filePath, state) => deps.writeApprovals(filePath, state),
849
+ });
850
+ let replayResult;
851
+ try {
852
+ replayResult = await replayShellCommand(recorded.approval.input ?? '', recorded.approval.cwd ?? ctx.repoRoot, getExecutionLeaseMs(ctx.config));
853
+ }
854
+ catch (error) {
855
+ const detail = error instanceof Error ? error.message : String(error);
856
+ await deps.appendAudit(ctx, {
857
+ event: 'approval',
858
+ kind: 'approval',
859
+ verdict: 'allow',
860
+ approvalId,
861
+ reason: 'approval_replay_error',
862
+ summary: recorded.approval.input ?? recorded.approval.summary ?? prompt,
863
+ });
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
+ });
888
+ return {
889
+ 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}` : ''),
893
+ };
894
+ }
895
+ await deps.appendAudit(ctx, {
896
+ event: 'approval',
897
+ kind: 'approval',
898
+ verdict: 'allow',
899
+ approvalId,
900
+ reason: 'approval_replay_succeeded',
901
+ summary: recorded.approval.input ?? recorded.approval.summary ?? prompt,
902
+ });
903
+ return {
904
+ continue: false,
905
+ user_message: `Belay approval recorded for ${approvalId}. One-step shell replay succeeded; no manual retry required.` +
906
+ (output ? `\n${output}` : ''),
907
+ };
908
+ }
909
+ await deps.appendAudit(ctx, {
910
+ event: 'approval',
911
+ kind: 'approval',
912
+ verdict: 'allow',
913
+ approvalId,
914
+ reason: 'approval_replay_failed',
915
+ summary: recorded.approval.input ?? recorded.approval.summary ?? prompt,
916
+ });
917
+ const timeoutNote = replayResult.timedOut ? ' Replay timed out.' : '';
918
+ return {
919
+ 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}` : ''),
923
+ };
924
+ }
472
925
  return {
473
926
  continue: false,
474
927
  user_message: recorded.message,
928
+ ...(replay ? { replay } : {}),
475
929
  };
476
930
  }
477
931
  export function gateVerdictToCursorResponse(verdict) {
@@ -504,6 +958,7 @@ export function gateVerdictToClaudeUserPromptResponse(verdict) {
504
958
  hookEventName: 'UserPromptSubmit',
505
959
  continue: false,
506
960
  user_message: verdict.user_message,
961
+ ...(verdict.replay ? { replay: verdict.replay } : {}),
507
962
  },
508
963
  };
509
964
  }
@@ -520,6 +975,7 @@ export function gateVerdictToCodexUserPromptResponse(verdict) {
520
975
  return {
521
976
  decision: 'block',
522
977
  reason: verdict.user_message,
978
+ ...(verdict.replay ? { replay: verdict.replay } : {}),
523
979
  };
524
980
  }
525
981
  export async function appendObservedAudit(ctx, deps, eventName, payload) {