@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
package/dist/cli.js CHANGED
@@ -6,11 +6,14 @@ import { auditProject, formatAuditReport } from './commands/audit.js';
6
6
  import { doctorProject, formatDoctorReport } from './commands/doctor.js';
7
7
  import { dogfoodProject, formatDogfoodResult } from './commands/dogfood.js';
8
8
  import { explainCommand, formatExplainReport } from './commands/explain.js';
9
+ import { formatHarvestReport, harvestApplyProject, harvestListProject } from './commands/harvest.js';
9
10
  import { formatMetricsReport, metricsProject } from './commands/metrics.js';
11
+ import { formatQualityReport, qualityCheck } from './commands/quality.js';
10
12
  import { formatRecoverReport, recoverProject } from './commands/recover.js';
11
13
  import { formatReport, reportProject } from './commands/report.js';
12
14
  import { revokeApproval } from './commands/revoke.js';
13
15
  import { formatSimulateReport, simulateProject } from './commands/simulate.js';
16
+ import { revokeStandingAllow } from './commands/standing-allow.js';
14
17
  import { formatStatusReport, statusProject } from './commands/status.js';
15
18
  import { loadConfigFile } from './config-io.js';
16
19
  import { rejectDeprecatedJudgeModelAuto } from './core/judge-model-policy.js';
@@ -154,6 +157,13 @@ function parseArgs(argv) {
154
157
  index += 1;
155
158
  continue;
156
159
  }
