@guilz-dev/belay 0.4.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 (158) hide show
  1. package/README.md +7 -1
  2. package/dist/adapters/layouts/scope.js +3 -2
  3. package/dist/adapters/shared/gate-runtime.d.ts +1 -2
  4. package/dist/adapters/shared/gate-runtime.js +226 -28
  5. package/dist/bundle/claude-runtime.mjs +6967 -4078
  6. package/dist/bundle/codex-runtime.mjs +5951 -3064
  7. package/dist/bundle/cursor-runtime.mjs +5951 -3064
  8. package/dist/cli.js +43 -1
  9. package/dist/commands/classify-for-report.js +1 -1
  10. package/dist/commands/config.d.ts +8 -0
  11. package/dist/commands/config.js +134 -11
  12. package/dist/commands/doctor.js +36 -1
  13. package/dist/commands/explain.js +11 -1
  14. package/dist/commands/judge.js +3 -2
  15. package/dist/commands/session.d.ts +20 -0
  16. package/dist/commands/session.js +51 -0
  17. package/dist/config-io.js +49 -12
  18. package/dist/conformance/guarantee-posture.d.ts +21 -0
  19. package/dist/conformance/guarantee-posture.js +46 -0
  20. package/dist/conformance/guarantee-table.d.ts +1 -0
  21. package/dist/conformance/guarantee-table.js +14 -9
  22. package/dist/core/approval-service.js +6 -2
  23. package/dist/core/approval.d.ts +2 -0
  24. package/dist/core/approval.js +14 -3
  25. package/dist/core/capability/approval-state-mutation.d.ts +13 -0
  26. package/dist/core/capability/approval-state-mutation.js +92 -0
  27. package/dist/core/capability/approval-v3.d.ts +15 -0
  28. package/dist/core/capability/approval-v3.js +74 -0
  29. package/dist/core/capability/attestation.d.ts +13 -0
  30. package/dist/core/capability/attestation.js +55 -0
  31. package/dist/core/capability/boundary-attestation-sign.d.ts +21 -0
  32. package/dist/core/capability/boundary-attestation-sign.js +84 -0
  33. package/dist/core/capability/boundary-driver-container.d.ts +7 -0
  34. package/dist/core/capability/boundary-driver-container.js +122 -0
  35. package/dist/core/capability/boundary-driver.d.ts +29 -0
  36. package/dist/core/capability/boundary-driver.js +43 -0
  37. package/dist/core/capability/boundary-egress.d.ts +21 -0
  38. package/dist/core/capability/boundary-egress.js +109 -0
  39. package/dist/core/capability/boundary-grant-materialize.d.ts +13 -0
  40. package/dist/core/capability/boundary-grant-materialize.js +78 -0
  41. package/dist/core/capability/boundary-profile.d.ts +15 -0
  42. package/dist/core/capability/boundary-profile.js +25 -0
  43. package/dist/core/capability/boundary-run.d.ts +23 -0
  44. package/dist/core/capability/boundary-run.js +23 -0
  45. package/dist/core/capability/boundary-session.d.ts +57 -0
  46. package/dist/core/capability/boundary-session.js +129 -0
  47. package/dist/core/capability/capability-request-hash.d.ts +2 -0
  48. package/dist/core/capability/capability-request-hash.js +8 -0
  49. package/dist/core/capability/gate-latency-ratchet.d.ts +7 -0
  50. package/dist/core/capability/gate-latency-ratchet.js +18 -0
  51. package/dist/core/capability/gate-policy-shadow.d.ts +25 -0
  52. package/dist/core/capability/gate-policy-shadow.js +108 -0
  53. package/dist/core/capability/gate-shadow-audit.d.ts +1 -0
  54. package/dist/core/capability/gate-shadow-audit.js +1 -0
  55. package/dist/core/capability/gate-shadow-ratchet.d.ts +14 -0
  56. package/dist/core/capability/gate-shadow-ratchet.js +81 -0
  57. package/dist/core/capability/grant-lease.d.ts +9 -0
  58. package/dist/core/capability/grant-lease.js +46 -0
  59. package/dist/core/capability/grant-loader.d.ts +12 -0
  60. package/dist/core/capability/grant-loader.js +23 -0
  61. package/dist/core/capability/grant-match.d.ts +6 -0
  62. package/dist/core/capability/grant-match.js +93 -0
  63. package/dist/core/capability/grant.d.ts +16 -0
  64. package/dist/core/capability/grant.js +16 -0
  65. package/dist/core/capability/index.d.ts +8 -0
  66. package/dist/core/capability/index.js +7 -0
  67. package/dist/core/capability/limits.d.ts +7 -0
  68. package/dist/core/capability/limits.js +55 -0
  69. package/dist/core/capability/policy-bridge.d.ts +18 -0
  70. package/dist/core/capability/policy-bridge.js +51 -0
  71. package/dist/core/capability/policy-engine.d.ts +70 -0
  72. package/dist/core/capability/policy-engine.js +476 -0
  73. package/dist/core/capability/policy-types.d.ts +20 -0
  74. package/dist/core/capability/policy-types.js +1 -0
  75. package/dist/core/capability/request.d.ts +45 -0
  76. package/dist/core/capability/request.js +1 -0
  77. package/dist/core/capability/resolver.d.ts +4 -0
  78. package/dist/core/capability/resolver.js +4 -0
  79. package/dist/core/classify-subagent.d.ts +2 -1
  80. package/dist/core/classify-subagent.js +38 -2
  81. package/dist/core/classify-tool.js +126 -62
  82. package/dist/core/config.d.ts +14 -3
  83. package/dist/core/config.js +56 -3
  84. package/dist/core/gate-contract.d.ts +3 -0
  85. package/dist/core/gate-contract.js +3 -0
  86. package/dist/core/gate-engine.js +6 -1
  87. package/dist/core/judge-doctor.js +39 -14
  88. package/dist/core/judge-fallback-hints.d.ts +13 -0
  89. package/dist/core/judge-fallback-hints.js +108 -0
  90. package/dist/core/path-utils.d.ts +1 -0
  91. package/dist/core/path-utils.js +5 -2
  92. package/dist/core/recovery/capability.d.ts +3 -0
  93. package/dist/core/recovery/capability.js +22 -0
  94. package/dist/core/recovery/fail-closed.d.ts +6 -0
  95. package/dist/core/recovery/fail-closed.js +25 -0
  96. package/dist/core/recovery/index.d.ts +3 -0
  97. package/dist/core/recovery/index.js +2 -0
  98. package/dist/core/recovery/types.d.ts +20 -0
  99. package/dist/core/recovery/types.js +1 -0
  100. package/dist/core/standing-allow.js +3 -0
  101. package/dist/core/transactional/diff-evaluator.js +4 -1
  102. package/dist/core/transactional/eligibility.js +4 -0
  103. package/dist/core/transactional/git-worktree.d.ts +10 -3
  104. package/dist/core/transactional/git-worktree.js +81 -11
  105. package/dist/core/transactional/reasons.d.ts +1 -0
  106. package/dist/core/transactional/reasons.js +4 -0
  107. package/dist/core/transactional/runner.js +22 -6
  108. package/dist/core/transactional/types.d.ts +4 -0
  109. package/dist/core/types.d.ts +21 -3
  110. package/dist/core/verdict/adapter.d.ts +3 -4
  111. package/dist/core/verdict/adapter.js +16 -12
  112. package/dist/core/verdict/cursor-acp-client.d.ts +67 -0
  113. package/dist/core/verdict/cursor-acp-client.js +345 -0
  114. package/dist/core/verdict/egress-classify.d.ts +2 -2
  115. package/dist/core/verdict/egress-classify.js +2 -2
  116. package/dist/core/verdict/git-classifier.d.ts +26 -0
  117. package/dist/core/verdict/git-classifier.js +533 -0
  118. package/dist/core/verdict/judge-audit.js +1 -0
  119. package/dist/core/verdict/judge-baseline.d.ts +1 -1
  120. package/dist/core/verdict/judge-baseline.js +1 -0
  121. package/dist/core/verdict/judge-broker-service.js +12 -0
  122. package/dist/core/verdict/judge-catalog.js +1 -0
  123. package/dist/core/verdict/judge-cli-fingerprint.d.ts +1 -0
  124. package/dist/core/verdict/judge-cli-fingerprint.js +6 -0
  125. package/dist/core/verdict/judge-cli.js +5 -3
  126. package/dist/core/verdict/judge-provider-matrix.js +1 -1
  127. package/dist/core/verdict/judge-runtime-config.d.ts +2 -0
  128. package/dist/core/verdict/judge-runtime-config.js +10 -6
  129. package/dist/core/verdict/judge-session-broker.d.ts +1 -1
  130. package/dist/core/verdict/judge-session-broker.js +10 -5
  131. package/dist/core/verdict/judge-transport.d.ts +5 -1
  132. package/dist/core/verdict/judge-transport.js +24 -14
  133. package/dist/core/verdict/judge.d.ts +2 -15
  134. package/dist/core/verdict/judge.js +5 -68
  135. package/dist/core/verdict/launcher-resolve.d.ts +2 -0
  136. package/dist/core/verdict/launcher-resolve.js +16 -0
  137. package/dist/core/verdict/prescan.d.ts +15 -0
  138. package/dist/core/verdict/prescan.js +68 -0
  139. package/dist/core/verdict/shell-policy.d.ts +25 -0
  140. package/dist/core/verdict/shell-policy.js +40 -0
  141. package/dist/core/verdict/shell-semantics.d.ts +24 -0
  142. package/dist/core/verdict/shell-semantics.js +225 -0
  143. package/dist/core/verdict/types.d.ts +13 -1
  144. package/dist/core/verdict/verdict.js +296 -178
  145. package/dist/corpus/evaluate.js +3 -7
  146. package/dist/corpus/gate-latency-budget.d.ts +39 -0
  147. package/dist/corpus/gate-latency-budget.js +33 -0
  148. package/dist/corpus/mutators.d.ts +1 -1
  149. package/dist/corpus/mutators.js +11 -0
  150. package/dist/corpus/runtime-match.js +1 -3
  151. package/dist/corpus/standing-allow-catalog.generated.js +29 -9
  152. package/dist/judge-broker-daemon.js +15 -1
  153. package/dist/services/sandbox-service.d.ts +4 -0
  154. package/dist/services/sandbox-service.js +28 -2
  155. package/dist/types.d.ts +2 -2
  156. package/dist/version.d.ts +1 -1
  157. package/dist/version.js +1 -1
  158. package/package.json +1 -1
