@guilz-dev/belay 0.2.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 (180) hide show
  1. package/README.md +39 -12
  2. package/dist/adapters/cursor/runtime-entry.js +1 -0
  3. package/dist/adapters/shared/gate-runtime.d.ts +11 -14
  4. package/dist/adapters/shared/gate-runtime.js +289 -23
  5. package/dist/bundle/claude-runtime.mjs +4435 -708
  6. package/dist/bundle/codex-runtime.mjs +4448 -710
  7. package/dist/bundle/cursor-runtime.mjs +4437 -710
  8. package/dist/cli.js +333 -11
  9. package/dist/commands/approve.d.ts +1 -0
  10. package/dist/commands/approve.js +56 -26
  11. package/dist/commands/config.d.ts +70 -0
  12. package/dist/commands/config.js +577 -0
  13. package/dist/commands/doctor.js +13 -2
  14. package/dist/commands/explain.js +6 -1
  15. package/dist/commands/harvest.d.ts +29 -0
  16. package/dist/commands/harvest.js +72 -0
  17. package/dist/commands/health-snapshot.d.ts +6 -0
  18. package/dist/commands/health-snapshot.js +34 -0
  19. package/dist/commands/judge.d.ts +100 -0
  20. package/dist/commands/judge.js +304 -0
  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/simulate.d.ts +1 -0
  25. package/dist/commands/simulate.js +7 -2
  26. package/dist/commands/standing-allow.d.ts +10 -0
  27. package/dist/commands/standing-allow.js +26 -0
  28. package/dist/commands/status.js +2 -2
  29. package/dist/commands/stdin-key.d.ts +1 -0
  30. package/dist/commands/stdin-key.js +8 -0
  31. package/dist/commands/tui.d.ts +48 -0
  32. package/dist/commands/tui.js +150 -0
  33. package/dist/config-io.js +10 -5
  34. package/dist/conformance/guarantee-table.js +12 -0
  35. package/dist/conformance/types.d.ts +2 -0
  36. package/dist/core/approval-replay-cli.d.ts +7 -0
  37. package/dist/core/approval-replay-cli.js +36 -0
  38. package/dist/core/approval-replay.d.ts +45 -0
  39. package/dist/core/approval-replay.js +141 -0
  40. package/dist/core/approval-service.d.ts +15 -0
  41. package/dist/core/approval-service.js +47 -2
  42. package/dist/core/approval.d.ts +24 -3
  43. package/dist/core/approval.js +47 -4
  44. package/dist/core/audit-analysis.d.ts +7 -1
  45. package/dist/core/audit-analysis.js +111 -0
  46. package/dist/core/audit-io.d.ts +2 -0
  47. package/dist/core/audit-io.js +14 -0
  48. package/dist/core/audit-metrics.d.ts +7 -0
  49. package/dist/core/audit-metrics.js +24 -4
  50. package/dist/core/audit-query.d.ts +1 -0
  51. package/dist/core/audit-query.js +3 -0
  52. package/dist/core/audit-replay-context.d.ts +35 -0
  53. package/dist/core/audit-replay-context.js +88 -0
  54. package/dist/core/audit-types.d.ts +24 -1
  55. package/dist/core/audit-types.js +1 -1
  56. package/dist/core/capability/index.d.ts +4 -3
  57. package/dist/core/capability/index.js +3 -2
  58. package/dist/core/capability/paths.d.ts +2 -1
  59. package/dist/core/capability/paths.js +76 -8
  60. package/dist/core/capability/reasons.d.ts +3 -0
  61. package/dist/core/capability/reasons.js +8 -0
  62. package/dist/core/capability/trusted-workspace-roots.d.ts +25 -0
  63. package/dist/core/capability/trusted-workspace-roots.js +149 -0
  64. package/dist/core/capability/types.d.ts +11 -1
  65. package/dist/core/capability-approval.d.ts +2 -1
  66. package/dist/core/capability-approval.js +100 -2
  67. package/dist/core/classify-subagent.js +2 -20
  68. package/dist/core/classify-tool.js +129 -30
  69. package/dist/core/config-layers.js +2 -1
  70. package/dist/core/config.d.ts +40 -2
  71. package/dist/core/config.js +159 -11
  72. package/dist/core/credential-store.d.ts +11 -0
  73. package/dist/core/credential-store.js +60 -0
  74. package/dist/core/gate-engine.js +104 -13
  75. package/dist/core/harvest.d.ts +53 -0
  76. package/dist/core/harvest.js +276 -0
  77. package/dist/core/index.d.ts +9 -3
  78. package/dist/core/index.js +6 -2
  79. package/dist/core/integrity.d.ts +2 -0
  80. package/dist/core/integrity.js +13 -0
  81. package/dist/core/judge-api-key.d.ts +19 -0
  82. package/dist/core/judge-api-key.js +74 -0
  83. package/dist/core/judge-cloud-consent.d.ts +13 -0
  84. package/dist/core/judge-cloud-consent.js +38 -0
  85. package/dist/core/judge-config.d.ts +41 -4
  86. package/dist/core/judge-config.js +263 -57
  87. package/dist/core/judge-doctor.d.ts +9 -1
  88. package/dist/core/judge-doctor.js +199 -94
  89. package/dist/core/judge-model-discovery.d.ts +24 -0
  90. package/dist/core/judge-model-discovery.js +168 -0
  91. package/dist/core/judge-model-policy.d.ts +5 -0
  92. package/dist/core/judge-model-policy.js +21 -0
  93. package/dist/core/judge-runtime-detection.d.ts +9 -0
  94. package/dist/core/judge-runtime-detection.js +68 -0
  95. package/dist/core/path-utils.d.ts +11 -0
  96. package/dist/core/path-utils.js +56 -6
  97. package/dist/core/reclassify.d.ts +3 -0
  98. package/dist/core/reclassify.js +44 -14
  99. package/dist/core/replay-scrub.d.ts +9 -0
  100. package/dist/core/replay-scrub.js +43 -0
  101. package/dist/core/shell-tokenizer.d.ts +2 -0
  102. package/dist/core/shell-tokenizer.js +82 -23
  103. package/dist/core/standing-allow.d.ts +50 -0
  104. package/dist/core/standing-allow.js +175 -0
  105. package/dist/core/transactional/diff-evaluator.js +1 -19
  106. package/dist/core/types.d.ts +17 -0
  107. package/dist/core/verdict/adapter.d.ts +1 -0
  108. package/dist/core/verdict/adapter.js +17 -2
  109. package/dist/core/verdict/containment.d.ts +8 -2
  110. package/dist/core/verdict/containment.js +45 -10
  111. package/dist/core/verdict/judge-audit.d.ts +1 -0
  112. package/dist/core/verdict/judge-audit.js +32 -1
  113. package/dist/core/verdict/judge-baseline.d.ts +20 -0
  114. package/dist/core/verdict/judge-baseline.js +48 -0
  115. package/dist/core/verdict/judge-broker-service.d.ts +42 -0
  116. package/dist/core/verdict/judge-broker-service.js +279 -0
  117. package/dist/core/verdict/judge-catalog.d.ts +40 -0
  118. package/dist/core/verdict/judge-catalog.js +148 -0
  119. package/dist/core/verdict/judge-cli-fingerprint.d.ts +5 -0
  120. package/dist/core/verdict/judge-cli-fingerprint.js +61 -0
  121. package/dist/core/verdict/judge-cli.d.ts +46 -0
  122. package/dist/core/verdict/judge-cli.js +401 -0
  123. package/dist/core/verdict/judge-factory.d.ts +15 -4
  124. package/dist/core/verdict/judge-factory.js +127 -15
  125. package/dist/core/verdict/judge-provider-matrix.d.ts +20 -0
  126. package/dist/core/verdict/judge-provider-matrix.js +66 -0
  127. package/dist/core/verdict/judge-runtime-config.d.ts +42 -0
  128. package/dist/core/verdict/judge-runtime-config.js +92 -0
  129. package/dist/core/verdict/judge-session-broker.d.ts +48 -0
  130. package/dist/core/verdict/judge-session-broker.js +195 -0
  131. package/dist/core/verdict/judge-session-guard.d.ts +27 -0
  132. package/dist/core/verdict/judge-session-guard.js +91 -0
  133. package/dist/core/verdict/judge-session-kill-switch.d.ts +11 -0
  134. package/dist/core/verdict/judge-session-kill-switch.js +43 -0
  135. package/dist/core/verdict/judge-session-mutex.d.ts +9 -0
  136. package/dist/core/verdict/judge-session-mutex.js +23 -0
  137. package/dist/core/verdict/judge-shadow.d.ts +24 -0
  138. package/dist/core/verdict/judge-shadow.js +116 -0
  139. package/dist/core/verdict/judge-transport.d.ts +40 -0
  140. package/dist/core/verdict/judge-transport.js +258 -0
  141. package/dist/core/verdict/judge.d.ts +33 -1
  142. package/dist/core/verdict/judge.js +84 -14
  143. package/dist/core/verdict/parser.js +6 -1
  144. package/dist/core/verdict/persistent-paths.d.ts +8 -0
  145. package/dist/core/verdict/persistent-paths.js +52 -0
  146. package/dist/core/verdict/types.d.ts +19 -2
  147. package/dist/core/verdict/verdict.js +166 -49
  148. package/dist/corpus/adversarial-probe.d.ts +86 -0
  149. package/dist/corpus/adversarial-probe.js +220 -0
  150. package/dist/corpus/evaluate.d.ts +45 -12
  151. package/dist/corpus/evaluate.js +62 -5
  152. package/dist/corpus/gates.d.ts +35 -0
  153. package/dist/corpus/gates.js +81 -0
  154. package/dist/corpus/judge-accuracy.d.ts +8 -0
  155. package/dist/corpus/judge-accuracy.js +39 -0
  156. package/dist/corpus/must-allow-commands.d.ts +5 -0
  157. package/dist/corpus/must-allow-commands.js +13 -0
  158. package/dist/corpus/mutators.d.ts +28 -0
  159. package/dist/corpus/mutators.js +151 -0
  160. package/dist/corpus/ratchet.d.ts +42 -0
  161. package/dist/corpus/ratchet.js +116 -0
  162. package/dist/corpus/runtime-match.d.ts +19 -0
  163. package/dist/corpus/runtime-match.js +45 -0
  164. package/dist/corpus/standing-allow-catalog.generated.d.ts +13 -0
  165. package/dist/corpus/standing-allow-catalog.generated.js +79 -0
  166. package/dist/corpus/types.d.ts +47 -0
  167. package/dist/corpus/types.js +157 -0
  168. package/dist/installer.js +69 -15
  169. package/dist/judge-broker-daemon.d.ts +1 -0
  170. package/dist/judge-broker-daemon.js +123 -0
  171. package/dist/services/sandbox-service.d.ts +1 -0
  172. package/dist/services/sandbox-service.js +2 -0
  173. package/dist/types.d.ts +7 -0
  174. package/dist/version.d.ts +1 -1
  175. package/dist/version.js +1 -1
  176. package/package.json +7 -3
  177. package/skills/belay/SKILL.md +15 -8
  178. package/skills/belay/belay-approve.md +17 -0
  179. package/dist/commands/init-wizard.d.ts +0 -21
  180. package/dist/commands/init-wizard.js +0 -63
