@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
@@ -2,20 +2,30 @@ import { repoLocalStateDirFor } from '../config-io.js';
2
2
  import { normalizeJudgeProvider, scrubOptionsFromConfig } from './config.js';
3
3
  import { resolveJudgeCredential } from './judge-api-key.js';
4
4
  import { hasValidCloudConsent } from './judge-config.js';
5
+ import { formatJudgeRecoveryHint, inferProviderIdFromFallbackReason, } from './judge-fallback-hints.js';
5
6
  import { discoverJudgeModels, modelPresenceFromDiscovery, } from './judge-model-discovery.js';
6
7
  import { detectJudgeRuntimeCapabilities, resolveJudgeTransport } from './judge-runtime-detection.js';
7
8
  import { createOllamaJudge, createOpenAiCompatibleJudge } from './verdict/judge.js';
8
9
  import { stopJudgeBrokerDaemon } from './verdict/judge-broker-service.js';
9
10
  import { getJudgeProviderCapabilities, getJudgeProviderSpec, isRemovedProviderId, normalizeLegacyProviderId, } from './verdict/judge-catalog.js';
10
11
  import { createJudgeFromConfig, resolveJudgeModel } from './verdict/judge-factory.js';
12
+ import { resolveJudgeSmokeProbeTimeoutMs } from './verdict/judge-runtime-config.js';
11
13
  import { listRepoJudgeSessionBrokers, stopRepoJudgeSessionBroker, } from './verdict/judge-session-broker.js';
12
14
  import { clearJudgeSessionKillSwitch } from './verdict/judge-session-kill-switch.js';