package/README.md CHANGED
@@ -244,7 +244,13 @@ Notable settings:
244
244
  (`judge.providerId`); **driver** is the API compatibility layer (`judge.provider`:
245
245
  `ollama`, `openai-compatible`, or `anthropic`); **host** is where hooks install
246
246
  (`config.adapter`: `cursor`, `claude`, `codex`). Set `judge.endpoint` when needed;
247
- credentials via `belay config credential mode project|apiKey` or env vars. Record egress
247
+ credentials via `belay config credential mode project|apiKey` or env vars. **`project`**
248
+ reads API keys from the shell environment at runtime (`BELAY_JUDGE_API_KEY`, then provider
249
+ vars such as `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` / `CURSOR_API_KEY`); Belay does **not**
250
+ load `.env` files for Judge. When the native CLI is available, `project` can also use the
251
+ host login session without a stored key. **`apiKey`** stores a key in Belay's credential store
252
+ (`credentials.json`, mode `0600`; under `~/.config/belay/` when control plane is enabled).
253
+ Record egress
248
254
  consent during `belay config` or via `belay judge consent` → `belay approve` →
249
255
  `belay judge use … --cloud-consent-approval-id`. Cloud providers can use native CLI
250
256
  transport without `judge.endpoint` when the host CLI is available (`codex-cli`,
@@ -1,5 +1,6 @@
1
1
  import os from 'node:os';
2
2
  import path from 'node:path';
3
+ import { isPathOutsideRoot } from '../../core/path-utils.js';
3
4
  function agentHomeDir(adapter) {
4
5
  const home = os.homedir();
5
6
  if (adapter === 'cursor') {
@@ -23,13 +24,13 @@ export function isPathInside(child, parent) {
23
24
  const resolvedChild = path.resolve(child);
24
25
  const resolvedParent = path.resolve(parent);
25
26
  const relative = path.relative(resolvedParent, resolvedChild);
26
- return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
27
+ return relative === '' || (!isPathOutsideRoot(relative) && !path.isAbsolute(relative));
27
28
  }
28
29
  export function buildRunnerInvocation(platform, hooksDir, repoRoot, hookScript, ...args) {
29
30
  const runnerFile = platform === 'win32' ? 'belay-runner.cmd' : 'belay-runner';
30
31
  const runnerAbs = path.resolve(hooksDir, runnerFile);
31
32
  const relative = path.relative(path.resolve(repoRoot), runnerAbs);
32
- const useRelative = relative.length > 0 && !relative.startsWith('..') && !path.isAbsolute(relative);
33
+ const useRelative = relative.length > 0 && !isPathOutsideRoot(relative) && !path.isAbsolute(relative);
33
34
  const runnerRef = useRelative
34
35
  ? platform === 'win32'
35
36
  ? `.\\${relative.split(path.sep).join('\\')}`
@@ -1,6 +1,5 @@
1
1
  import { type ApprovalReplayHint } from '../../core/approval-replay.js';
2
- import type { GatedActionKind } from '../../core/gate-contract.js';
3
- import { type GatePermissionResponse, type GateVerdict } from '../../core/gate-contract.js';
2
+ import { type GatedActionKind, type GatePermissionResponse, type GateVerdict } from '../../core/gate-contract.js';
4
3
  import { GateNormalizationError } from '../../core/gate-engine.js';
5
4
  import { type BelayConfigV3 } from '../../core/index.js';
6
5
  import type { ApprovalStateFile, ClassifierOptions } from '../../core/types.js';
@@ -7,16 +7,27 @@ import { buildReplayHint, buildRetryInstructionForConfig, getExecutionLeaseMs, r
7
7
  import { consumeApprovedAfterCliReplay, 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 { 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';
10
18
  import { collectOutsideRepoPaths, collectOutsideRepoPathsFromToolPayload, fsScopeAllowlistPath, isCapabilityBrokerDemotionActive, loadFsScopeAllowlistSync, loadTrustedWorkspaceRootsSync, shouldSkipBrokerApprovedOnce, shouldSkipBrokerApprovedRecord, trustedWorkspaceRootsPath, validateTrustedWorkspaceRootCandidate, } from '../../core/capability/index.js';
11
19
  import { resolveLayeredConfig, teamConfigPath } from '../../core/config-layers.js';
12
20
  import { classifyResultToGateVerdict, unnormalizedGateVerdict, } from '../../core/gate-contract.js';
13
21
  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';
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';
15
24
  import { notifyDeny } from '../../core/notify.js';
16
25
  import { canonicalPath } from '../../core/path-utils.js';
26
+ import { recoveryFailClosedResult, recoveryFailReasonFromSkip, } from '../../core/recovery/fail-closed.js';
17
27
  import { fingerprintReplayPayload } from '../../core/replay-scrub.js';
18
28
  import { loadStandingAllow, resolveStandingAllowMatch, standingAllowFile, } from '../../core/standing-allow.js';
19
29
  import { isTransactionalEligible, runTransactionalExecution, TRANSACTIONAL_ALREADY_APPLIED, TRANSACTIONAL_APPROVAL_BYPASS_REASONS, } from '../../core/transactional/index.js';
30
+ import { egressStatus } from '../../services/egress-service.js';
20
31
  import { protectedArtifactRoots } from '../layouts/protected-paths.js';
21
32
  const EMPTY_APPROVALS = {
22
33
  version: 1,
@@ -66,10 +77,12 @@ export function createDefaultGateRuntimeDeps() {
66
77
  ? pendingApprovalsFile(ctx.config, repoLocalStateDir)
67
78
  : approvedApprovalsFile(ctx.config, repoLocalStateDir);
68
79
  const loaded = await loadJsonFile(filePath, EMPTY_APPROVALS);
80
+ const version = loaded.version === 3 ? 3 : loaded.version === 2 ? 2 : 1;
69
81
  return {
70
82
  filePath,
71
83
  state: {
72
- version: loaded.version === 2 ? 2 : 1,
84
+ version,
85
+ ...(loaded.revision !== undefined ? { revision: loaded.revision } : {}),
73
86
  approvals: Array.isArray(loaded.approvals) ? loaded.approvals : [],
74
87
  },
75
88
  };
@@ -133,7 +146,7 @@ async function ensurePendingApproval(ctx, deps, kind, result, approvalInput, sco
133
146
  approval.repoRoot === ctx.repoRoot);
134
147
  if (existing) {
135
148
  await deps.writeApprovals(pending.filePath, pending.state);
136
- return existing;
149
+ return { approval: existing, created: false };
137
150
  }
138
151
  const approval = createApprovalRecordWithEnvelope({
139
152
  kind,
@@ -145,11 +158,13 @@ async function ensurePendingApproval(ctx, deps, kind, result, approvalInput, sco
145
158
  approvalId: `belay_${randomUUID().replaceAll('-', '').slice(0, 12)}`,
146
159
  approvalInput,
147
160
  scopeHint,
161
+ capabilityRequests: result.capabilityRequests,
148
162
  });
149
- pending.state.version = 2;
163
+ pending.state.version = APPROVAL_STATE_VERSION_V3;
164
+ pending.state.revision = (pending.state.revision ?? 0) + 1;
150
165
  pending.state.approvals.push(approval);
151
166
  await deps.writeApprovals(pending.filePath, pending.state);
152
- return approval;
167
+ return { approval, created: true };
153
168
  }
154
169
  function extractShellCommandFromPayload(payload) {
155
170
  const toolInput = payload.tool_input;
@@ -208,26 +223,53 @@ function deriveWorkspaceRootScopeHint(params) {
208
223
  return { scope: 'workspace-root', path: validation.normalizedPath };
209
224
  }
210
225
  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 &&
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 &&
214
229
  approval.fingerprint === fingerprint &&
215
230
  approval.repoRoot === ctx.repoRoot);
216
231
  if (index === -1) {
217
- await deps.writeApprovals(approved.filePath, approved.state);
232
+ await deps.writeApprovals(loaded.filePath, loaded.state);
218
233
  return null;
219
234
  }
220
- const approval = approved.state.approvals[index];
235
+ const approval = loaded.state.approvals[index];
221
236
  if (approval.executionLeaseExpiresAt) {
222
- await deps.writeApprovals(approved.filePath, approved.state);
223
- return approval;
237
+ await deps.writeApprovals(loaded.filePath, loaded.state);
238
+ return { approval, firstExecution: false };
239
+ }
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;
224
271
  }
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;
272
+ return { approval: consumed, firstExecution: true };
231
273
  }
232
274
  export async function evaluateGatedAction(ctx, deps, params) {
233
275
  let action;
@@ -280,15 +322,42 @@ export async function evaluateGatedAction(ctx, deps, params) {
280
322
  });
281
323
  }
282
324
  const classifierOptions = runtimeClassifierOptions(ctx, ctx.config);
283
- 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);
284
344
  let result = predicted;
285
345
  let predictedAssessment;
286
346
  let observedAssessment;
287
347
  let transactionalLayer;
288
- if (isTransactionalEligible(ctx.config, params.kind, predicted) &&
348
+ const recoveryCandidate = isTransactionalEligible(ctx.config, params.kind, predicted) &&
289
349
  params.kind === 'shell' &&
290
- params.command) {
350
+ Boolean(params.command);
351
+ if (recoveryCandidate && params.command) {
291
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
+ });
292
361
  const txResult = await runTransactionalExecution({
293
362
  command: params.command,
294
363
  cwd: params.cwd,
@@ -299,9 +368,11 @@ export async function evaluateGatedAction(ctx, deps, params) {
299
368
  diffContext: {
300
369
  repoRoot: ctx.repoRoot,
301
370
  sensitivePaths: ctx.config.classifier.sensitivePaths,
302
- protectedRoots: classifierOptions.protectedArtifactRoots ?? [],
371
+ protectedRoots: enrichedClassifierOptions.protectedArtifactRoots ?? [],
303
372
  maxDeletionCount: transactional.maxDeletionCount,
304
373
  },
374
+ boundaryContext,
375
+ dirtyIgnoreRoots: protectedArtifactRoots(ctx.layout, ctx.repoRoot, ctx.config.controlPlane.enabled ? configuredControlPlaneDir(ctx.config) : null),
305
376
  });
306
377
  if (!txResult.skipped && txResult.observed) {
307
378
  result = txResult.result;
@@ -315,10 +386,15 @@ export async function evaluateGatedAction(ctx, deps, params) {
315
386
  transactionalTimedOut: txResult.timedOut === true,
316
387
  };
317
388
  }
318
- else if (txResult.skipReason) {
389
+ else {
390
+ const skipReason = txResult.skipReason ?? 'recovery_observation_failed';
391
+ result = recoveryFailClosedResult(predicted, recoveryFailReasonFromSkip(skipReason), [
392
+ skipReason,
393
+ ]);
319
394
  transactionalLayer = {
320
395
  transactional: false,
321
- transactionalSkipReason: txResult.skipReason,
396
+ transactionalSkipReason: skipReason,
397
+ recoveryFailClosed: true,
322
398
  };
323
399
  }
324
400
  }
@@ -353,7 +429,7 @@ export async function evaluateGatedAction(ctx, deps, params) {
353
429
  fingerprint: result.fingerprint,
354
430
  repoRoot: ctx.repoRoot,
355
431
  },
356
- classifierOptions,
432
+ classifierOptions: enrichedClassifierOptions,
357
433
  scopeHintPayload: params.payload,
358
434
  });
359
435
  }
@@ -392,9 +468,47 @@ export async function gateUnmappedToolVerdict(ctx, deps, toolName, payload) {
392
468
  },
393
469
  });
394
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 };
494
+ },
495
+ });
496
+ return consumed === true;
497
+ }
395
498
  async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {}) {
396
499
  const replayContext = buildAuditReplayContext(kind, result, auditExtras.replayAction);
397
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
+ });
398
512
  const gateBase = {
399
513
  event: gateAuditEventName(kind),
400
514
  kind,
@@ -407,6 +521,7 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
407
521
  schemaVersion: result.axes ? 2 : 1,
408
522
  ...(result.axes ?? {}),
409
523
  ...auditExtras.transactionalLayer,
524
+ ...capabilityAudit,
410
525
  ...(replayContext ? { replayContext } : {}),
411
526
  ...(actionSnapshot ? { actionSnapshot } : {}),
412
527
  };