package/README.md CHANGED
@@ -34,8 +34,8 @@ policy to maintain.
34
34
  | **Claude Code** | Supported | `.claude/settings.json` | `.claude/belay.config.json` |
35
35
  | **Codex** | Experimental | `.codex/config.toml` | `.codex/belay.config.json` |
36
36
 
37
- Pick the adapter at install time with `--adapter cursor|claude|codex` (or let
38
- `init-wizard` prompt). Hosts use different hook event names, but Belay registers
37
+ Pick the adapter at install time with `--adapter cursor|claude|codex` (or use
38
+ `belay config` interactively). Hosts use different hook event names, but Belay registers
39
39
  the same runners (`belay-tool-gate`, `belay-before-submit`, `belay-audit`) at
40
40
  equivalent lifecycle points:
41
41
 
@@ -68,8 +68,8 @@ audit instead of guessing.
68
68
  ## Quick start
69
69
 
70
70
  ```bash
71
- # Interactive setup (prompts for adapter, scope, skill, mode)
72
- npx @guilz-dev/belay init-wizard
71
+ # Interactive setup (adapter, scope, skill, judge provider, credentials)
72
+ belay config
73
73
 
74
74
  # Or non-interactive
75
75
  npx @guilz-dev/belay init --adapter claude # Claude Code
@@ -131,6 +131,9 @@ Belay is a layered hook gate, not a static denylist. Higher layers are opt-in.
131
131
  - L3 command lists are **not security boundaries** by themselves — see
132
132
  [docs/ops/semver-policy.md](./docs/ops/semver-policy.md) and
133
133
  [docs/guarantee-table.md](./docs/guarantee-table.md).
134
+ - At default L3, **local-recoverable mutations outside the repo** pass after Tier1
135
+ (e.g. IDE plan files). To deny repo-external writes at the OS boundary, enable L1-full
136
+ (`sandbox.runtime` ≠ `none`). Tier1 requires a working judge (local Ollama by default).
134
137
  - Adversarial resistance requires the full L1 stack:
135
138
  `belay init --preset l1-full-recommended`, verified with `belay sandbox status`.
136
139
 
@@ -236,20 +239,37 @@ Notable settings:
236
239
  current OS user. `upgrade` migrates repo-local approvals in; disabling merges
237
240
  them back. File-mutation tools and shell redirects cannot write control-plane
238
241
  paths while it is enabled.
239
- - **Cloud judge** — for `judge.provider: "openai-compatible"`, set
240
- `judge.endpoint` and provide `BELAY_JUDGE_API_KEY` (or `OPENAI_API_KEY`), or
241
- opt in with
242
- `belay init --judge-provider openai-compatible --judge-endpoint <url> --accept-cloud-judge`.
243
- Fresh installs default to local Ollama (`local-ollama`).
242
+ - **Cloud judge** — configure with `belay config` (interactive) or `belay config set judge.providerId <id>`.
243
+ Providers: `ollama`, `codex`, `claude`, `cursor`. **Provider** is the vendor/service
244
+ (`judge.providerId`); **driver** is the API compatibility layer (`judge.provider`:
245
+ `ollama`, `openai-compatible`, or `anthropic`); **host** is where hooks install
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
248
+ consent during `belay config` or via `belay judge consent` → `belay approve` →
249
+ `belay judge use … --cloud-consent-approval-id`. Cloud providers can use native CLI
250
+ transport without `judge.endpoint` when the host CLI is available (`codex-cli`,
251
+ `cursor-cli`, `claude-cli`); HTTP transport still requires endpoint and recorded consent.
252
+ Fresh installs default to the
253
+ host-matched provider (`cursor` → `cursor`, etc.). `belay judge use` remains available
254
+ as a secondary path.
255
+
256
+ Legacy `judge.model: auto` in config files is normalized to the provider catalog default on load
257
+ (with a warning); new `auto` values are rejected on CLI, `belay config set`, and `belay judge use`.
258
+ On an installed repo, interactive `belay config` defaults to judge-only setup without re-running
259
+ `init`. Model discovery is covered by unit tests with mocks; set `BELAY_LIVE_CLI_DISCOVERY=1` locally
260
+ for optional live CLI probes. Cloud egress consent is enforced for HTTP transport; native CLI
261
+ transport uses the host session and does not require `judge.endpoint`. `BELAY_JUDGE_MODEL_RESOLVED`
262
+ applies only under Vitest (test overrides).
244
263
 
245
264
  ## Command reference
246
265
 
247
266
  ```bash
