@guilz-dev/belay 0.3.0 → 0.4.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 (140) hide show
  1. package/dist/adapters/cursor/runtime-entry.js +1 -0
  2. package/dist/adapters/shared/gate-runtime.d.ts +11 -14
  3. package/dist/adapters/shared/gate-runtime.js +277 -19
  4. package/dist/bundle/claude-runtime.mjs +3138 -699
  5. package/dist/bundle/codex-runtime.mjs +3162 -711
  6. package/dist/bundle/cursor-runtime.mjs +3140 -701
  7. package/dist/cli.js +137 -5
  8. package/dist/commands/approve.d.ts +1 -0
  9. package/dist/commands/approve.js +45 -26
  10. package/dist/commands/config.js +135 -52
  11. package/dist/commands/doctor.js +11 -1
  12. package/dist/commands/explain.js +6 -1
  13. package/dist/commands/harvest.d.ts +29 -0
  14. package/dist/commands/harvest.js +72 -0
  15. package/dist/commands/judge.d.ts +11 -1
  16. package/dist/commands/judge.js +21 -2
  17. package/dist/commands/metrics.js +34 -2
  18. package/dist/commands/quality.d.ts +38 -0
  19. package/dist/commands/quality.js +105 -0
  20. package/dist/commands/simulate.d.ts +1 -0
  21. package/dist/commands/simulate.js +7 -2
  22. package/dist/commands/standing-allow.d.ts +10 -0
  23. package/dist/commands/standing-allow.js +26 -0
  24. package/dist/commands/tui.d.ts +48 -0
  25. package/dist/commands/tui.js +150 -0
  26. package/dist/config-io.js +10 -5
  27. package/dist/core/approval-replay-cli.d.ts +7 -0
  28. package/dist/core/approval-replay-cli.js +36 -0
  29. package/dist/core/approval-replay.d.ts +45 -0
  30. package/dist/core/approval-replay.js +141 -0
  31. package/dist/core/approval-service.d.ts +15 -0
  32. package/dist/core/approval-service.js +47 -2
  33. package/dist/core/approval.d.ts +24 -3
  34. package/dist/core/approval.js +47 -4
  35. package/dist/core/audit-analysis.d.ts +7 -1
  36. package/dist/core/audit-analysis.js +111 -0
  37. package/dist/core/audit-metrics.d.ts +7 -0
  38. package/dist/core/audit-metrics.js +24 -4
  39. package/dist/core/audit-query.d.ts +1 -0
  40. package/dist/core/audit-query.js +3 -0
  41. package/dist/core/audit-replay-context.d.ts +35 -0
  42. package/dist/core/audit-replay-context.js +88 -0
  43. package/dist/core/audit-types.d.ts +24 -1
  44. package/dist/core/audit-types.js +1 -1
  45. package/dist/core/capability/index.d.ts +2 -1
  46. package/dist/core/capability/index.js +1 -0
  47. package/dist/core/capability/paths.d.ts +2 -2
  48. package/dist/core/capability/paths.js +9 -9
  49. package/dist/core/capability/trusted-workspace-roots.d.ts +25 -0
  50. package/dist/core/capability/trusted-workspace-roots.js +149 -0
  51. package/dist/core/capability/types.d.ts +11 -1
  52. package/dist/core/capability-approval.d.ts +2 -1
  53. package/dist/core/capability-approval.js +100 -2
  54. package/dist/core/classify-subagent.js +2 -20
  55. package/dist/core/classify-tool.js +32 -3
  56. package/dist/core/config.d.ts +18 -0
  57. package/dist/core/config.js +49 -0
  58. package/dist/core/gate-engine.js +6 -6
  59. package/dist/core/harvest.d.ts +53 -0
  60. package/dist/core/harvest.js +276 -0
  61. package/dist/core/index.d.ts +9 -3
  62. package/dist/core/index.js +6 -2
  63. package/dist/core/judge-doctor.d.ts +3 -0
  64. package/dist/core/judge-doctor.js +54 -0
  65. package/dist/core/path-utils.d.ts +11 -0
  66. package/dist/core/path-utils.js +56 -6
  67. package/dist/core/reclassify.d.ts +3 -0
  68. package/dist/core/reclassify.js +44 -14
  69. package/dist/core/replay-scrub.d.ts +9 -0
  70. package/dist/core/replay-scrub.js +43 -0
  71. package/dist/core/shell-tokenizer.d.ts +2 -0
  72. package/dist/core/shell-tokenizer.js +82 -23
  73. package/dist/core/standing-allow.d.ts +50 -0
  74. package/dist/core/standing-allow.js +175 -0
  75. package/dist/core/types.d.ts +15 -0
  76. package/dist/core/verdict/adapter.js +10 -1
  77. package/dist/core/verdict/containment.d.ts +4 -3
  78. package/dist/core/verdict/containment.js +17 -12
  79. package/dist/core/verdict/judge-audit.d.ts +1 -0
  80. package/dist/core/verdict/judge-audit.js +32 -1
  81. package/dist/core/verdict/judge-baseline.d.ts +20 -0
  82. package/dist/core/verdict/judge-baseline.js +48 -0
  83. package/dist/core/verdict/judge-broker-service.d.ts +42 -0
  84. package/dist/core/verdict/judge-broker-service.js +279 -0
  85. package/dist/core/verdict/judge-cli-fingerprint.d.ts +5 -0
  86. package/dist/core/verdict/judge-cli-fingerprint.js +61 -0
  87. package/dist/core/verdict/judge-cli.d.ts +23 -0
  88. package/dist/core/verdict/judge-cli.js +137 -16
  89. package/dist/core/verdict/judge-factory.js +13 -4
  90. package/dist/core/verdict/judge-provider-matrix.d.ts +20 -0
  91. package/dist/core/verdict/judge-provider-matrix.js +66 -0
  92. package/dist/core/verdict/judge-runtime-config.d.ts +42 -0
  93. package/dist/core/verdict/judge-runtime-config.js +92 -0
  94. package/dist/core/verdict/judge-session-broker.d.ts +48 -0
  95. package/dist/core/verdict/judge-session-broker.js +195 -0
  96. package/dist/core/verdict/judge-session-guard.d.ts +27 -0
  97. package/dist/core/verdict/judge-session-guard.js +91 -0
  98. package/dist/core/verdict/judge-session-kill-switch.d.ts +11 -0
  99. package/dist/core/verdict/judge-session-kill-switch.js +43 -0
  100. package/dist/core/verdict/judge-session-mutex.d.ts +9 -0
  101. package/dist/core/verdict/judge-session-mutex.js +23 -0
  102. package/dist/core/verdict/judge-shadow.d.ts +24 -0
  103. package/dist/core/verdict/judge-shadow.js +116 -0
  104. package/dist/core/verdict/judge-transport.d.ts +40 -0
  105. package/dist/core/verdict/judge-transport.js +258 -0
  106. package/dist/core/verdict/judge.d.ts +14 -1
  107. package/dist/core/verdict/judge.js +6 -5
  108. package/dist/core/verdict/parser.js +6 -1
  109. package/dist/core/verdict/types.d.ts +13 -0
  110. package/dist/core/verdict/verdict.js +7 -4
  111. package/dist/corpus/adversarial-probe.d.ts +86 -0
  112. package/dist/corpus/adversarial-probe.js +220 -0
  113. package/dist/corpus/evaluate.d.ts +45 -12
  114. package/dist/corpus/evaluate.js +62 -5
  115. package/dist/corpus/gates.d.ts +35 -0
  116. package/dist/corpus/gates.js +81 -0
  117. package/dist/corpus/judge-accuracy.d.ts +8 -0
  118. package/dist/corpus/judge-accuracy.js +39 -0
  119. package/dist/corpus/must-allow-commands.d.ts +5 -0
  120. package/dist/corpus/must-allow-commands.js +13 -0
  121. package/dist/corpus/mutators.d.ts +28 -0
  122. package/dist/corpus/mutators.js +151 -0
  123. package/dist/corpus/ratchet.d.ts +42 -0
  124. package/dist/corpus/ratchet.js +116 -0
  125. package/dist/corpus/runtime-match.d.ts +19 -0
  126. package/dist/corpus/runtime-match.js +45 -0
  127. package/dist/corpus/standing-allow-catalog.generated.d.ts +13 -0
  128. package/dist/corpus/standing-allow-catalog.generated.js +79 -0
  129. package/dist/corpus/types.d.ts +47 -0
  130. package/dist/corpus/types.js +157 -0
  131. package/dist/installer.js +4 -1
  132. package/dist/judge-broker-daemon.d.ts +1 -0
  133. package/dist/judge-broker-daemon.js +123 -0
  134. package/dist/services/sandbox-service.d.ts +1 -0
  135. package/dist/services/sandbox-service.js +2 -0
  136. package/dist/version.d.ts +1 -1
  137. package/dist/version.js +1 -1
  138. package/package.json +7 -3
  139. package/skills/belay/SKILL.md +7 -1
  140. package/skills/belay/belay-approve.md +17 -0