@@ -470,6 +585,32 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
470
585
  entry.fingerprint === result.fingerprint &&
471
586
  entry.repoRoot === ctx.repoRoot);
472
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
+ }
473
614
  if (matchedApproval &&
474
615
  auditExtras.replayAction &&
475
616
  !validateReplayEnvelope(matchedApproval, auditExtras.replayAction)) {
@@ -499,11 +640,14 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
499
640
  }
500
641
  }
501
642
  if (approved) {
643
+ if (approved.firstExecution) {
644
+ await recordGateApprovalAsk(stateDir, result.reason, true);
645
+ }
502
646
  await deps.appendAudit(ctx, {
503
647
  ...gateBase,
504
648
  verdict: 'allow',
505
649
  reason: 'approved_once',
506
- approvalId: approved.approvalId,
650
+ approvalId: approved.approval.approvalId,
507
651
  wouldBlock: false,
508
652
  permission: 'allow',
509
653
  });
@@ -512,10 +656,32 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
512
656
  mode: ctx.config.mode,
513
657
  permission: 'allow',
514
658
  wouldBlock: false,
515
- approvalId: approved.approvalId,
659
+ approvalId: approved.approval.approvalId,
516
660
  });
517
661
  }
518
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
+ }
519
685
  await deps.appendAudit(ctx, {
520
686
  ...gateBase,
521
687
  verdict: result.verdict,
@@ -545,12 +711,44 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
545
711
  wouldBlock: true,
546
712
  });
547
713
  }
548
- const approval = await ensurePendingApproval(ctx, deps, kind, result, auditExtras.approvalInput, deriveWorkspaceRootScopeHint({
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({
549
744
  result,
550
745
  replayAction: auditExtras.replayAction,
551
746
  payloadForScopeHint: auditExtras.scopeHintPayload,
552
747
  options: auditExtras.classifierOptions ?? {},
553
748
  }));
749
+ if (created) {
750
+ await recordGateApprovalAsk(stateDir, result.reason, false);
751
+ }
554
752
  let approvalToken;
555
753
  try {
556
754
  approvalToken = await issueApprovalToken({