248
267
  belay init [--adapter cursor|claude|codex] [--scope project|global]
249
268
  [--preset strict|standard|audit-first|l1-full-recommended]
250
- [--with-skill] [--dogfood]
251
- belay init-wizard # interactive install
252
- belay upgrade # refresh hooks + runtime, migrate config
269
+ [--migrate-judge-default] [--with-skill] [--dogfood]
270
+ belay config # interactive setup (primary)
271
+ belay config list|get|set|unset|judge|credential
272
+ belay upgrade [--migrate-judge-default] # refresh hooks + runtime, migrate config
253
273
  belay dogfood [--enforce] # toggle audit / enforce mode
254
274
  belay doctor [--fix] # check (and repair) floor health
255
275
  belay status # show install scope / skill-only state
@@ -263,6 +283,13 @@ belay egress <start|stop|status|env>
263
283
  belay sandbox status
264
284
  belay approve <approval-id> [--scope once|domain|path]
265
285
  belay revoke <approval-id>
286
+ belay judge status
287
+ belay judge list
288
+ belay judge use <ollama|codex|claude|cursor> [--model <id>] [--endpoint <url>]
289
+ [--accept-cloud] [--cloud-consent-approval-id <id>]
290
+ [--credential project|apiKey] [--key-stdin] [--key-env <NAME>]
291
+ belay judge test
292
+ belay judge consent <provider-id> [--endpoint <url>]
266
293
  ```
267
294
 
268
295
  ## Coexisting with existing hooks
@@ -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, } 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,11 +429,75 @@ 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
- const approved = TRANSACTIONAL_APPROVAL_BYPASS_REASONS.has(result.reason) ||
326
- shouldSkipBrokerApprovedOnce(brokerActive, result.reason)
327
- ? null
328
- : await consumeApprovedApproval(ctx, deps, kind, result.fingerprint);
464
+ let approved = null;
465
+ if (!TRANSACTIONAL_APPROVAL_BYPASS_REASONS.has(result.reason) &&
466
+ !shouldSkipBrokerApprovedOnce(brokerActive, result.reason)) {
467
+ const approvedState = await deps.loadApprovals(ctx, 'approved-approvals.json');
468
+ approvedState.state = compactApprovals(approvedState.state);
469
+ const matchedApproval = approvedState.state.approvals.find((entry) => entry.kind === kind &&
470
+ entry.fingerprint === result.fingerprint &&
471
+ entry.repoRoot === ctx.repoRoot);
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
+ }
498
+ approved = await consumeApprovedApproval(ctx, deps, kind, result.fingerprint);
499
+ }
500
+ }
329
501
  if (approved) {
330
502
  await deps.appendAudit(ctx, {
331
503
  ...gateBase,
@@ -373,7 +545,12 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
373
545
  wouldBlock: true,
374
546
  });
375
547
  }
376
- 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
+ }));
377
554
  let approvalToken;
378
555
  try {
379
556
  approvalToken = await issueApprovalToken({
@@ -411,7 +588,7 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
411
588
  permission: 'deny',
412
589
  wouldBlock: true,
413
590
  approvalId: approval.approvalId,
414
- 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.`,
415
592
  agent_message: `Belay denied this action as ${result.reason}. Wait for approval, then retry the exact same action once.`,