@@ -44,6 +44,7 @@ export async function runBeforeSubmitPromptHook() {
44
44
  jsonResponse({
45
45
  continue: result.continue,
46
46
  ...(result.user_message ? { user_message: result.user_message } : {}),
47
+ ...(result.replay ? { replay: result.replay } : {}),
47
48
  });
48
49
  }
49
50
  catch {
@@ -1,9 +1,15 @@
1
+ import { type ApprovalReplayHint } from '../../core/approval-replay.js';
1
2
  import type { GatedActionKind } from '../../core/gate-contract.js';
2
3
  import { type GatePermissionResponse, type GateVerdict } from '../../core/gate-contract.js';
3
4
  import { GateNormalizationError } from '../../core/gate-engine.js';
4
- import { type ApprovalStateFile, type BelayConfigV3 } from '../../core/index.js';
5
- import type { ClassifierOptions } from '../../core/types.js';
5
+ import { type BelayConfigV3 } from '../../core/index.js';
6
+ import type { ApprovalStateFile, ClassifierOptions } from '../../core/types.js';
6
7
  import type { AdapterLayout } from '../layouts/types.js';
8
+ export interface ApprovalPromptResult {
9
+ continue: boolean;
10
+ user_message?: string;
11
+ replay?: ApprovalReplayHint;
12
+ }
7
13
  export interface GateRuntimeContext {
8
14
  layout: AdapterLayout;
9
15
  repoRoot: string;
@@ -32,20 +38,11 @@ export declare function evaluateGatedAction(ctx: GateRuntimeContext, deps: GateR
32
38
  }): Promise<GateVerdict>;
33
39
  /** R39: unmapped Codex tools ask via pending approval — not hard deny without approval path. */
34
40
  export declare function gateUnmappedToolVerdict(ctx: GateRuntimeContext, deps: GateRuntimeDeps, toolName: string, payload: Record<string, unknown>): Promise<GateVerdict>;
35
- export declare function processApprovalPrompt(ctx: GateRuntimeContext, deps: GateRuntimeDeps, prompt: string): Promise<{
36
- continue: boolean;
37
- user_message?: string;
38
- }>;
41
+ export declare function processApprovalPrompt(ctx: GateRuntimeContext, deps: GateRuntimeDeps, prompt: string): Promise<ApprovalPromptResult>;
39
42
  export declare function gateVerdictToCursorResponse(verdict: GateVerdict): GatePermissionResponse;
40
43
  export declare function gateVerdictToClaudePreToolUseResponse(verdict: GateVerdict): Record<string, unknown>;
41
- export declare function gateVerdictToClaudeUserPromptResponse(verdict: {
42
- continue: boolean;
43
- user_message?: string;
44
- }): Record<string, unknown>;
44
+ export declare function gateVerdictToClaudeUserPromptResponse(verdict: ApprovalPromptResult): Record<string, unknown>;
45
45
  export declare function gateVerdictToCodexPreToolUseResponse(verdict: GateVerdict): Record<string, unknown>;
46
- export declare function gateVerdictToCodexUserPromptResponse(verdict: {
47
- continue: boolean;
48
- user_message?: string;
49
- }): Record<string, unknown>;
46
+ export declare function gateVerdictToCodexUserPromptResponse(verdict: ApprovalPromptResult): Record<string, unknown>;
50
47
  export declare function appendObservedAudit(ctx: GateRuntimeContext, deps: GateRuntimeDeps, eventName: string, payload: Record<string, unknown>): Promise<void>;
51
48
  export { GateNormalizationError };
@@ -2,20 +2,37 @@ 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 { collectOutsideRepoPaths, collectOutsideRepoPathsFromToolPayload, fsScopeAllowlistPath, isCapabilityBrokerDemotionActive, loadFsScopeAllowlistSync, loadTrustedWorkspaceRootsSync, shouldSkipBrokerApprovedOnce, shouldSkipBrokerApprovedRecord, trustedWorkspaceRootsPath, validateTrustedWorkspaceRootCandidate, } from '../../core/capability/index.js';
8
11
  import { resolveLayeredConfig, teamConfigPath } from '../../core/config-layers.js';
9
12
  import { classifyResultToGateVerdict, unnormalizedGateVerdict, } from '../../core/gate-contract.js';
10
13
  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';
14
+ import { approvalCommandMatch, approvedApprovalsFile, canonicalStringify, classifierOptionsFromConfig, configuredControlPlaneDir, pendingApprovalsFile, resolveControlPlaneDir, scrubOptionsFromConfig, scrubValue, toolFingerprint, } from '../../core/index.js';
12
15
  import { notifyDeny } from '../../core/notify.js';
16
+ import { canonicalPath } from '../../core/path-utils.js';
17
+ import { fingerprintReplayPayload } from '../../core/replay-scrub.js';
18
+ import { loadStandingAllow, resolveStandingAllowMatch, standingAllowFile, } from '../../core/standing-allow.js';
13
19
  import { isTransactionalEligible, runTransactionalExecution, TRANSACTIONAL_ALREADY_APPLIED, TRANSACTIONAL_APPROVAL_BYPASS_REASONS, } from '../../core/transactional/index.js';
14
20
  import { protectedArtifactRoots } from '../layouts/protected-paths.js';
15
21
  const EMPTY_APPROVALS = {
16
22
  version: 1,
17
23
  approvals: [],
18
24
  };
25
+ function adapterIdFromContext(ctx) {
26
+ if (ctx.config.adapter === 'cursor' ||
27
+ ctx.config.adapter === 'claude' ||
28
+ ctx.config.adapter === 'codex') {
29
+ return ctx.config.adapter;
30
+ }
31
+ if (ctx.layout.name === 'cursor' || ctx.layout.name === 'claude' || ctx.layout.name === 'codex') {
32
+ return ctx.layout.name;
33
+ }
34
+ return undefined;
35
+ }
19
36
  async function loadJsonFile(filePath, fallback) {
20
37
  try {
21
38
  const raw = await readFile(filePath, 'utf8');
@@ -90,11 +107,13 @@ export function repoShellClassifierOptions(config, repoRoot, layout, extras = {}
90
107
  export function runtimeClassifierOptions(ctx, config) {
91
108
  const repoLocalStateDir = ctx.layout.repoLocalStateDir(ctx.repoRoot);
92
109
  const brokerFsScope = isCapabilityBrokerDemotionActive(config);
110
+ const trustedRoots = loadTrustedWorkspaceRootsSync(trustedWorkspaceRootsPath(config, repoLocalStateDir));
93
111
  return repoShellClassifierOptions(config, ctx.repoRoot, ctx.layout, {
94
112
  brokerFsScope,
95
113
  fsScopeAllowlist: brokerFsScope
96
114
  ? loadFsScopeAllowlistSync(fsScopeAllowlistPath(config, repoLocalStateDir))
97
115
  : undefined,
116
+ trustedWorkspaceRoots: trustedRoots.roots.map((entry) => entry.path),
98
117
  });
99
118
  }
100
119
  function gateAuditEventName(kind) {
@@ -106,7 +125,7 @@ function gateAuditEventName(kind) {
106
125
  }
107
126
  return 'subagentGate';
108
127
  }
109
- async function ensurePendingApproval(ctx, deps, kind, result, approvalInput) {
128
+ async function ensurePendingApproval(ctx, deps, kind, result, approvalInput, scopeHint) {
110
129
  const pending = await deps.loadApprovals(ctx, 'pending-approvals.json');
111
130
  pending.state = compactApprovals(pending.state);
112
131
  const existing = pending.state.approvals.find((approval) => approval.kind === kind &&
@@ -116,7 +135,7 @@ async function ensurePendingApproval(ctx, deps, kind, result, approvalInput) {
116
135
  await deps.writeApprovals(pending.filePath, pending.state);
117
136
  return existing;
118
137
  }
119
- const approval = createApprovalRecord({
138
+ const approval = createApprovalRecordWithEnvelope({
120
139
  kind,
121
140
  fingerprint: result.fingerprint,
122
141
  repoRoot: ctx.repoRoot,
@@ -124,14 +143,70 @@ async function ensurePendingApproval(ctx, deps, kind, result, approvalInput) {
124
143
  summary: result.normalizedCommand ?? result.summary ?? '',
125
144
  approvalTtlMinutes: ctx.config.approvalTtlMinutes,
126
145
  approvalId: `belay_${randomUUID().replaceAll('-', '').slice(0, 12)}`,
127
- input: approvalInput?.input,
128
- inputKind: approvalInput?.inputKind,
146
+ approvalInput,
147
+ scopeHint,
129
148
  });
130
149
  pending.state.version = 2;
131
150
  pending.state.approvals.push(approval);
132
151
  await deps.writeApprovals(pending.filePath, pending.state);
133
152
  return approval;
134
153
  }
154
+ function extractShellCommandFromPayload(payload) {
155
+ const toolInput = payload.tool_input;
156
+ if (!toolInput || typeof toolInput !== 'object') {
157
+ return '';
158
+ }
159
+ const input = toolInput;
160
+ return typeof input.command === 'string' ? input.command : '';
161
+ }
162
+ function deriveWorkspaceRootScopeHint(params) {
163
+ if (params.result.reason !== 'outside_repo_mutation' &&
164
+ params.result.reason !== 'outside_repo_redirect') {
165
+ return undefined;
166
+ }
167
+ const action = params.replayAction;
168
+ if (!action?.cwd) {
169
+ return undefined;
170
+ }
171
+ const outsidePaths = new Set();
172
+ if (action.kind === 'shell' && action.command) {
173
+ for (const resolved of collectOutsideRepoPaths(action.command, action.cwd, action.repoRoot, params.options.trustedWorkspaceRoots)) {
174
+ outsidePaths.add(resolved);
175
+ }
176
+ }
177
+ else if (action.kind === 'tool') {
178
+ const toolPayload = params.payloadForScopeHint ?? action.payload ?? {};
179
+ for (const resolved of collectOutsideRepoPathsFromToolPayload(toolPayload, action.cwd, action.repoRoot, params.options.trustedWorkspaceRoots)) {
180
+ outsidePaths.add(resolved);
181
+ }
182
+ const command = extractShellCommandFromPayload(toolPayload);
183
+ if (command) {
184
+ for (const resolved of collectOutsideRepoPaths(command, action.cwd, action.repoRoot, params.options.trustedWorkspaceRoots)) {
185
+ outsidePaths.add(resolved);
186
+ }
187
+ }
188
+ }
189
+ if (outsidePaths.size !== 1) {
190
+ return undefined;
191
+ }
192
+ const targetPath = [...outsidePaths][0];
193
+ if (!targetPath) {
194
+ return undefined;
195
+ }
196
+ const candidateRoot = canonicalPath(path.dirname(targetPath));
197
+ const validation = validateTrustedWorkspaceRootCandidate({
198
+ candidatePath: candidateRoot,
199
+ repoRoot: action.repoRoot,
200
+ controlPlaneDir: params.options.controlPlaneDir ?? undefined,
201
+ protectedRoots: params.options.protectedArtifactRoots ?? [],
202
+ requireExistingDirectory: true,
203
+ requireNonGit: true,
204
+ });
205
+ if (!validation.ok) {
206
+ return undefined;
207
+ }
208
+ return { scope: 'workspace-root', path: validation.normalizedPath };
209
+ }
135
210
  async function consumeApprovedApproval(ctx, deps, kind, fingerprint) {
136
211
  const approved = await deps.loadApprovals(ctx, 'approved-approvals.json');
137
212
  approved.state = compactApprovals(approved.state);
@@ -149,7 +224,7 @@ async function consumeApprovedApproval(ctx, deps, kind, fingerprint) {
149
224
  }
150
225
  approved.state.approvals[index] = {
151
226
  ...approval,
152
- executionLeaseExpiresAt: new Date(Date.now() + APPROVAL_EXECUTION_LEASE_MS).toISOString(),
227
+ executionLeaseExpiresAt: new Date(Date.now() + getExecutionLeaseMs(ctx.config)).toISOString(),
153
228
  };
154
229
  await deps.writeApprovals(approved.filePath, approved.state);
155
230
  return approval;
@@ -247,6 +322,8 @@ export async function evaluateGatedAction(ctx, deps, params) {
247
322
  };
248
323
  }
249
324
  }
325
+ const scrubOpts = scrubOptionsFromConfig(ctx.config);
326
+ const scrubbedPayload = fingerprintReplayPayload(params.kind, params.payload, scrubOpts);
250
327
  return gateDecisionToVerdict(ctx, deps, params.kind, result, {
251
328
  predictedAssessment,
252
329
  observedAssessment,
@@ -255,24 +332,40 @@ export async function evaluateGatedAction(ctx, deps, params) {
255
332
  ? {
256
333
  input: params.command ?? result.normalizedCommand ?? result.summary ?? '',
257
334
  inputKind: 'shell',
335
+ cwd: params.cwd,
258
336
  }
259
337
  : {
260
338
  input: params.command ??
261
339
  result.normalizedCommand ??
262
340
  result.summary ??
263
- canonicalStringify(params.payload ?? {}),
341
+ canonicalStringify(scrubbedPayload ?? {}),
264
342
  inputKind: params.kind,
343
+ cwd: params.cwd,
344
+ toolName: params.toolName,
345
+ payload: scrubbedPayload,
265
346
  },
347
+ replayAction: {
348
+ kind: params.kind,
349
+ cwd: params.cwd,
350
+ toolName: params.toolName,
351
+ command: params.command,
352
+ payload: scrubbedPayload,
353
+ fingerprint: result.fingerprint,
354
+ repoRoot: ctx.repoRoot,
355
+ },
356
+ classifierOptions,
357
+ scopeHintPayload: params.payload,
266
358
  });
267
359
  }
268
360
  /** R39: unmapped Codex tools ask via pending approval — not hard deny without approval path. */
269
361
  export async function gateUnmappedToolVerdict(ctx, deps, toolName, payload) {
270
- const scrubbed = scrubValue(payload, scrubOptionsFromConfig(ctx.config));
362
+ const scrubOpts = scrubOptionsFromConfig(ctx.config);
363
+ const replayPayload = fingerprintReplayPayload('tool', payload, scrubOpts) ?? {};
271
364
  const result = {
272
365
  verdict: 'deny_pending_approval',
273
366
  reason: 'unmapped_tool',
274
367
  summary: toolName,
275
- fingerprint: toolFingerprint(toolName, scrubbed, ctx.repoRoot),
368
+ fingerprint: toolFingerprint(toolName, replayPayload, ctx.repoRoot),
276
369
  assessment: {
277
370
  reversibility: 'irreversible',
278
371
  external: false,
@@ -285,10 +378,23 @@ export async function gateUnmappedToolVerdict(ctx, deps, toolName, payload) {
285
378
  approvalInput: {
286
379
  input: toolName,
287
380
  inputKind: 'tool',
381
+ cwd: ctx.repoRoot,
382
+ toolName,
383
+ payload: replayPayload,
384
+ },
385
+ replayAction: {
386
+ kind: 'tool',
387
+ cwd: ctx.repoRoot,
388
+ toolName,
389
+ payload: replayPayload,
390
+ fingerprint: result.fingerprint,
391
+ repoRoot: ctx.repoRoot,
288
392
  },
289
393
  });
290
394
  }
291
395
  async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {}) {
396
+ const replayContext = buildAuditReplayContext(kind, result, auditExtras.replayAction);
397
+ const actionSnapshot = buildAuditActionSnapshot(kind, result, auditExtras.replayAction);
292
398
  const gateBase = {
293
399
  event: gateAuditEventName(kind),
294
400
  kind,
@@ -301,6 +407,8 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
301
407
  schemaVersion: result.axes ? 2 : 1,
302
408
  ...(result.axes ?? {}),
303
409
  ...auditExtras.transactionalLayer,
410
+ ...(replayContext ? { replayContext } : {}),
411
+ ...(actionSnapshot ? { actionSnapshot } : {}),
304
412
  };
305
413
  if (result.reason === TRANSACTIONAL_ALREADY_APPLIED) {
306
414
  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 +429,37 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
321
429
  agent_message: agentMessage,
322
430
  });
323
431
  }
432
+ if (result.verdict === 'deny_pending_approval' &&
433
+ ctx.config.mode === 'enforce' &&
434
+ !TRANSACTIONAL_APPROVAL_BYPASS_REASONS.has(result.reason)) {
435
+ const standingAllowPath = standingAllowFile(ctx.config, ctx.layout.repoLocalStateDir(ctx.repoRoot));
436
+ const standingState = await loadStandingAllow(standingAllowPath);
437
+ const standingMatch = resolveStandingAllowMatch({
438
+ kind,
439
+ result,
440
+ repoRoot: ctx.repoRoot,
441
+ state: standingState,
442
+ });
443
+ if (standingMatch) {
444
+ await deps.appendAudit(ctx, {
445
+ ...gateBase,
446
+ verdict: 'allow',
447
+ reason: 'standing_allow',
448
+ standingAllowSource: standingMatch.source,
449
+ ...(standingMatch.catalogCommand
450
+ ? { standingAllowCatalogCommand: standingMatch.catalogCommand }
451
+ : {}),
452
+ wouldBlock: false,
453
+ permission: 'allow',
454
+ });
455
+ return classifyResultToGateVerdict({
456
+ result: { ...result, verdict: 'allow', reason: 'standing_allow' },
457
+ mode: ctx.config.mode,
458
+ permission: 'allow',
459
+ wouldBlock: false,
460
+ });
461
+ }
462
+ }
324
463
  const brokerActive = isCapabilityBrokerDemotionActive(ctx.config);
325
464
  let approved = null;
326
465
  if (!TRANSACTIONAL_APPROVAL_BYPASS_REASONS.has(result.reason) &&
@@ -331,6 +470,31 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
331
470
  entry.fingerprint === result.fingerprint &&
332
471
  entry.repoRoot === ctx.repoRoot);
333
472
  if (!shouldSkipBrokerApprovedRecord(brokerActive, matchedApproval?.reason)) {
473
+ if (matchedApproval &&
474
+ auditExtras.replayAction &&
475
+ !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.',
496
+ });
497
+ }
334
498
  approved = await consumeApprovedApproval(ctx, deps, kind, result.fingerprint);
335
499
  }
336
500
  }
@@ -381,7 +545,12 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
381
545
  wouldBlock: true,
382
546
  });
383
547
  }
384
- const approval = await ensurePendingApproval(ctx, deps, kind, result, auditExtras.approvalInput);
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
+ }));
385
554
  let approvalToken;