160
+ if (token === '--live-probe') {
161
+ if (command !== 'judge') {
162
+ throw new Error('--live-probe is only valid for judge test.');
163
+ }
164
+ options.judgeLiveProbe = true;
165
+ continue;
166
+ }
157
167
  if (token === '--json') {
158
168
  options.json = true;
159
169
  continue;
@@ -178,6 +188,24 @@ function parseArgs(argv) {
178
188
  index += 1;
179
189
  continue;
180
190
  }
191
+ if (token === '--outcome') {
192
+ const next = rest[index + 1];
193
+ if (!next || !['provably-benign', 'accepted-benign', 'reject'].includes(next)) {
194
+ throw new Error('--outcome requires provably-benign, accepted-benign, or reject.');
195
+ }
196
+ options.harvestOutcome = next;
197
+ index += 1;
198
+ continue;
199
+ }
200
+ if (token === '--corpus') {
201
+ const next = rest[index + 1];
202
+ if (!next) {
203
+ throw new Error('--corpus requires a path.');
204
+ }
205
+ options.corpusPath = next;
206
+ index += 1;
207
+ continue;
208
+ }
181
209
  if (token === '--kind') {
182
210
  const next = rest[index + 1];
183
211
  if (!next) {
@@ -252,11 +280,18 @@ function parseArgs(argv) {
252
280
  index += 1;
253
281
  continue;
254
282
  }
283
+ if (token === '--replay') {
284
+ if (command !== 'approve') {
285
+ throw new Error('--replay is only valid for approve.');
286
+ }
287
+ options.approveReplay = true;
288
+ continue;
289
+ }
255
290
  if (token === '--scope') {
256
291
  const next = rest[index + 1];
257
292
  if (command === 'approve') {
258
- if (!next || !['once', 'domain', 'path'].includes(next)) {
259
- throw new Error('--scope requires once, domain, or path.');
293
+ if (!next || !['once', 'domain', 'path', 'workspace-root'].includes(next)) {
294
+ throw new Error('--scope requires once, domain, path, or workspace-root.');
260
295
  }
261
296
  options.approveScope = next;
262
297
  }
@@ -315,6 +350,9 @@ function parseArgs(argv) {
315
350
  if (command === 'recover') {
316
351
  options.recoverCommand = next;
317
352
  }
353
+ else if (command === 'harvest') {
354
+ options.harvestCommand = next;
355
+ }
318
356
  else {
319
357
  options.explainCommand = next;
320
358
  }
@@ -367,16 +405,31 @@ function parseArgs(argv) {
367
405
  }
368
406
  throw new Error('sandbox requires subcommand: status');
369
407
  }
408
+ if (command === 'standing-allow' && !options.standingAllowSubcommand) {
409
+ if (token === 'revoke') {
410
+ options.standingAllowSubcommand = token;
411
+ continue;
412
+ }
413
+ throw new Error('standing-allow requires subcommand: revoke');
414
+ }
415
+ if (command === 'harvest' && !options.harvestSubcommand) {
416
+ if (token === 'list' || token === 'apply') {
417
+ options.harvestSubcommand = token;
418
+ continue;
419
+ }
420
+ throw new Error('harvest requires subcommand: list or apply');
421
+ }
370
422
  if (command === 'judge' && !options.judgeSubcommand) {
371
423
  if (token === 'status' ||
372
424
  token === 'list' ||
373
425
  token === 'use' ||
374
426
  token === 'test' ||
427
+ token === 'bench' ||
375
428
  token === 'consent') {
376
429
  options.judgeSubcommand = token;
377
430
  continue;
378
431
  }
379
- throw new Error('judge requires subcommand: status, list, use, test, or consent');
432
+ throw new Error('judge requires subcommand: status, list, use, test, bench, or consent');
380
433
  }
381
434
  if (command === 'config' && !options.configSubcommand) {
382
435
  if (token === 'list' ||
@@ -458,6 +511,7 @@ Usage:
458
511
  ${c} dogfood [--target <dir>] [--adapter cursor|claude|codex] [--enforce] [--force]
459
512
  ${c} doctor [--target <dir>] [--adapter cursor|claude|codex] [--json] [--fix] [--dry-run]
460
513
  ${c} metrics [--target <dir>] [--json]
514
+ ${c} quality [--target <dir>] [--corpus <path>] [--json]
461
515
  ${c} report [--target <dir>] [--since <iso>] [--until <iso>] [--limit <n>] [--json]
462
516
  ${c} recover [--target <dir>] [--since <iso>] [--fingerprint <fp>] [--command "<text>"] [--limit <n>] [--json]
463
517
  (--limit picks the Nth recover candidate after priority ranking: local_mutation first, then recency; 1 = highest priority, default 1)
@@ -467,11 +521,15 @@ Usage:
467
521
  ${c} explain [--target <dir>] [--cwd <dir>] [--kind shell|tool|subagent] [--tool <name>] [--payload-json <json>] [--command <text>] [--json] [-- <command>]
468
522
  ${c} egress <start|stop|status|env> [--target <dir>] [--json]
469
523
  ${c} sandbox status [--target <dir>] [--json]
470
- ${c} judge <status|list|use|test|consent> [--target <dir>] [--json]
524
+ ${c} judge <status|list|use|test|bench|consent> [--target <dir>] [--json]
525
+ ${c} judge test [--target <dir>] [--json] [--live-probe]
471
526
  ${c} judge use <ollama|codex|claude|cursor> [--model <id>] [--endpoint <url>] [--timeout <ms>] [--accept-cloud] [--cloud-consent-approval-id <id>] [--credential project|apiKey] [--key-stdin] [--key-env <NAME>]
472
527
  ${c} judge consent <ollama|codex|claude|cursor> [--endpoint <url>]
473
- ${c} approve <approval-id> [--scope once|domain|path] [--path <path>] [--token <signed-token>] [--target <dir>]
528
+ ${c} approve <approval-id> [--replay] [--scope once|domain|path|workspace-root] [--path <path>] [--token <signed-token>] [--target <dir>]
474
529
  ${c} revoke <approval-id> [--target <dir>]
530
+ ${c} standing-allow revoke --fingerprint <fp> [--kind shell|tool|subagent] [--target <dir>]
531
+ ${c} harvest list [--target <dir>] [--since <iso>] [--until <iso>] [--json]
532
+ ${c} harvest apply --command "<text>" --outcome provably-benign|accepted-benign|reject [--reason <r>] [--corpus <path>] [--target <dir>]
475
533
  `);
476
534
  }
477
535
  async function main() {
@@ -545,6 +603,9 @@ async function main() {
545
603
  if (!options.judgeSubcommand) {
546
604
  throw new Error('judge requires subcommand: status, list, use, or test');
547
605
  }
606
+ if (options.judgeLiveProbe && options.judgeSubcommand !== 'test') {
607
+ throw new Error('--live-probe is only valid for judge test.');
608
+ }
548
609
  const result = await runJudgeCommand({
549
610
  targetDir: options.targetDir,
550
611
  json: options.json,
@@ -558,6 +619,7 @@ async function main() {
558
619
  credentialMode: options.credentialMode,
559
620
  keyStdin: options.keyStdin,
560
621
  keyEnv: options.keyEnv,
622
+ liveProbe: options.judgeLiveProbe,
561
623
  });
562
624
  if (options.json && typeof result === 'object') {
563
625
  process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
@@ -679,6 +741,21 @@ async function main() {
679
741
  }
680
742
  return;
681
743
  }
744
+ if (command === 'quality') {
745
+ const report = await qualityCheck({
746
+ targetDir: options.targetDir,
747
+ corpusDir: options.corpusPath,
748
+ json: options.json,
749
+ });
750
+ if (options.json) {
751
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
752
+ }
753
+ else {
754
+ process.stdout.write(`${formatQualityReport(report)}\n`);
755
+ }
756
+ process.exitCode = report.ok ? 0 : 1;
757
+ return;
758
+ }
682
759
  if (command === 'report') {
683
760
  const report = await reportProject({
684
761
  targetDir: options.targetDir,
@@ -695,6 +772,42 @@ async function main() {
695
772
  }
696
773
  return;
697
774
  }
775
+ if (command === 'harvest') {
776
+ if (options.harvestSubcommand === 'list') {
777
+ const report = await harvestListProject({
778
+ targetDir: options.targetDir,
779
+ since: options.since,
780
+ until: options.until,
781
+ json: options.json,
782
+ });
783
+ if (options.json) {
784
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
785
+ }
786
+ else {
787
+ process.stdout.write(`${formatHarvestReport(report)}\n`);
788
+ }
789
+ return;
790
+ }
791
+ if (options.harvestSubcommand === 'apply') {
792
+ if (!options.harvestCommand) {
793
+ throw new Error('harvest apply requires --command.');
794
+ }
795
+ if (!options.harvestOutcome) {
796
+ throw new Error('harvest apply requires --outcome.');
797
+ }
798
+ const result = await harvestApplyProject({
799
+ targetDir: options.targetDir,
800
+ command: options.harvestCommand,
801
+ outcome: options.harvestOutcome,
802
+ reason: options.reason,
803
+ corpusPath: options.corpusPath,
804
+ });
805
+ process.stdout.write(`${result.message}\n`);
806
+ process.exitCode = result.ok ? 0 : 1;
807
+ return;
808
+ }
809
+ throw new Error('harvest requires subcommand: list or apply');
810
+ }
698
811
  if (command === 'recover') {
699
812
  const report = await recoverProject({
700
813
  targetDir: options.targetDir,
@@ -804,6 +917,7 @@ async function main() {
804
917
  token: options.approvalToken,
805
918
  scope: options.approveScope,
806
919
  scopePath: options.approvePath,
920
+ replay: options.approveReplay,
807
921
  });
808
922
  process.stdout.write(`${result.message}\n`);
809
923
  process.exitCode = result.ok ? 0 : 1;
@@ -821,6 +935,24 @@ async function main() {
821
935
  process.exitCode = result.ok ? 0 : 1;
822
936
  return;
823
937
  }
938
+ if (command === 'standing-allow') {
939
+ if (options.standingAllowSubcommand === 'revoke') {
940
+ if (!options.fingerprint) {
941
+ throw new Error('standing-allow revoke requires --fingerprint.');
942
+ }
943
+ const result = await revokeStandingAllow({
944
+ targetDir: options.targetDir,
945
+ fingerprint: options.fingerprint,
946
+ kind: options.kind === 'tool' || options.kind === 'subagent' || options.kind === 'shell'
947
+ ? options.kind
948
+ : undefined,
949
+ });
950
+ process.stdout.write(`${result.message}\n`);
951
+ process.exitCode = result.ok ? 0 : 1;
952
+ return;
953
+ }
954
+ throw new Error('standing-allow requires subcommand: revoke');
955
+ }
824
956
  throw new Error(`Unknown command: ${command}`);
825
957
  }
826
958
  catch (error) {
@@ -7,6 +7,7 @@ export interface ApproveOptions {
7
7
  token?: string;
8
8
  scope?: ApproveScope;
9
9
  scopePath?: string;
10
+ replay?: boolean;
10
11
  }
11
12
  export declare function approvePending(options: ApproveOptions): Promise<{
12
13
  ok: boolean;
@@ -1,6 +1,7 @@
1
1
  import path from 'node:path';
2
- import { approvedApprovalsPath, loadApprovalState, loadConfigFile, pendingApprovalsPath, saveApprovalState, } from '../config-io.js';
3
- import { recordApproval } from '../core/approval-service.js';
2
+ import { loadApprovalState, loadConfigFile } from '../config-io.js';
3
+ import { canAutoReplay, getExecutionLeaseMs, replayShellCommand } from '../core/approval-replay.js';
4
+ import { consumeApprovedAfterCliReplay, createGateApprovalStore, recordApproval, } from '../core/approval-service.js';
4
5
  import { JUDGE_CLOUD_CONSENT_REASON } from '../core/capability/reasons.js';
5
6
  import { recordCapabilityApproval } from '../core/capability-approval.js';
6
7
  import { recordEgressApproval } from '../core/egress-approval.js';
@@ -32,11 +33,11 @@ export async function approvePending(options) {
32
33
  });
33
34
  return { ok: result.ok, message: result.message };
34
35
  }
35
- if (options.scope === 'path') {
36
+ if (options.scope === 'path' || options.scope === 'workspace-root') {
36
37
  const result = await recordCapabilityApproval({
37
38
  approvalId: options.approvalId,
38
39
  config,
39
- scope: 'path',
40
+ scope: options.scope,
40
41
  scopePath: options.scopePath,
41
42
  token: options.token,
42
43
  requireSignedToken: config.approvalSigning.required,
@@ -44,33 +45,51 @@ export async function approvePending(options) {
44
45
  });
45
46
  return { ok: result.ok, message: result.message };
46
47
  }
48
+ const approvalStore = createGateApprovalStore(repoRoot, config);
47
49
  const result = await recordApproval({
48
50
  approvalId: options.approvalId,
49
51
  config,
50
52
  token: options.token,
51
53
  requireSignedToken: config.approvalSigning.required,
52
- store: {
53
- async loadPending() {
54
- const filePath = pendingApprovalsPath(repoRoot, config);
55
- return {
56
- filePath,
57
- state: await loadApprovalState(repoRoot, 'pending-approvals.json', config),
58
- };
59
- },
60
- async loadApproved() {
61
- const filePath = approvedApprovalsPath(repoRoot, config);
62
- return {
63
- filePath,
64
- state: await loadApprovalState(repoRoot, 'approved-approvals.json', config),
65
- };
66
- },
67
- async writePending(_filePath, state) {
68
- await saveApprovalState(repoRoot, 'pending-approvals.json', state, config);
69
- },
70
- async writeApproved(_filePath, state) {
71
- await saveApprovalState(repoRoot, 'approved-approvals.json', state, config);
72
- },
73
- },
54
+ store: approvalStore,
74
55
  });
56
+ if (!result.ok) {
57
+ return { ok: result.ok, message: result.message };
58
+ }
59
+ if (options.replay) {
60
+ const approval = result.approval;
61
+ if (!approval) {
62
+ return { ok: false, message: 'Approval recorded but replay envelope is missing.' };
63
+ }
64
+ if (!canAutoReplay(config, approval.kind)) {
65
+ return {
66
+ ok: false,
67
+ message: 'Replay is not enabled for this approval kind. Retry the original action manually or enable approval.autoReplayScopes.',
68
+ };
69
+ }
70
+ if (approval.kind !== 'shell' || !approval.input) {
71
+ return {
72
+ ok: false,
73
+ message: 'CLI replay is only supported for shell approvals. Retry the original tool or subagent action manually.',
74
+ };
75
+ }
76
+ const replayResult = await replayShellCommand(approval.input, approval.cwd ?? repoRoot, getExecutionLeaseMs(config));
77
+ const output = [replayResult.stdout, replayResult.stderr].filter(Boolean).join('\n').trim();
78
+ if (replayResult.exitCode === 0) {
79
+ await consumeApprovedAfterCliReplay({
80
+ approvalId: options.approvalId,
81
+ store: approvalStore,
82
+ });
83
+ return {
84
+ ok: true,
85
+ message: `Belay replay succeeded for ${options.approvalId}. Do not retry via hooks; the one-shot grant was consumed.${output ? `\n${output}` : ''}`,
86
+ };
87
+ }
88
+ const timeoutNote = replayResult.timedOut ? ' Replay timed out.' : '';
89
+ return {
90
+ ok: false,
91
+ message: `Belay replay failed for ${options.approvalId} (exit ${replayResult.exitCode}).${timeoutNote} Approval remains active for one hook retry.${output ? `\n${output}` : ''}`,
92
+ };
93
+ }
75
94
  return { ok: result.ok, message: result.message };
76
95
  }
@@ -8,11 +8,13 @@ import { clearJudgeCredentialStore, writeJudgeCredentialStore } from '../core/cr
8
8
  import { refreshIntegrityIfPinned } from '../core/integrity.js';
9
9
  import { defaultJudgeProviderForAdapter, hasValidCloudConsent, isCloudJudgeConfig, resolveJudgeUsePatch, } from '../core/judge-config.js';
10
10
  import { rejectDeprecatedJudgeModelAuto } from '../core/judge-model-policy.js';
11
+ import { resolveJudgeTransport } from '../core/judge-runtime-detection.js';
11
12
  import { isJudgeProviderId, JUDGE_PROVIDER_IDS, normalizeLegacyProviderId, } from '../core/verdict/judge-catalog.js';
12
13
  import { initProject } from '../installer.js';
13
14
  import { isBelayFloorInstalled } from './health-snapshot.js';
14
15
  import { judgeStatus } from './judge.js';
15
16
  import { readKeyFromStdin } from './stdin-key.js';
17
+ import { confirmPrompt, intro, isInteractiveTTY, selectPrompt } from './tui.js';
16
18
  export const BELAY_CONFIG_SUBCOMMANDS = [
17
19
  'list',
18
20
  'get',
@@ -106,7 +108,9 @@ function listJudgeFields(judge) {
106
108
  return entries;
107
109
  }
108
110
  function warnCloudConsentIfNeeded(judge) {
109
- if (isCloudJudgeConfig(judge) && !hasValidCloudConsent(judge)) {
111
+ if (isCloudJudgeConfig(judge) &&
112
+ resolveJudgeTransport(judge) === 'http' &&
113
+ !hasValidCloudConsent(judge)) {
110
114
  process.stderr.write('Warning: Cloud judge saved without recorded consent. Tier1 cloud judge will fail closed until consent is granted (belay judge consent + belay approve, or TTY --accept-cloud-judge).\n');
111
115
  }
112
116
  }
@@ -285,46 +289,112 @@ export async function runBelayConfigCredential(options) {
285
289
  }
286
290
  throw new Error('credential requires action: mode, set, or clear.');
287
291
  }
288
- async function collectCloudJudgeWizardAnswers(ask, judgeProviderId) {
292
+ function formatSelectPrompt(options) {
293
+ const values = options.choices.map((choice) => choice.value).join(' | ');
294
+ return `${options.message} [${values}] (${options.defaultValue}): `;
295
+ }
296
+ function formatConfirmPrompt(message, defaultValue) {
297
+ return `${message} [y | n] (${defaultValue ? 'y' : 'n'}): `;
298
+ }
299
+ function parseSelectAnswer(raw, options) {
300
+ const normalized = raw.trim().toLowerCase();
301
+ if (!normalized) {
302
+ return options.defaultValue;
303
+ }
304
+ const found = options.choices.find((choice) => {
305
+ if (choice.value.toLowerCase() === normalized) {
306
+ return true;
307
+ }
308
+ return choice.label?.trim().toLowerCase() === normalized;
309
+ });
310
+ if (!found) {
311
+ throw new Error(`Invalid choice for ${options.message}: ${raw}`);
312
+ }
313
+ return found.value;
314
+ }
315
+ async function askTextLazy(message) {
316
+ const rl = readline.createInterface({ input, output });
317
+ try {
318
+ return (await rl.question(message)).trimEnd();
319
+ }
320
+ finally {
321
+ rl.close();
322
+ }
323
+ }
324
+ function createTestPrompter(prompts) {
325
+ let index = 0;
326
+ const nextPrompt = (message) => {
327
+ if (index >= prompts.length) {
328
+ throw new Error(`unexpected config prompt: ${message}`);
329
+ }
330
+ return prompts[index++];
331
+ };
332
+ return {
333
+ askText: async (message) => nextPrompt(message),
334
+ askSelect: async (options, parseRaw) => {
335
+ const raw = nextPrompt(formatSelectPrompt(options));
336
+ return parseRaw ? parseRaw(raw, options) : parseSelectAnswer(raw, options);
337
+ },
338
+ askConfirm: async (message, defaultValue) => parseYesNo(nextPrompt(formatConfirmPrompt(message, defaultValue)), defaultValue),
339
+ };
340
+ }
341
+ function createReadlinePrompter() {
342
+ return {
343
+ askText: askTextLazy,
344
+ askSelect: async (options, parseRaw) => {
345
+ const raw = await askTextLazy(formatSelectPrompt(options));
346
+ return parseRaw ? parseRaw(raw, options) : parseSelectAnswer(raw, options);
347
+ },
348
+ askConfirm: async (message, defaultValue) => parseYesNo(await askTextLazy(formatConfirmPrompt(message, defaultValue)), defaultValue),
349
+ };
350
+ }
351
+ function createTuiPrompter() {
352
+ return {
353
+ askText: askTextLazy,
354
+ askSelect: selectPrompt,
355
+ askConfirm: confirmPrompt,
356
+ };
357
+ }
358
+ async function withConfigWizardPrompter(options, fn) {
359
+ if (options.prompts) {
360
+ return fn(createTestPrompter(options.prompts));
361
+ }
362
+ if (isInteractiveTTY()) {
363
+ return fn(createTuiPrompter());
364
+ }
365
+ return fn(createReadlinePrompter());
366
+ }
367
+ function writeConfigWizardBanner(options, title) {
368
+ if (options.skipBanner) {
369
+ return;
370
+ }
371
+ if (isInteractiveTTY()) {
372
+ intro(title);
373
+ return;
374
+ }
375
+ output.write(`${title}\n`);
376
+ }
377
+ async function collectCloudJudgeWizardAnswers(prompter, judgeProviderId) {
289
378
  if (judgeProviderId === 'ollama') {
290
379
  return { acceptCloud: false };
291
380
  }
292
- const judgeCredentialMode = parseYesNo(await ask('Use project env for credentials? [y=project | n=apiKey] (y): '), true)
381
+ const judgeCredentialMode = (await prompter.askConfirm('Use project env for credentials?', true))
293
382
  ? 'project'
294
383
  : 'apiKey';
295
- const optionalEndpoint = (await ask('Judge endpoint URL (optional): ')).trim();
384
+ const optionalEndpoint = (await prompter.askText('Judge endpoint URL (optional): ')).trim();
296
385
  const judgeEndpoint = optionalEndpoint || undefined;
297
386
  if (judgeCredentialMode === 'apiKey') {
298
- const key = await ask('Paste API key (hidden input not available in all shells): ');
387
+ const key = await prompter.askText('Paste API key (hidden input not available in all shells): ');
299
388
  if (key.trim()) {
300
389
  process.env.BELAY_CONFIG_WIZARD_JUDGE_KEY = key.trim();
301
390
  }
302
391
  }
303
392
  let acceptCloud = false;
304
393
  if (judgeEndpoint) {
305
- acceptCloud = parseYesNo(await ask('Accept cloud judge egress (redacted commands leave the repo)? [y | n] (n): '), false);
394
+ acceptCloud = await prompter.askConfirm('Accept cloud judge egress (redacted commands leave the repo)?', false);
306
395
  }
307
396
  return { judgeCredentialMode, judgeEndpoint, acceptCloud };
308
397
  }
309
- async function withConfigPrompter(fn, prompts) {
310
- if (prompts) {
311
- let index = 0;
312
- const ask = async (message) => {
313
- if (index >= prompts.length) {
314
- throw new Error(`unexpected config prompt: ${message}`);
315
- }
316
- return prompts[index++];
317
- };
318
- return fn(ask);
319
- }
320
- const rl = readline.createInterface({ input, output });
321
- try {
322
- return await fn((message) => rl.question(message));
323
- }
324
- finally {
325
- rl.close();
326
- }
327
- }
328
398
  export async function resolveBelayConfigInteractiveMode(repoRoot) {
329
399
  try {
330
400
  return (await isBelayFloorInstalled({ targetDir: repoRoot })) ? 'judge-only' : 'full';
@@ -333,16 +403,26 @@ export async function resolveBelayConfigInteractiveMode(repoRoot) {
333
403
  return 'full';
334
404
  }
335
405
  }
336
- async function runBelayConfigFullWithPrompter(ask, options) {
337
- if (!options.skipBanner) {
338
- output.write('belay config\n');
339
- }
340
- const adapter = parseAdapter(await ask('Adapter [cursor | claude | codex] (cursor): '));
341
- const scope = parseScope(await ask('Install scope [project | global] (project): '));
342
- const withSkill = parseYesNo(await ask('Install SKILL.md and slash commands? [y | n] (y): '), true);
406
+ async function runBelayConfigFullWithWizard(prompter, options) {
407
+ writeConfigWizardBanner(options, 'belay config');
408
+ const adapter = await prompter.askSelect({
409
+ message: 'Adapter',
410
+ defaultValue: 'cursor',
411
+ choices: [{ value: 'cursor' }, { value: 'claude' }, { value: 'codex' }],
412
+ });
413
+ const scope = await prompter.askSelect({
414
+ message: 'Install scope',
415
+ defaultValue: 'project',
416
+ choices: [{ value: 'project' }, { value: 'global' }],
417
+ });
418
+ const withSkill = await prompter.askConfirm('Install SKILL.md and slash commands?', true);
343
419
  const defaultJudgeProviderId = defaultJudgeProviderForAdapter(adapter);
344
- const judgeProviderId = parseJudgeProviderId(await ask(`Judge provider [${JUDGE_PROVIDER_IDS.join(' | ')}] (${defaultJudgeProviderId}): `), defaultJudgeProviderId);
345
- const { judgeCredentialMode, judgeEndpoint, acceptCloud } = await collectCloudJudgeWizardAnswers(ask, judgeProviderId);
420
+ const judgeProviderId = await prompter.askSelect({
421
+ message: 'Judge provider',
422
+ defaultValue: defaultJudgeProviderId,
423
+ choices: JUDGE_PROVIDER_IDS.map((providerId) => ({ value: providerId })),
424
+ }, (raw, selectOptions) => parseJudgeProviderId(raw, selectOptions.defaultValue));
425
+ const { judgeCredentialMode, judgeEndpoint, acceptCloud } = await collectCloudJudgeWizardAnswers(prompter, judgeProviderId);
346
426
  const initOptions = buildInitOptionsFromConfigAnswers({
347
427
  adapter,
348
428
  scope,
@@ -362,19 +442,22 @@ async function runBelayConfigFullWithPrompter(ask, options) {
362
442
  }
363
443
  return result;
364
444
  }
365
- async function runBelayConfigJudgeOnlyWithPrompter(ask, options, repoRoot, config, adapter) {
366
- if (!options.skipBanner) {
367
- output.write('belay config (judge only)\n');
368
- }
445
+ async function runBelayConfigJudgeOnlyWithWizard(prompter, options, repoRoot, config, adapter) {
446
+ writeConfigWizardBanner(options, 'belay config (judge only)');
369
447
  const defaultJudgeProviderId = defaultJudgeProviderForAdapter(adapter);
370
- const judgeProviderId = parseJudgeProviderId(await ask(`Judge provider [${JUDGE_PROVIDER_IDS.join(' | ')}] (${defaultJudgeProviderId}): `), defaultJudgeProviderId);
371
- const { judgeCredentialMode, judgeEndpoint, acceptCloud } = await collectCloudJudgeWizardAnswers(ask, judgeProviderId);
448
+ const judgeProviderId = await prompter.askSelect({
449
+ message: 'Judge provider',
450
+ defaultValue: defaultJudgeProviderId,
451
+ choices: JUDGE_PROVIDER_IDS.map((providerId) => ({ value: providerId })),
452
+ }, (raw, selectOptions) => parseJudgeProviderId(raw, selectOptions.defaultValue));
453
+ const { judgeCredentialMode, judgeEndpoint, acceptCloud } = await collectCloudJudgeWizardAnswers(prompter, judgeProviderId);
372
454
  const patch = resolveJudgeUsePatch(config.judge, {
373
455
  providerId: judgeProviderId,
374
456
  endpoint: judgeEndpoint,
375
457
  credentialMode: judgeCredentialMode,
376
458
  acceptCloud: acceptCloud && Boolean(judgeEndpoint),
377
- interactiveTTY: true,
459
+ // `prompts` is a scripted stand-in for interactive responses in tests.
460
+ interactiveTTY: isInteractiveTTY() || Boolean(options.prompts),
378
461
  interactiveConsentApproved: acceptCloud && Boolean(judgeEndpoint),
379
462
  });
380
463
  if (patch.errors.length > 0) {
@@ -399,13 +482,13 @@ async function runBelayConfigJudgeOnlyWithPrompter(ask, options, repoRoot, confi
399
482
  return { repoRoot, adapter };
400
483
  }
401
484
  export async function runBelayConfigFullInteractive(options = {}) {
402
- return withConfigPrompter((ask) => runBelayConfigFullWithPrompter(ask, options), options.prompts);
485
+ return withConfigWizardPrompter(options, (prompter) => runBelayConfigFullWithWizard(prompter, options));
403
486
  }
404
487
  export async function runBelayConfigJudgeOnlyInteractive(options = {}) {
405
488
  const repoRoot = path.resolve(options.targetDir ?? process.cwd());
406
489
  const config = await loadConfigFile(repoRoot);
407
490
  const adapter = resolveAdapterName(config);
408
- return withConfigPrompter((ask) => runBelayConfigJudgeOnlyWithPrompter(ask, options, repoRoot, config, adapter), options.prompts);
491
+ return withConfigWizardPrompter(options, (prompter) => runBelayConfigJudgeOnlyWithWizard(prompter, options, repoRoot, config, adapter));
409
492
  }
410
493
  export async function runBelayConfigInteractive(options = {}) {
411
494
  const repoRoot = path.resolve(options.targetDir ?? process.cwd());
@@ -427,16 +510,16 @@ export async function runBelayConfigInteractive(options = {}) {
427
510
  skipBanner: true,
428
511
  });
429
512
  }
430
- return withConfigPrompter(async (ask) => {
431
- output.write('belay config\n');
432
- const judgeOnly = parseYesNo(await ask('Configure judge only? [Y/n]: '), true);
433
- if (judgeOnly) {
434
- const config = await loadConfigFile(repoRoot);
435
- const adapter = resolveAdapterName(config);
436
- return runBelayConfigJudgeOnlyWithPrompter(ask, { ...options, skipBanner: true }, repoRoot, config, adapter);
513
+ return withConfigWizardPrompter(options, async (prompter) => {
514
+ writeConfigWizardBanner(options, 'belay config');
515
+ const judgeOnly = await prompter.askConfirm('Configure judge only?', true);
516
+ if (!judgeOnly) {
517
+ return runBelayConfigFullWithWizard(prompter, { ...options, skipBanner: true });
437
518
  }
438
- return runBelayConfigFullWithPrompter(ask, { ...options, skipBanner: true });
439
- }, options.prompts);
519
+ const config = await loadConfigFile(repoRoot);
520
+ const adapter = resolveAdapterName(config);
521
+ return runBelayConfigJudgeOnlyWithWizard(prompter, { ...options, skipBanner: true }, repoRoot, config, adapter);
522
+ });
440
523
  }
441
524
  return runBelayConfigFullInteractive(options);
442
525
  }
@@ -10,7 +10,7 @@ import { approvedApprovalsPath, belayStateDir, detectAdapterName, loadLayeredCon
10
10
  import { detectFenceDrift, summarizeAuditVisibility } from '../core/audit-summary.js';
11
11
  import { defaultControlPlaneDir } from '../core/config.js';
12
12
  import { verifyIntegrityManifest } from '../core/integrity.js';
13
- import { diagnoseJudge } from '../core/judge-doctor.js';
13
+ import { diagnoseJudge, stopJudgeSessionBrokers } from '../core/judge-doctor.js';
14
14
  import { getManagedHookEntries } from '../defaults.js';
15
15
  import { resolveNodeBinary } from '../node-resolution.js';
16
16
  import { egressStatus } from '../services/egress-service.js';
@@ -239,6 +239,16 @@ export async function doctorProject(options = {}) {
239
239
  else {
240
240
  notes.push('No orphan approval cleanup actions were needed.');
241
241
  }
242
+ const judgeStateDir = belayStateDir(loadedConfig, repoLocalDir);
243
+ if (options.dryRun !== true) {
244
+ const stoppedBrokers = await stopJudgeSessionBrokers(repoRoot, judgeStateDir);
245
+ if (stoppedBrokers > 0) {
246
+ notes.push(`Stopped judge session broker artifacts for ${repoRoot}.`);
247
+ }
248
+ }
249
+ else {
250
+ notes.push('Dry run: would stop judge session broker and clear kill switch if present.');
251
+ }
242
252
  }
243
253
  let dogfood = null;
244
254
  if (loadedConfig) {