416
593
  });
417
594
  }
@@ -420,6 +597,7 @@ export async function processApprovalPrompt(ctx, deps, prompt) {
420
597
  if (!approvalId) {
421
598
  return { continue: true };
422
599
  }
600
+ const adapter = adapterIdFromContext(ctx);
423
601
  if (ctx.config.approvalSigning.required) {
424
602
  const message = `Signed approval token required for ${approvalId}. Editor prompt approval is disabled in this configuration. ` +
425
603
  `Use belay approve --approval-id ${approvalId} --token <signed-token>.`;
@@ -440,12 +618,11 @@ export async function processApprovalPrompt(ctx, deps, prompt) {
440
618
  approvalId,
441
619
  config: ctx.config,
442
620
  requireSignedToken: ctx.config.approvalSigning.required,
443
- store: {
444
- loadPending: () => deps.loadApprovals(ctx, 'pending-approvals.json'),
445
- loadApproved: () => deps.loadApprovals(ctx, 'approved-approvals.json'),
446
- writePending: (filePath, state) => deps.writeApprovals(filePath, state),
447
- writeApproved: (filePath, state) => deps.writeApprovals(filePath, state),
448
- },
621
+ adapter,
622
+ store: gateApprovalStoreFromDeps({
623
+ loadApprovals: (fileName) => deps.loadApprovals(ctx, fileName),
624
+ writeApprovals: (filePath, state) => deps.writeApprovals(filePath, state),
625
+ }),
449
626
  });
450
627
  await deps.appendAudit(ctx, {
451
628
  event: 'approval',
@@ -461,9 +638,96 @@ export async function processApprovalPrompt(ctx, deps, prompt) {
461
638
  user_message: recorded.message,
462
639
  };
463
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
+ }
464
727
  return {
465
728
  continue: false,
466
729
  user_message: recorded.message,
730
+ ...(replay ? { replay } : {}),
467
731
  };
468
732
  }
469
733
  export function gateVerdictToCursorResponse(verdict) {
@@ -496,6 +760,7 @@ export function gateVerdictToClaudeUserPromptResponse(verdict) {
496
760
  hookEventName: 'UserPromptSubmit',
497
761
  continue: false,
498
762
  user_message: verdict.user_message,
763
+ ...(verdict.replay ? { replay: verdict.replay } : {}),
499
764
  },
500
765
  };
501
766
  }
@@ -512,6 +777,7 @@ export function gateVerdictToCodexUserPromptResponse(verdict) {
512
777
  return {
513
778
  decision: 'block',
514
779
  reason: verdict.user_message,
780
+ ...(verdict.replay ? { replay: verdict.replay } : {}),
515
781
  };
516
782
  }
517
783
  export async function appendObservedAudit(ctx, deps, eventName, payload) {