386
555
  try {
387
556
  approvalToken = await issueApprovalToken({
@@ -419,7 +588,7 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
419
588
  permission: 'deny',
420
589
  wouldBlock: true,
421
590
  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.`,
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.`,
423
592
  agent_message: `Belay denied this action as ${result.reason}. Wait for approval, then retry the exact same action once.`,
424
593
  });
425
594
  }
@@ -428,6 +597,7 @@ export async function processApprovalPrompt(ctx, deps, prompt) {
428
597
  if (!approvalId) {
429
598
  return { continue: true };
430
599
  }
600
+ const adapter = adapterIdFromContext(ctx);
431
601
  if (ctx.config.approvalSigning.required) {
432
602
  const message = `Signed approval token required for ${approvalId}. Editor prompt approval is disabled in this configuration. ` +
433
603
  `Use belay approve --approval-id ${approvalId} --token <signed-token>.`;
@@ -448,12 +618,11 @@ export async function processApprovalPrompt(ctx, deps, prompt) {
448
618
  approvalId,
449
619
  config: ctx.config,
450
620
  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
- },
621
+ adapter,
622
+ store: gateApprovalStoreFromDeps({
623
+ loadApprovals: (fileName) => deps.loadApprovals(ctx, fileName),
624
+ writeApprovals: (filePath, state) => deps.writeApprovals(filePath, state),
625
+ }),
457
626
  });