15
+ function judgeShadowAdvisory(warnings, message) {
16
+ warnings.push(`[judge shadow advisory] ${message}`);
17
+ }
13
18
  export async function diagnoseJudge(config, repoRoot = process.cwd(), options = {}) {
14
19
  const shouldRunLiveProbe = options.liveProbe === true && !process.env.VITEST && !process.env.VITEST_WORKER_ID;
15
20
  const issues = [];
16
21
  const warnings = [];
17
22
  const notes = [];
18
23
  const judge = config.judge;
24
+ const gateMode = judge.mode ?? 'shadow';
25
+ notes.push(`Judge gate mode: ${gateMode} (sync Tier1 removed from hook gate; judge is shadow/async only)`);
26
+ if (gateMode === 'off') {
27
+ notes.push('Judge shadow is disabled (judge.mode=off).');
28
+ }
19
29
  const provider = normalizeJudgeProvider(judge.provider);
20
30
  const rawProviderId = judge.providerId ? String(judge.providerId) : undefined;
21
31
  if (rawProviderId && isRemovedProviderId(rawProviderId)) {
@@ -36,15 +46,19 @@ export async function diagnoseJudge(config, repoRoot = process.cwd(), options =
36
46
  const capabilities = getJudgeProviderCapabilities(providerId);
37
47
  const transport = resolveJudgeTransport(judge);
38
48
  const runtime = detectJudgeRuntimeCapabilities(providerId);
49
+ const cursorAcpEnabled = transport === 'cursor-cli' &&
50
+ providerId === 'cursor' &&
51
+ config.judge.runtime?.session.enabled === true;
52
+ const effectiveTransport = cursorAcpEnabled ? 'cursor-acp' : transport;
39
53
  notes.push(`Judge providerId: ${providerId}`);
40
54
  notes.push(`Judge driver: ${provider}`);
41
55
  notes.push(`Judge model requested: ${judge.model}`);
42
- notes.push(`Judge transport: ${transport}`);
56
+ notes.push(`Judge transport: ${effectiveTransport}`);
43
57
  if (config.policy.modelAssist.enabled) {
44
58
  warnings.push('policy.modelAssist is enabled but is not wired to v2 Tier1. Use top-level judge instead.');
45
59
  }
46
60
  if (capabilities?.requiresConsent && !hasValidCloudConsent(judge) && transport === 'http') {
47
- issues.push('Cloud judge consent is not recorded. Tier1 cloud judge will fail closed until consent is granted.');
61
+ judgeShadowAdvisory(warnings, 'Cloud judge consent is not recorded. Shadow cloud judge will fail until consent is granted.');
48
62
  }
49
63
  else if (judge.cloudConsent?.accepted) {
50
64
  notes.push(`Cloud consent: accepted ${judge.cloudConsent.at} by ${judge.cloudConsent.by}`);
@@ -74,12 +88,12 @@ export async function diagnoseJudge(config, repoRoot = process.cwd(), options =
74
88
  if (providerId === 'ollama') {
75
89
  notes.push(`Ollama endpoint: ${endpoint}`);
76
90
  if (discovery.modelIds.length === 0) {
77
- issues.push(`Ollama endpoint unreachable or returned no models. Tier1 will fail closed.`);
91
+ judgeShadowAdvisory(warnings, 'Ollama endpoint unreachable or returned no models. Shadow judge will not run.');
78
92
  }
79
93
  else {
80
94
  const hasModel = modelCheck.status === 'found';
81
95
  if (!hasModel) {
82
- issues.push(`Ollama model "${judge.model}" is not present. Pull it before enforce mode.`);
96
+ judgeShadowAdvisory(warnings, `Ollama model "${judge.model}" is not present. Pull it before enabling shadow comparisons.`);
83
97
  }
84
98
  else {
85
99
  notes.push(`Ollama model "${judge.model}" is available.`);
@@ -103,7 +117,7 @@ export async function diagnoseJudge(config, repoRoot = process.cwd(), options =
103
117
  context: { cwd: process.cwd(), repoRoot: process.cwd() },
104
118
  });
105
119
  if (warmResult.reason === 'ollama_unavailable' || warmResult.reason === 'ollama_parse_error') {
106
- issues.push(`Ollama warm call failed: ${warmResult.reason}`);
120
+ judgeShadowAdvisory(warnings, `Ollama warm call failed: ${warmResult.reason}`);
107
121
  }
108
122
  else {
109
123
  notes.push('Ollama warm call succeeded.');
@@ -111,24 +125,29 @@ export async function diagnoseJudge(config, repoRoot = process.cwd(), options =
111
125
  return { issues, warnings, notes, modelCheck };
112
126
  }
113
127
  if (transport === 'unavailable') {
114
- issues.push('No judge transport is available (configure endpoint or install native CLI). Tier1 will fail closed to ask.');
128
+ judgeShadowAdvisory(warnings, 'No judge transport is available (configure endpoint or install native CLI). Shadow judge will not run.');
115
129
  return { issues, warnings, notes, modelCheck };
116
130
  }
117
131
  if (transport.endsWith('-cli')) {
118
132
  if (!runtime.cliTransport) {
119
- issues.push(`Native CLI transport (${transport}) is not available. Tier1 judge will fail closed to ask.`);
133
+ judgeShadowAdvisory(warnings, `Native CLI transport (${transport}) is not available. Shadow judge will not run.`);
120
134
  }
121
135
  else if (!keyInfo.key && keyInfo.sourceKind !== 'host-session') {
122
- issues.push('Judge API key is not set for the configured credential mode. Tier1 cloud judge will fail closed to ask.');
136
+ judgeShadowAdvisory(warnings, 'Judge API key is not set for the configured credential mode. Shadow cloud judge will not run.');
123
137
  }
124
138
  else {
125
- notes.push(`Native CLI transport available: ${transport}`);
139
+ notes.push(cursorAcpEnabled
140
+ ? 'Cursor ACP transport available.'
141
+ : `Native CLI transport available: ${transport}`);
142
+ if (cursorAcpEnabled) {
143
+ notes.push(`Cursor ACP session transport enabled (max ${config.judge.runtime?.session.maxTurns ?? 'unknown'} turns).`);
144
+ }
126
145
  if (shouldRunLiveProbe) {
127
146
  const smokeConfig = {
128
147
  ...config,
129
148
  judge: {
130
149
  ...judge,
131
- timeoutMs: Math.min(judge.timeoutMs, 5000),
150
+ timeoutMs: resolveJudgeSmokeProbeTimeoutMs(judge.timeoutMs),
132
151
  },
133
152
  };
134
153
  const smokeJudge = createJudgeFromConfig(smokeConfig, { repoRoot });
@@ -140,10 +159,16 @@ export async function diagnoseJudge(config, repoRoot = process.cwd(), options =
140
159
  smokeJudge.lastTrace?.fallbackReason ??
141
160
  smokeResult.reason;
142
161
  if (smokeJudge.lastTrace?.provider === 'fallback') {
143
- issues.push(`Native CLI transport smoke probe failed (${smokeFallback}). Tier1 will fail closed to ask.`);
162
+ const recoveryHint = formatJudgeRecoveryHint(inferProviderIdFromFallbackReason(smokeFallback, providerId), smokeFallback);
163
+ const smokeLabel = cursorAcpEnabled ? 'Cursor ACP' : 'Native CLI transport';
164
+ judgeShadowAdvisory(warnings, recoveryHint
165
+ ? `${smokeLabel} smoke probe failed (${smokeFallback}). ${recoveryHint}`
166
+ : `${smokeLabel} smoke probe failed (${smokeFallback}).`);
144
167
  }
145
168
  else {
146
- notes.push('Native CLI transport smoke probe succeeded.');
169
+ notes.push(cursorAcpEnabled
170
+ ? 'Cursor ACP smoke probe succeeded.'
171
+ : 'Native CLI transport smoke probe succeeded.');
147
172
  }
148
173
  }
149
174
  }
@@ -153,7 +178,7 @@ export async function diagnoseJudge(config, repoRoot = process.cwd(), options =
153
178
  notes.push(`HTTP endpoint: ${judge.endpoint}`);
154
179
  }
155
180
  if (!keyInfo.key) {
156
- issues.push('Judge API key is not set for the configured credential mode. Tier1 cloud judge will fail closed to ask.');
181
+ judgeShadowAdvisory(warnings, 'Judge API key is not set for the configured credential mode. Shadow cloud judge will not run.');
157
182
  }
158
183
  else {
159
184
  notes.push(`Credential source: ${keyInfo.source ?? keyInfo.sourceKind}`);
@@ -188,7 +213,7 @@ export async function diagnoseJudge(config, repoRoot = process.cwd(), options =
188
213
  });
189
214
  if (dryRun.reason.startsWith('openai_compatible_') ||
190
215
  dryRun.reason === 'outbound_scrub_failed') {
191
- issues.push(`HTTP judge dry-run failed: ${dryRun.reason}`);
216
+ judgeShadowAdvisory(warnings, `HTTP judge dry-run failed: ${dryRun.reason}`);
192
217
  }
193
218
  else {
194
219
  notes.push('HTTP judge dry-run succeeded.');
@@ -0,0 +1,13 @@
1
+ import type { ClassifyResult } from './types.js';
2
+ export declare function extractJudgeFallbackReason(result: Pick<ClassifyResult, 'axes' | 'assessment'>): string | undefined;
3
+ export declare function isJudgeInfrastructureFailure(result: Pick<ClassifyResult, 'axes' | 'assessment' | 'reason'>): boolean;
4
+ export declare function inferProviderIdFromFallbackReason(fallbackReason: string | undefined, fallbackProviderId?: string): string;
5
+ export declare function formatJudgeRecoveryHint(providerId: string, fallbackReason?: string): string | null;
6
+ export declare function formatJudgeInfrastructureDenyMessage(params: {
7
+ providerId: string;
8
+ fallbackReason?: string;
9
+ command?: string;
10
+ }): {
11
+ user_message: string;
12
+ agent_message: string;
13
+ };
@@ -0,0 +1,108 @@
1
+ const INFRASTRUCTURE_FALLBACK_SUFFIXES = [
2
+ '_cli_nonzero',
3
+ '_cli_unavailable',
4
+ '_cli_parse_error',
5
+ '_cli_spawn_error',
6
+ ];
7
+ const INFRASTRUCTURE_FALLBACK_REASONS = new Set([
8
+ 'eval_timeout',
9
+ 'connect_timeout',
10
+ 'parse_timeout',
11
+ 'session_unavailable',
12
+ 'session_disabled',
13
+ 'provider_not_allowlisted',
14
+ 'guard_reset',
15
+ 'kill_switch',
16
+ 'non_json_response',
17
+ 'parse_error',
18
+ 'cli_error',
19
+ 'timeout',
20
+ 'version_mismatch',
21
+ 'shadow_forced_spawn',
22
+ 'unsafe_option_rejected',
23
+ 'broker_busy_reset',
24
+ ]);
25
+ function isInfrastructureFallbackReason(reason) {
26
+ if (INFRASTRUCTURE_FALLBACK_REASONS.has(reason)) {
27
+ return true;
28
+ }
29
+ return INFRASTRUCTURE_FALLBACK_SUFFIXES.some((suffix) => reason.endsWith(suffix));
30
+ }
31
+ function infrastructureSignal(signal) {
32
+ return isInfrastructureFallbackReason(signal);
33
+ }
34
+ export function extractJudgeFallbackReason(result) {
35
+ if (result.axes?.judgeFallbackReason) {
36
+ return result.axes.judgeFallbackReason;
37
+ }
38
+ return result.assessment.signals.find((signal) => isInfrastructureFallbackReason(signal));
39
+ }
40
+ export function isJudgeInfrastructureFailure(result) {
41
+ if (result.axes?.judgeProvider === 'fallback') {
42
+ return true;
43
+ }
44
+ const fallbackReason = extractJudgeFallbackReason(result);
45
+ if (fallbackReason && isInfrastructureFallbackReason(fallbackReason)) {
46
+ return true;
47
+ }
48
+ return result.assessment.signals.some(infrastructureSignal);
49
+ }
50
+ export function inferProviderIdFromFallbackReason(fallbackReason, fallbackProviderId = 'cursor') {
51
+ if (!fallbackReason) {
52
+ return fallbackProviderId;
53
+ }
54
+ const match = fallbackReason.match(/^(cursor|claude|codex)_/);
55
+ return match?.[1] ?? fallbackProviderId;
56
+ }
57
+ export function formatJudgeRecoveryHint(providerId, fallbackReason) {
58
+ if (!fallbackReason) {
59
+ return null;
60
+ }
61
+ if (fallbackReason.endsWith('_cli_nonzero') || fallbackReason.endsWith('_cli_unavailable')) {
62
+ switch (providerId) {
63
+ case 'cursor':
64
+ return 'Judge CLI is not authenticated. Run: agent login';
65
+ case 'claude':
66
+ return 'Claude CLI failed. Run: claude auth login';
67
+ case 'codex':
68
+ return 'Codex CLI failed. Check codex auth';
69
+ default:
70
+ return `Judge CLI transport failed (${fallbackReason}). Check ${providerId} CLI auth`;
71
+ }
72
+ }
73
+ if (fallbackReason.endsWith('_cli_parse_error')) {
74
+ return `Judge CLI returned an invalid response (${fallbackReason}). Retry after updating the ${providerId} CLI`;
75
+ }
76
+ if (fallbackReason.endsWith('_cli_spawn_error')) {
77
+ return `Judge CLI could not be started (${fallbackReason}). Install or repair the ${providerId} CLI`;
78
+ }
79
+ switch (fallbackReason) {
80
+ case 'eval_timeout':
81
+ case 'connect_timeout':
82
+ case 'parse_timeout':
83
+ case 'timeout':
84
+ return 'Judge timed out. Check network or increase judge.timeoutMs';
85
+ case 'session_unavailable':
86
+ case 'session_disabled':
87
+ return 'Judge session transport is unavailable. Run: belay doctor';
88
+ case 'kill_switch':
89
+ return 'Judge session kill switch is active. Run: belay judge status';
90
+ default:
91
+ if (isInfrastructureFallbackReason(fallbackReason)) {
92
+ return `Judge transport failed (${fallbackReason}). Run: belay doctor`;
93
+ }
94
+ return null;
95
+ }
96
+ }
97
+ export function formatJudgeInfrastructureDenyMessage(params) {
98
+ const fallbackReason = params.fallbackReason ?? 'judge_transport_unavailable';
99
+ const recoveryHint = formatJudgeRecoveryHint(params.providerId, fallbackReason) ??
100
+ 'Judge transport is unavailable. Run: belay doctor';
101
+ const commandHint = params.command
102
+ ? ` For details: belay explain --command "${params.command}"`
103
+ : ' For details: belay explain';
104
+ return {
105
+ user_message: `Belay could not reach the Tier1 judge (${fallbackReason}). ${recoveryHint}. Then retry.${commandHint}`,
106
+ agent_message: `Belay denied this action because the Tier1 judge transport failed (${fallbackReason}). Repair judge infrastructure, then retry the exact same action.`,
107
+ };
108
+ }
@@ -5,6 +5,7 @@
5
5
  * fs-scope allowlist matching).
6
6
  */
7
7
  export declare function canonicalPath(targetPath: string): string;
8
+ export declare function isPathOutsideRoot(relativePath: string): boolean;
8
9
  export declare function pathWithinRoot(root: string, targetPath: string): boolean;
9
10
  export declare function relativeWithinRepo(repoRoot: string, targetPath: string): string | null;
10
11
  export type WorkspaceRootMatch = {
@@ -36,6 +36,9 @@ export function canonicalPath(targetPath) {
36
36
  }
37
37
  return current;
38
38
  }
39
+ export function isPathOutsideRoot(relativePath) {
40
+ return relativePath === '..' || relativePath.startsWith(`..${path.sep}`);
41
+ }
39
42
  export function pathWithinRoot(root, targetPath) {
40
43
  const resolvedRoot = canonicalPath(root);
41
44
  const resolvedTarget = canonicalPath(targetPath);
@@ -43,7 +46,7 @@ export function pathWithinRoot(root, targetPath) {
43
46
  if (relativePath === '') {
44
47
  return true;
45
48
  }
46
- return !relativePath.startsWith('..') && !path.isAbsolute(relativePath);
49
+ return !isPathOutsideRoot(relativePath) && !path.isAbsolute(relativePath);
47
50
  }
48
51
  function relativeWithinRoot(root, targetPath, options = {}) {
49
52
  const resolvedRoot = options.canonicalizeRoot === false ? path.resolve(root) : canonicalPath(root);
@@ -52,7 +55,7 @@ function relativeWithinRoot(root, targetPath, options = {}) {
52
55
  if (relativePath === '') {
53
56
  return '.';
54
57
  }
55
- if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
58
+ if (isPathOutsideRoot(relativePath) || path.isAbsolute(relativePath)) {
56
59
  return null;
57
60
  }
58
61
  return relativePath;
@@ -0,0 +1,3 @@
1
+ import type { CapabilityRequestV1 } from '../capability/request.js';
2
+ /** Actions that cannot be closed by local git-worktree / file checkpoint recovery. */
3
+ export declare function capabilityRequestsBlockRecovery(requests: CapabilityRequestV1[] | undefined): boolean;
@@ -0,0 +1,22 @@
1
+ const NON_RECOVERABLE_ACTIONS = new Set([
2
+ 'network.connect',
3
+ 'git.ref.write',
4
+ 'control_plane.write',
5
+ 'secret.read',
6
+ 'indeterminate',
7
+ ]);
8
+ /** Actions that cannot be closed by local git-worktree / file checkpoint recovery. */
9
+ export function capabilityRequestsBlockRecovery(requests) {
10
+ if (!requests?.length) {
11
+ return false;
12
+ }
13
+ for (const request of requests) {
14
+ if (NON_RECOVERABLE_ACTIONS.has(request.action)) {
15
+ return true;
16
+ }
17
+ if (request.evidence.level === 'indeterminate') {
18
+ return true;
19
+ }
20
+ }
21
+ return false;
22
+ }
@@ -0,0 +1,6 @@
1
+ import type { ClassifyResult } from '../types.js';
2
+ export declare const RECOVERY_SUBSTRATE_UNAVAILABLE = "recovery_substrate_unavailable";
3
+ export declare const RECOVERY_EXECUTION_FAILED = "recovery_execution_failed";
4
+ export declare const RECOVERY_DIRTY_WORKTREE = "recovery_dirty_worktree";
5
+ export declare function recoveryFailReasonFromSkip(skipReason: string): string;
6
+ export declare function recoveryFailClosedResult(predicted: ClassifyResult, reason: string, signals?: string[]): ClassifyResult;
@@ -0,0 +1,25 @@
1
+ export const RECOVERY_SUBSTRATE_UNAVAILABLE = 'recovery_substrate_unavailable';
2
+ export const RECOVERY_EXECUTION_FAILED = 'recovery_execution_failed';
3
+ export const RECOVERY_DIRTY_WORKTREE = 'recovery_dirty_worktree';
4
+ export function recoveryFailReasonFromSkip(skipReason) {
5
+ if (skipReason === 'transactional_command_failed' || skipReason === 'transactional_timed_out') {
6
+ return RECOVERY_EXECUTION_FAILED;
7
+ }
8
+ if (skipReason === 'dirty_worktree') {
9
+ return RECOVERY_DIRTY_WORKTREE;
10
+ }
11
+ return RECOVERY_SUBSTRATE_UNAVAILABLE;
12
+ }
13
+ export function recoveryFailClosedResult(predicted, reason, signals = []) {
14
+ return {
15
+ ...predicted,
16
+ verdict: 'deny_pending_approval',
17
+ reason,
18
+ assessment: {
19
+ ...predicted.assessment,
20
+ reversibility: 'irreversible',
21
+ confidence: 1,
22
+ signals: [...predicted.assessment.signals, 'recovery_fail_closed', ...signals],
23
+ },
24
+ };
25
+ }
@@ -0,0 +1,3 @@
1
+ export { capabilityRequestsBlockRecovery } from './capability.js';
2
+ export { RECOVERY_DIRTY_WORKTREE, RECOVERY_EXECUTION_FAILED, RECOVERY_SUBSTRATE_UNAVAILABLE, recoveryFailClosedResult, recoveryFailReasonFromSkip, } from './fail-closed.js';
3
+ export type { RecoveryBackend, RecoveryProofContext, RecoveryProofV1 } from './types.js';
@@ -0,0 +1,2 @@
1
+ export { capabilityRequestsBlockRecovery } from './capability.js';
2
+ export { RECOVERY_DIRTY_WORKTREE, RECOVERY_EXECUTION_FAILED, RECOVERY_SUBSTRATE_UNAVAILABLE, recoveryFailClosedResult, recoveryFailReasonFromSkip, } from './fail-closed.js';
@@ -0,0 +1,20 @@
1
+ import type { CapabilityRequestV1 } from '../capability/request.js';
2
+ /** Backends that can mint a verifiable RecoveryProof (ADR: proof-only execution). */
3
+ export type RecoveryBackend = 'git_worktree' | 'file_checkpoint';
4
+ export interface RecoveryProofV1 {
5
+ version: 1;
6
+ backend: RecoveryBackend;
7
+ inputFingerprint: string;
8
+ resourceScope: string;
9
+ baseStateHash: string;
10
+ effectClosure: 'repo_local_fs';
11
+ issuedAt: string;
12
+ expiresAt: string;
13
+ probeSignals: string[];
14
+ }
15
+ export interface RecoveryProofContext {
16
+ repoRoot: string;
17
+ cwd: string;
18
+ command: string;
19
+ requests: CapabilityRequestV1[];
20
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -18,6 +18,9 @@ const STANDING_ALLOW_BLOCKED_REASONS = new Set([
18
18
  'protected_artifact',
19
19
  'pipe_to_shell',
20
20
  'command_substitution',
21
+ 'outside_repo_mutation',
22
+ 'outside_repo_redirect',
23
+ 'control_plane_mutation',
21
24
  ]);
22
25
  export function standingAllowFile(config, repoLocalStateDir) {
23
26
  return `${belayStateDir(config, repoLocalStateDir)}/standing-allow.json`;
@@ -51,7 +51,10 @@ export function evaluateTransactionalDiff(changes, ctx) {
51
51
  categories.add('large_deletion');
52
52
  }
53
53
  const categoryList = [...categories];
54
- const dangerous = categoryList.includes('large_deletion');
54
+ const dangerous = categoryList.includes('large_deletion') ||
55
+ categoryList.includes('repo_outside') ||
56
+ categoryList.includes('sensitive_path') ||
57
+ categoryList.includes('control_plane');
55
58
  const base = {
56
59
  categories: categoryList,
57
60
  changes,
@@ -1,3 +1,4 @@
1
+ import { capabilityRequestsBlockRecovery } from '../recovery/capability.js';
1
2
  const EXCLUDED_REASONS = new Set([
2
3
  'unparseable_shell',
3
4
  'external_effect',
@@ -26,6 +27,9 @@ export function isTransactionalEligible(config, kind, result) {
26
27
  if (EXCLUDED_REASONS.has(result.reason)) {
27
28
  return false;
28
29
  }
30
+ if (capabilityRequestsBlockRecovery(result.capabilityRequests)) {
31
+ return false;
32
+ }
29
33
  const { assessment } = result;
30
34
  if (assessment.external) {
31
35
  return false;
@@ -1,7 +1,12 @@
1
1
  import type { TransactionalFileChange, TransactionalSnapshot } from './types.js';
2
+ export declare const TRANSACTIONAL_APPLY_TOCTOU = "transactional_apply_toctou";
3
+ export declare function hashRepoFile(filePath: string): Promise<string>;
4
+ export declare function captureRepoFileHashes(repoRoot: string, changes: TransactionalFileChange[]): Promise<Map<string, string | null>>;
2
5
  export declare function isGitWorktreeAvailable(repoRoot: string): Promise<boolean>;
3
- export declare function isDirtyWorktree(repoRoot: string): Promise<boolean>;
4
- export declare function createGitWorktreeSnapshot(repoRoot: string, stateDir: string): Promise<TransactionalSnapshot>;
6
+ export declare function isDirtyWorktree(repoRoot: string, options?: {
7
+ ignoreRoots?: string[];
8
+ }): Promise<boolean>;
9
+ export declare function createGitWorktreeSnapshot(repoRoot: string, _stateDir: string): Promise<TransactionalSnapshot>;
5
10
  export declare function resolveWorktreeCwd(repoRoot: string, worktreePath: string, cwd: string): string;
6
11
  export interface ShellRunResult {
7
12
  exitCode: number | null;
@@ -10,4 +15,6 @@ export interface ShellRunResult {
10
15
  }
11
16
  export declare function runShellCommand(command: string, cwd: string, timeoutMs: number): Promise<ShellRunResult>;
12
17
  export declare function collectWorktreeChanges(worktreePath: string): Promise<TransactionalFileChange[]>;
13
- export declare function applyWorktreeChanges(worktreePath: string, repoRoot: string, changes: TransactionalFileChange[]): Promise<void>;
18
+ export declare function applyWorktreeChanges(worktreePath: string, repoRoot: string, changes: TransactionalFileChange[], options?: {
19
+ baseHashes?: Map<string, string | null>;
20
+ }): Promise<void>;
@@ -1,10 +1,11 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { randomUUID } from 'node:crypto';
2
+ import { createHash } from 'node:crypto';
3
3
  import { existsSync } from 'node:fs';
4
- import { copyFile, mkdir, mkdtemp, rm } from 'node:fs/promises';
4
+ import { copyFile, mkdir, mkdtemp, readFile, rm } from 'node:fs/promises';
5
5
  import os from 'node:os';
6
6
  import path from 'node:path';
7
- import { canonicalPath } from '../path-utils.js';
7
+ import { canonicalPath, isPathOutsideRoot, pathWithinRoot } from '../path-utils.js';
8
+ export const TRANSACTIONAL_APPLY_TOCTOU = 'transactional_apply_toctou';
8
9
  function execGit(repoRoot, args) {
9
10
  return new Promise((resolve, reject) => {
10
11
  const child = spawn('git', ['-C', repoRoot, ...args], {
@@ -24,6 +25,38 @@ function execGit(repoRoot, args) {
24
25
  });
25
26
  });
26
27
  }
28
+ function porcelainRelativePath(line) {
29
+ if (line.length < 4) {
30
+ return null;
31
+ }
32
+ const relativePath = line.slice(3).trim();
33
+ return relativePath || null;
34
+ }
35
+ function isIgnoredDirtyPath(repoRoot, relativePath, ignoreRoots) {
36
+ if (ignoreRoots.length === 0) {
37
+ return false;
38
+ }
39
+ const absolutePath = canonicalPath(path.join(repoRoot, relativePath));
40
+ return ignoreRoots.some((root) => pathWithinRoot(root, absolutePath) ||
41
+ root === absolutePath ||
42
+ pathWithinRoot(absolutePath, root));
43
+ }
44
+ export async function hashRepoFile(filePath) {
45
+ const content = await readFile(filePath);
46
+ return createHash('sha256').update(content).digest('hex');
47
+ }
48
+ export async function captureRepoFileHashes(repoRoot, changes) {
49
+ const hashes = new Map();
50
+ for (const change of changes) {
51
+ const target = path.join(repoRoot, change.relativePath);
52
+ if (!existsSync(target)) {
53
+ hashes.set(change.relativePath, null);
54
+ continue;
55
+ }
56
+ hashes.set(change.relativePath, await hashRepoFile(target));
57
+ }
58
+ return hashes;
59
+ }
27
60
  export async function isGitWorktreeAvailable(repoRoot) {
28
61
  try {
29
62
  await execGit(repoRoot, ['rev-parse', '--git-dir']);
@@ -33,18 +66,32 @@ export async function isGitWorktreeAvailable(repoRoot) {
33
66
  return false;
34
67
  }
35
68
  }
36
- export async function isDirtyWorktree(repoRoot) {
69
+ export async function isDirtyWorktree(repoRoot, options) {
37
70
  try {
38
- const status = await execGit(repoRoot, ['status', '--porcelain', '--untracked-files=no']);
39
- return status.trim().length > 0;
71
+ const status = await execGit(repoRoot, ['status', '--porcelain']);
72
+ const ignoreRoots = (options?.ignoreRoots ?? []).map((root) => canonicalPath(root));
73
+ const repoRootCanonical = canonicalPath(repoRoot);
74
+ for (const line of status.split('\n')) {
75
+ if (!line.trim()) {
76
+ continue;
77
+ }
78
+ const relativePath = porcelainRelativePath(line);
79
+ if (!relativePath) {
80
+ continue;
81
+ }
82
+ if (isIgnoredDirtyPath(repoRootCanonical, relativePath, ignoreRoots)) {
83
+ continue;
84
+ }
85
+ return true;
86
+ }
87
+ return false;
40
88
  }
41
89
  catch {
42
90
  return true;
43
91
  }
44
92
  }
45
- export async function createGitWorktreeSnapshot(repoRoot, stateDir) {
46
- const worktreePath = path.join(stateDir, `tx-${randomUUID().replaceAll('-', '')}`);
47
- await mkdir(stateDir, { recursive: true });
93
+ export async function createGitWorktreeSnapshot(repoRoot, _stateDir) {
94
+ const worktreePath = await mkdtemp(path.join(os.tmpdir(), 'belay-tx-'));
48
95
  await execGit(repoRoot, ['worktree', 'add', '--detach', worktreePath, 'HEAD']);
49
96
  return {
50
97
  worktreePath,
@@ -67,7 +114,7 @@ export async function createGitWorktreeSnapshot(repoRoot, stateDir) {
67
114
  export function resolveWorktreeCwd(repoRoot, worktreePath, cwd) {
68
115
  const resolvedCwd = canonicalPath(cwd);
69
116
  const relative = path.relative(canonicalPath(repoRoot), resolvedCwd);
70
- if (relative.startsWith('..') || path.isAbsolute(relative)) {
117
+ if (isPathOutsideRoot(relative) || path.isAbsolute(relative)) {
71
118
  return worktreePath;
72
119
  }
73
120
  if (relative === '') {
@@ -155,7 +202,30 @@ async function rollbackAppliedChanges(actions) {
155
202
  }
156
203
  }
157
204
  }
158
- export async function applyWorktreeChanges(worktreePath, repoRoot, changes) {
205
+ async function assertRepoFilesUnchanged(repoRoot, changes, baseHashes) {
206
+ for (const change of changes) {
207
+ const target = path.join(repoRoot, change.relativePath);
208
+ const expected = baseHashes.get(change.relativePath);
209
+ const exists = existsSync(target);
210
+ if (expected === null) {
211
+ if (exists) {
212
+ throw new Error(TRANSACTIONAL_APPLY_TOCTOU);
213
+ }
214
+ continue;
215
+ }
216
+ if (!exists) {
217
+ throw new Error(TRANSACTIONAL_APPLY_TOCTOU);
218
+ }
219
+ const current = await hashRepoFile(target);
220
+ if (current !== expected) {
221
+ throw new Error(TRANSACTIONAL_APPLY_TOCTOU);
222
+ }
223
+ }
224
+ }
225
+ export async function applyWorktreeChanges(worktreePath, repoRoot, changes, options) {
226
+ if (options?.baseHashes) {
227
+ await assertRepoFilesUnchanged(repoRoot, changes, options.baseHashes);
228
+ }
159
229
  const backupRoot = await mkdtemp(path.join(os.tmpdir(), 'belay-tx-rollback-'));
160
230
  const rollbackActions = [];
161
231
  try {
@@ -1,4 +1,5 @@
1
1
  export declare const TRANSACTIONAL_ALREADY_APPLIED = "transactional_already_applied";
2
2
  export declare const TRANSACTIONAL_OBSERVED_RISK = "transactional_observed_risk";
3
3
  export declare const TRANSACTIONAL_APPLY_FAILED = "transactional_apply_failed";
4
+ export { RECOVERY_DIRTY_WORKTREE, RECOVERY_EXECUTION_FAILED, RECOVERY_SUBSTRATE_UNAVAILABLE, } from '../recovery/fail-closed.js';
4
5
  export declare const TRANSACTIONAL_APPROVAL_BYPASS_REASONS: Set<string>;
@@ -1,8 +1,12 @@
1
1
  export const TRANSACTIONAL_ALREADY_APPLIED = 'transactional_already_applied';
2
2
  export const TRANSACTIONAL_OBSERVED_RISK = 'transactional_observed_risk';
3
3
  export const TRANSACTIONAL_APPLY_FAILED = 'transactional_apply_failed';
4
+ export { RECOVERY_DIRTY_WORKTREE, RECOVERY_EXECUTION_FAILED, RECOVERY_SUBSTRATE_UNAVAILABLE, } from '../recovery/fail-closed.js';
4
5
  export const TRANSACTIONAL_APPROVAL_BYPASS_REASONS = new Set([
5
6
  TRANSACTIONAL_OBSERVED_RISK,
6
7
  TRANSACTIONAL_ALREADY_APPLIED,
7
8
  TRANSACTIONAL_APPLY_FAILED,
9
+ 'recovery_substrate_unavailable',
10
+ 'recovery_execution_failed',
11
+ 'recovery_dirty_worktree',
8
12
  ]);