458
627
  await deps.appendAudit(ctx, {
459
628
  event: 'approval',
@@ -469,9 +638,96 @@ export async function processApprovalPrompt(ctx, deps, prompt) {
469
638
  user_message: recorded.message,
470
639
  };
471
640
  }
641
+ const replay = recorded.approval ? buildReplayHint(ctx.config, recorded.approval, adapter) : null;
642
+ const shouldFallbackReplay = adapter === 'cursor' &&
643
+ replay?.kind === 'shell' &&
644
+ replay.autoReplay === true &&
645
+ Boolean(recorded.approval?.input) &&
646
+ (!process.env.VITEST || process.env.BELAY_TEST_APPROVAL_REPLAY === '1');
647
+ if (shouldFallbackReplay && recorded.approval) {
648
+ const approvalStore = gateApprovalStoreFromDeps({
649
+ loadApprovals: (fileName) => deps.loadApprovals(ctx, fileName),
650
+ writeApprovals: (filePath, state) => deps.writeApprovals(filePath, state),
651
+ });
652
+ let replayResult;
653
+ try {
654
+ replayResult = await replayShellCommand(recorded.approval.input ?? '', recorded.approval.cwd ?? ctx.repoRoot, getExecutionLeaseMs(ctx.config));
655
+ }
656
+ catch (error) {
657
+ const detail = error instanceof Error ? error.message : String(error);
658
+ await deps.appendAudit(ctx, {
659
+ event: 'approval',
660
+ kind: 'approval',
661
+ verdict: 'allow',
662
+ approvalId,
663
+ reason: 'approval_replay_error',
664
+ summary: recorded.approval.input ?? recorded.approval.summary ?? prompt,
665
+ });
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
+ });
690
+ return {
691
+ 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}` : ''),
695
+ };
696
+ }
697
+ await deps.appendAudit(ctx, {
698
+ event: 'approval',
699
+ kind: 'approval',
700
+ verdict: 'allow',
701
+ approvalId,
702
+ reason: 'approval_replay_succeeded',
703
+ summary: recorded.approval.input ?? recorded.approval.summary ?? prompt,
704
+ });
705
+ return {
706
+ continue: false,
707
+ user_message: `Belay approval recorded for ${approvalId}. One-step shell replay succeeded; no manual retry required.` +
708
+ (output ? `\n${output}` : ''),
709
+ };
710
+ }
711
+ await deps.appendAudit(ctx, {
712
+ event: 'approval',
713
+ kind: 'approval',
714
+ verdict: 'allow',
715
+ approvalId,
716
+ reason: 'approval_replay_failed',
717
+ summary: recorded.approval.input ?? recorded.approval.summary ?? prompt,
718
+ });
719
+ const timeoutNote = replayResult.timedOut ? ' Replay timed out.' : '';
720
+ return {
721
+ 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}` : ''),
725
+ };
726
+ }
472
727
  return {
473
728
  continue: false,
474
729
  user_message: recorded.message,
730
+ ...(replay ? { replay } : {}),
475
731
  };
476
732
  }
477
733
  export function gateVerdictToCursorResponse(verdict) {
@@ -504,6 +760,7 @@ export function gateVerdictToClaudeUserPromptResponse(verdict) {
504
760
  hookEventName: 'UserPromptSubmit',
505
761
  continue: false,
506
762
  user_message: verdict.user_message,
763
+ ...(verdict.replay ? { replay: verdict.replay } : {}),
507
764
  },
508
765
  };
509
766
  }
@@ -520,6 +777,7 @@ export function gateVerdictToCodexUserPromptResponse(verdict) {
520
777
  return {
521
778
  decision: 'block',
522
779
  reason: verdict.user_message,
780
+ ...(verdict.replay ? { replay: verdict.replay } : {}),
523
781
  };
524
782
  }
525
783
  export async function appendObservedAudit(ctx, deps, eventName, payload) {