@guilz-dev/belay 0.1.1 → 0.3.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 (106) hide show
  1. package/README.md +55 -15
  2. package/dist/adapters/shared/gate-runtime.js +25 -9
  3. package/dist/bundle/claude-runtime.mjs +1786 -379
  4. package/dist/bundle/codex-runtime.mjs +1785 -379
  5. package/dist/bundle/cursor-runtime.mjs +1786 -379
  6. package/dist/cli.js +201 -11
  7. package/dist/commands/approve.js +11 -0
  8. package/dist/commands/classify-for-report.js +3 -3
  9. package/dist/commands/config.d.ts +70 -0
  10. package/dist/commands/config.js +494 -0
  11. package/dist/commands/doctor.js +3 -2
  12. package/dist/commands/explain.js +14 -14
  13. package/dist/commands/health-snapshot.d.ts +6 -0
  14. package/dist/commands/health-snapshot.js +34 -0
  15. package/dist/commands/judge.d.ts +90 -0
  16. package/dist/commands/judge.js +285 -0
  17. package/dist/commands/recover.js +2 -2
  18. package/dist/commands/status.js +2 -2
  19. package/dist/commands/stdin-key.d.ts +1 -0
  20. package/dist/commands/stdin-key.js +8 -0
  21. package/dist/conformance/guarantee-table.js +12 -0
  22. package/dist/conformance/types.d.ts +2 -0
  23. package/dist/core/approval.d.ts +3 -0
  24. package/dist/core/approval.js +18 -3
  25. package/dist/core/audit-io.d.ts +2 -0
  26. package/dist/core/audit-io.js +14 -0
  27. package/dist/core/audit-query.js +5 -1
  28. package/dist/core/capability/index.d.ts +2 -2
  29. package/dist/core/capability/index.js +2 -2
  30. package/dist/core/capability/paths.d.ts +1 -0
  31. package/dist/core/capability/paths.js +74 -6
  32. package/dist/core/capability/reasons.d.ts +3 -0
  33. package/dist/core/capability/reasons.js +8 -0
  34. package/dist/core/classify-tool.js +97 -27
  35. package/dist/core/config-layers.js +2 -1
  36. package/dist/core/config.d.ts +23 -3
  37. package/dist/core/config.js +112 -13
  38. package/dist/core/credential-store.d.ts +11 -0
  39. package/dist/core/credential-store.js +60 -0
  40. package/dist/core/gate-contract.d.ts +1 -1
  41. package/dist/core/gate-contract.js +1 -1
  42. package/dist/core/gate-engine.js +105 -14
  43. package/dist/core/index.d.ts +2 -2
  44. package/dist/core/index.js +2 -2
  45. package/dist/core/integrity.d.ts +2 -0
  46. package/dist/core/integrity.js +13 -0
  47. package/dist/core/judge-api-key.d.ts +19 -0
  48. package/dist/core/judge-api-key.js +74 -0
  49. package/dist/core/judge-cloud-consent.d.ts +13 -0
  50. package/dist/core/judge-cloud-consent.js +38 -0
  51. package/dist/core/judge-config.d.ts +45 -4
  52. package/dist/core/judge-config.js +266 -44
  53. package/dist/core/judge-doctor.d.ts +6 -1
  54. package/dist/core/judge-doctor.js +148 -97
  55. package/dist/core/judge-model-discovery.d.ts +24 -0
  56. package/dist/core/judge-model-discovery.js +168 -0
  57. package/dist/core/judge-model-policy.d.ts +5 -0
  58. package/dist/core/judge-model-policy.js +21 -0
  59. package/dist/core/judge-runtime-detection.d.ts +9 -0
  60. package/dist/core/judge-runtime-detection.js +68 -0
  61. package/dist/core/transactional/diff-evaluator.js +1 -19
  62. package/dist/core/types.d.ts +7 -3
  63. package/dist/core/{v2 → verdict}/adapter.d.ts +1 -0
  64. package/dist/core/{v2 → verdict}/adapter.js +16 -4
  65. package/dist/core/{v2 → verdict}/containment.d.ts +5 -0
  66. package/dist/core/{v2 → verdict}/containment.js +32 -2
  67. package/dist/core/{v2 → verdict}/egress-classify.js +3 -0
  68. package/dist/core/verdict/judge-catalog.d.ts +40 -0
  69. package/dist/core/verdict/judge-catalog.js +148 -0
  70. package/dist/core/verdict/judge-cli.d.ts +23 -0
  71. package/dist/core/verdict/judge-cli.js +280 -0
  72. package/dist/core/{v2 → verdict}/judge-factory.d.ts +15 -4
  73. package/dist/core/verdict/judge-factory.js +178 -0
  74. package/dist/core/{v2 → verdict}/judge.d.ts +20 -1
  75. package/dist/core/{v2 → verdict}/judge.js +85 -18
  76. package/dist/core/{v2 → verdict}/launcher-resolve.js +72 -1
  77. package/dist/core/verdict/persistent-paths.d.ts +8 -0
  78. package/dist/core/verdict/persistent-paths.js +52 -0
  79. package/dist/core/{v2 → verdict}/types.d.ts +6 -2
  80. package/dist/core/{v2 → verdict}/verdict.js +176 -46
  81. package/dist/corpus/evaluate.js +2 -2
  82. package/dist/installer.js +66 -14
  83. package/dist/types.d.ts +8 -1
  84. package/dist/version.d.ts +1 -1
  85. package/dist/version.js +1 -1
  86. package/package.json +2 -1
  87. package/skills/belay/SKILL.md +8 -7
  88. package/dist/commands/init-wizard.d.ts +0 -16
  89. package/dist/commands/init-wizard.js +0 -50
  90. package/dist/core/v2/judge-factory.js +0 -75
  91. /package/dist/core/{v2 → verdict}/egress-classify.d.ts +0 -0
  92. /package/dist/core/{v2 → verdict}/fingerprint.d.ts +0 -0
  93. /package/dist/core/{v2 → verdict}/fingerprint.js +0 -0
  94. /package/dist/core/{v2 → verdict}/index.d.ts +0 -0
  95. /package/dist/core/{v2 → verdict}/index.js +0 -0
  96. /package/dist/core/{v2 → verdict}/judge-audit.d.ts +0 -0
  97. /package/dist/core/{v2 → verdict}/judge-audit.js +0 -0
  98. /package/dist/core/{v2 → verdict}/judge-outbound.d.ts +0 -0
  99. /package/dist/core/{v2 → verdict}/judge-outbound.js +0 -0
  100. /package/dist/core/{v2 → verdict}/launcher-resolve.d.ts +0 -0
  101. /package/dist/core/{v2 → verdict}/overrides.d.ts +0 -0
  102. /package/dist/core/{v2 → verdict}/overrides.js +0 -0
  103. /package/dist/core/{v2 → verdict}/parser.d.ts +0 -0
  104. /package/dist/core/{v2 → verdict}/parser.js +0 -0
  105. /package/dist/core/{v2 → verdict}/types.js +0 -0
  106. /package/dist/core/{v2 → verdict}/verdict.d.ts +0 -0
@@ -1,10 +1,9 @@
1
- import path from 'node:path';
2
- import { relativeWithinRepo } from '../path-utils.js';
1
+ import { resolveMutationTarget } from '../path-utils.js';
3
2
  import { extractRedirectTargets, tokenizeShell } from '../shell-tokenizer.js';
4
- import { analyzePathTargets, cwdRelative, resolveTrustedPath } from './containment.js';
3
+ import { analyzePathTargets, cwdRelative, isDestructiveHighStakesMutation, resolveTrustedPath, touchesProtectedRoot, } from './containment.js';
5
4
  import { classifyEgressTool } from './egress-classify.js';
6
5
  import { verdictFingerprint } from './fingerprint.js';
7
- import { prescanInterpreterCode, tier1RequiresAsk } from './judge.js';
6
+ import { mutationPrescanRequiresAsk, prescanInterpreterCode, tier1RequiresAsk } from './judge.js';
8
7
  import { isRoutineLauncher, resolveLauncherRecipe } from './launcher-resolve.js';
9
8
  import { allowFromCustomOverride, askFromCustomExternal, customAllowMatch, customExternalMatch, } from './overrides.js';
10
9
  import { extractRecursiveScript, isBareInterpreter, isVariableIndirectHead, parseSegment, peelTransparentWrappers, redactCommand, segmentOpacity, splitTopLevelSegments, substitutionInners, } from './parser.js';
@@ -91,6 +90,7 @@ const LOCAL_ROUTINE_HEADS = new Set([
91
90
  'make',
92
91
  'cmake',
93
92
  ]);
93
+ const BELAY_SELF_COMMANDS = new Set(['approve', 'revoke']);
94
94
  const FIND_DANGEROUS_FLAGS = new Set(['-delete', '-exec', '-execdir', '-ok', '-okdir']);
95
95
  function isFindDangerous(tokens) {
96
96
  return tokens.some((token) => FIND_DANGEROUS_FLAGS.has(token) || token.startsWith('-exec') || token.startsWith('-ok'));
@@ -256,8 +256,45 @@ function tier0ExternalMatch(key, head, tokens) {
256
256
  }
257
257
  return false;
258
258
  }
259
- function tier0HighStakesRm(tokens, context) {
259
+ function isBelayJudgeSelfCommand(tokens) {
260
+ return (tokens[0] === 'belay' &&
261
+ tokens[1] === 'judge' &&
262
+ (tokens[2] === 'use' ||
263
+ tokens[2] === 'status' ||
264
+ tokens[2] === 'list' ||
265
+ tokens[2] === 'test' ||
266
+ tokens[2] === 'consent'));
267
+ }
268
+ function isBelayConfigSelfCommand(tokens) {
269
+ if (tokens[0] !== 'belay' || tokens[1] !== 'config') {
270
+ return false;
271
+ }
272
+ const sub = tokens[2];
273
+ if (!sub) {
274
+ return true;
275
+ }
276
+ if (sub === 'list' || sub === 'get' || sub === 'judge') {
277
+ return true;
278
+ }
279
+ if (sub === 'credential') {
280
+ return true;
281
+ }
282
+ if (sub === 'set' || sub === 'unset') {
283
+ const pathKey = tokens[3] ?? '';
284
+ return pathKey.startsWith('judge.');
285
+ }
286
+ return false;
287
+ }
288
+ function isBelaySelfCommand(tokens) {
260
289
  const head = tokens[0] ?? '';
290
+ const subcommand = tokens[1] ?? '';
291
+ return (head === 'belay' &&
292
+ (BELAY_SELF_COMMANDS.has(subcommand) ||
293
+ isBelayJudgeSelfCommand(tokens) ||
294
+ isBelayConfigSelfCommand(tokens)));
295
+ }
296
+ function tier0HighStakesRm(tokens, context) {
297
+ const head = (tokens[0] ?? '').split('/').pop() ?? '';
261
298
  if (head !== 'rm') {
262
299
  return null;
263
300
  }
@@ -280,31 +317,40 @@ function tier0HighStakesRm(tokens, context) {
280
317
  signals: ['missing_trusted_cwd', ...analysis.signals],
281
318
  });
282
319
  }
283
- if (analysis.isHighStakes) {
284
- return askVerdict({
285
- location: analysis.location,
286
- opacity: 'transparent',
287
- effect: 'local_mutation',
288
- confidence: 'deterministic',
289
- reason: 'high_stakes_path',
290
- signals: ['high_stakes_path', ...analysis.signals],
291
- });
292
- }
293
320
  for (const target of targets) {
294
- if (target === '~' || target.startsWith('~/') || target.startsWith('/')) {
295
- const resolved = path.resolve(target === '~' || target.startsWith('~/') ? (process.env.HOME ?? '/') : context.cwd, target);
296
- const relative = relativeWithinRepo(context.repoRoot, resolved);
297
- if (relative === null) {
298
- return askVerdict({
299
- location: 'repo_outside',
300
- opacity: 'transparent',
301
- effect: 'local_mutation',
302
- confidence: 'deterministic',
303
- reason: 'repo_outside_mutation',
304
- signals: ['repo_outside_mutation'],
305
- });
321
+ const normalized = target.replace(/^['"]|['"]$/g, '');
322
+ if (normalized === '~' || normalized === '$HOME' || normalized.startsWith('~/')) {
323
+ return askVerdict({
324
+ location: 'repo_outside',
325
+ opacity: 'transparent',
326
+ effect: 'local_mutation',
327
+ confidence: 'deterministic',
328
+ reason: 'high_stakes_path',
329
+ signals: ['catastrophic_home_deletion', ...analysis.signals],
330
+ });
331
+ }
332
+ }
333
+ if (analysis.isHighStakes && analysis.signals.includes('high_stakes_path')) {
334
+ let destructiveHighStakes = false;
335
+ for (const target of targets) {
336
+ const resolved = resolveTrustedPath(target, context.cwd, context.trustedCwd) ??
337
+ resolveMutationTarget(target, context.cwd);
338
+ if (resolved &&
339
+ isDestructiveHighStakesMutation(head, resolved, context.repoRoot, context.sensitivePaths, context.protectedArtifactRoots)) {
340
+ destructiveHighStakes = true;
341
+ break;
306
342
  }
307
343
  }
344
+ if (destructiveHighStakes) {
345
+ return askVerdict({
346
+ location: analysis.location,
347
+ opacity: 'transparent',
348
+ effect: 'local_mutation',
349
+ confidence: 'deterministic',
350
+ reason: 'high_stakes_path',
351
+ signals: ['high_stakes_path', ...analysis.signals],
352
+ });
353
+ }
308
354
  }
309
355
  return null;
310
356
  }
@@ -488,6 +534,16 @@ async function evaluateSegment(command, context, depth) {
488
534
  if (rmVerdict) {
489
535
  return rmVerdict;
490
536
  }
537
+ if (isBelaySelfCommand(peeled)) {
538
+ return allowVerdict({
539
+ location: 'unknown',
540
+ opacity: 'transparent',
541
+ effect: 'local_mutation',
542
+ confidence: 'deterministic',
543
+ reason: 'belay_control_plane_command',
544
+ signals: ['belay_control_plane_command', segment.head],
545
+ });
546
+ }
491
547
  let effect = 'unknown';
492
548
  if (READ_ONLY_KEYS.has(segment.key) || READ_ONLY_KEYS.has(segment.head)) {
493
549
  effect = 'read_only';
@@ -499,6 +555,9 @@ async function evaluateSegment(command, context, depth) {
499
555
  effect = 'local_mutation';
500
556
  }
501
557
  const pathArgs = extractPathArgs(peeled);
558
+ if (extractRedirectTargets(peeled).length > 0 && effect === 'read_only') {
559
+ effect = 'local_mutation';
560
+ }
502
561
  const pathAnalysis = analyzePathTargets({
503
562
  targets: pathArgs,
504
563
  cwd: context.cwd,
@@ -529,15 +588,45 @@ async function evaluateSegment(command, context, depth) {
529
588
  });
530
589
  }
531
590
  }
532
- if (pathAnalysis.isHighStakes) {
533
- return askVerdict({
534
- location: pathAnalysis.location,
535
- opacity: 'transparent',
536
- effect: 'local_mutation',
537
- confidence: 'deterministic',
538
- reason: 'high_stakes_path',
539
- signals: pathAnalysis.signals,
540
- });
591
+ if ((effect === 'local_mutation' || effect === 'unknown') &&
592
+ context.protectedArtifactRoots &&
593
+ context.protectedArtifactRoots.length > 0) {
594
+ for (const target of pathArgs) {
595
+ const resolved = resolveTrustedPath(target, context.cwd, context.trustedCwd) ??
596
+ resolveMutationTarget(target, context.cwd);
597
+ if (resolved && touchesProtectedRoot(resolved, context.protectedArtifactRoots)) {
598
+ return askVerdict({
599
+ location: pathAnalysis.location,
600
+ opacity: 'transparent',
601
+ effect: 'local_mutation',
602
+ confidence: 'deterministic',
603
+ reason: 'high_stakes_path',
604
+ signals: ['high_stakes_path', 'control_plane_path'],
605
+ });
606
+ }
607
+ }
608
+ }
609
+ if (effect === 'local_mutation' || effect === 'unknown') {
610
+ let destructiveHighStakes = false;
611
+ for (const target of pathArgs) {
612
+ const resolved = resolveTrustedPath(target, context.cwd, context.trustedCwd) ??
613
+ resolveMutationTarget(target, context.cwd);
614
+ if (resolved &&
615
+ isDestructiveHighStakesMutation(segment.head, resolved, context.repoRoot, context.sensitivePaths, context.protectedArtifactRoots)) {
616
+ destructiveHighStakes = true;
617
+ break;
618
+ }
619
+ }
620
+ if (destructiveHighStakes) {
621
+ return askVerdict({
622
+ location: pathAnalysis.location,
623
+ opacity: 'transparent',
624
+ effect: 'local_mutation',
625
+ confidence: 'deterministic',
626
+ reason: 'high_stakes_path',
627
+ signals: pathAnalysis.signals,
628
+ });
629
+ }
541
630
  }
542
631
  if (segment.head === 'find' && isFindDangerous(peeled)) {
543
632
  return askVerdict({
@@ -549,15 +638,15 @@ async function evaluateSegment(command, context, depth) {
549
638
  signals: ['find_dangerous_action'],
550
639
  });
551
640
  }
552
- if (pathAnalysis.location === 'repo_outside' || pathAnalysis.location === 'mixed') {
553
- const outsideEffect = effect === 'read_only' ? 'read_only' : effect === 'unknown' ? 'local_mutation' : effect;
554
- return askVerdict({
641
+ if (effect === 'read_only' &&
642
+ (pathAnalysis.location === 'repo_outside' || pathAnalysis.location === 'mixed')) {
643
+ return allowVerdict({
555
644
  location: pathAnalysis.location,
556
- opacity: 'transparent',
557
- effect: outsideEffect,
645
+ opacity,
646
+ effect: 'read_only',
558
647
  confidence: 'deterministic',
559
- reason: 'repo_outside_mutation',
560
- signals: ['repo_outside_mutation', ...pathAnalysis.signals],
648
+ reason: 'read_only',
649
+ signals: ['read_only', ...pathAnalysis.signals],
561
650
  });
562
651
  }
563
652
  if (pathAnalysis.location === 'unknown' &&
@@ -572,7 +661,34 @@ async function evaluateSegment(command, context, depth) {
572
661
  signals: ['unknown_location_mutation'],
573
662
  });
574
663
  }
575
- const needsTier1 = effect === 'unknown' || TIER0_EXTERNAL_HEADS.has(segment.head) || egressClass === 'ambiguous';
664
+ const outsideMutation = pathAnalysis.location === 'repo_outside' || pathAnalysis.location === 'mixed';
665
+ const mutationPrescan = (effect === 'local_mutation' || effect === 'unknown') && pathArgs.length > 0
666
+ ? mutationPrescanRequiresAsk({
667
+ targets: pathArgs,
668
+ cwd: context.cwd,
669
+ repoRoot: context.repoRoot,
670
+ trustedCwd: context.trustedCwd,
671
+ sensitivePaths: context.sensitivePaths,
672
+ })
673
+ : null;
674
+ if (mutationPrescan) {
675
+ return askVerdict({
676
+ location: pathAnalysis.location === 'unknown'
677
+ ? 'unknown'
678
+ : pathAnalysis.location === 'repo_outside' || pathAnalysis.location === 'mixed'
679
+ ? pathAnalysis.location
680
+ : 'repo_local',
681
+ opacity,
682
+ effect: 'local_mutation',
683
+ confidence: 'deterministic',
684
+ reason: 'tier1_catastrophic',
685
+ signals: ['tier1_catastrophic', mutationPrescan.reason],
686
+ });
687
+ }
688
+ const needsTier1 = effect === 'unknown' ||
689
+ TIER0_EXTERNAL_HEADS.has(segment.head) ||
690
+ egressClass === 'ambiguous' ||
691
+ (outsideMutation && effect !== 'read_only');
576
692
  let tier1Trace;
577
693
  if (needsTier1) {
578
694
  const tier1Text = recursiveScript ?? command;
@@ -584,9 +700,13 @@ async function evaluateSegment(command, context, depth) {
584
700
  tier1Trace = context.judge.lastTrace;
585
701
  if (tier1RequiresAsk(tier1)) {
586
702
  return askVerdict({
587
- location: pathAnalysis.location === 'unknown' ? 'unknown' : 'repo_local',
703
+ location: pathAnalysis.location === 'unknown'
704
+ ? 'unknown'
705
+ : pathAnalysis.location === 'repo_outside' || pathAnalysis.location === 'mixed'
706
+ ? pathAnalysis.location
707
+ : 'repo_local',
588
708
  opacity,
589
- effect: tier1.external_change ? 'remote_mutation' : effect,
709
+ effect: !tier1.local_recoverable ? 'remote_mutation' : effect,
590
710
  confidence: 'llm',
591
711
  reason: 'tier1_catastrophic',
592
712
  signals: ['tier1_catastrophic', tier1.reason],
@@ -594,6 +714,16 @@ async function evaluateSegment(command, context, depth) {
594
714
  });
595
715
  }
596
716
  }
717
+ if (outsideMutation && effect !== 'read_only') {
718
+ return withJudgeTrace(allowVerdict({
719
+ location: pathAnalysis.location,
720
+ opacity,
721
+ effect: 'local_mutation',
722
+ confidence: tier1Trace ? 'llm' : 'deterministic',
723
+ reason: 'repo_outside_local_mutation',
724
+ signals: ['repo_outside_local_mutation', ...pathAnalysis.signals],
725
+ }), tier1Trace);
726
+ }
597
727
  if (pathAnalysis.location === 'repo_local' &&
598
728
  (effect === 'read_only' || effect === 'local_mutation') &&
599
729
  opacity !== 'opaque') {
@@ -2,8 +2,8 @@ import { readFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { classifierOptionsFromConfig, DEFAULT_CONFIG_V3 } from '../core/config.js';
5
- import { classifyShell } from '../core/v2/adapter.js';
6
- import { createDeterministicJudgeStub } from '../core/v2/judge.js';
5
+ import { classifyShell } from '../core/verdict/adapter.js';
6
+ import { createDeterministicJudgeStub } from '../core/verdict/judge.js';
7
7
  export function assessmentsDiverge(predicted, observed) {
8
8
  return (predicted.reversibility !== observed.reversibility ||
9
9
  predicted.external !== observed.external ||
package/dist/installer.js CHANGED
@@ -7,9 +7,10 @@ import { resolveScopedPaths } from './adapters/layouts/scope.js';
7
7
  import { getAdapter } from './adapters/registry.js';
8
8
  import { dogfoodProject } from './commands/dogfood.js';
9
9
  import { detectAdapterName, loadConfigFile, mergeAndWriteConfig, writeConfigFile, } from './config-io.js';
10
- import { isFreshConfigInput, mergeConfig, normalizeConfig } from './core/config.js';
10
+ import { appendCliAuditEvent } from './core/audit-io.js';
11
+ import { isFreshConfigInput, mergeConfig, normalizeConfig, } from './core/config.js';
11
12
  import { runtimeIntegrityFiles, writeIntegrityManifest } from './core/integrity.js';
12
- import { resolveInitJudgeConfig } from './core/judge-config.js';
13
+ import { hasValidCloudConsent, isCloudJudgeConfig, migrateImplicitLocalJudgeIfNeeded, resolveInitJudgeConfig, } from './core/judge-config.js';
13
14
  import { bootstrapStateFiles, writeSkillArtifacts } from './installer/bootstrap.js';
14
15
  import { writeRuntimeArtifacts } from './installer/runtime-artifacts.js';
15
16
  import { applyInstallScope, resolveOperationScope } from './installer/scope-config.js';
@@ -104,31 +105,62 @@ function resolveAdapterName(options, repoRoot) {
104
105
  }
105
106
  return 'cursor';
106
107
  }
107
- async function applyInitJudgeConfig(repoRoot, adapterName, options) {
108
- if (options.skipJudgeWrite) {
108
+ async function auditJudgeMigrationIfNeeded(repoRoot, adapterName, before, after, by) {
109
+ if (before.providerId === after.providerId && before.provider === after.provider) {
109
110
  return;
110
111
  }
111
- const layout = getAdapter(adapterName).layout;
112
- const configPath = layout.configPath(repoRoot);
113
- let existingConfig = {};
114
- if (await pathExists(configPath)) {
115
- existingConfig = JSON.parse(await readFile(configPath, 'utf8'));
112
+ const config = await loadConfigFile(repoRoot, adapterName);
113
+ await appendCliAuditEvent(repoRoot, config, {
114
+ event: 'judge_provider_changed',
115
+ from: { providerId: before.providerId, provider: before.provider },
116
+ to: { providerId: after.providerId, provider: after.provider },
117
+ by,
118
+ });
119
+ }
120
+ async function applyInitJudgeConfig(repoRoot, adapterName, options, isFreshBeforeInstall) {
121
+ if (options.skipJudgeWrite) {
122
+ return;
116
123
  }
117
- const isFresh = isFreshConfigInput(existingConfig);
118
124
  const mergedConfig = await loadConfigFile(repoRoot, adapterName);
119
- const hasExplicitJudgeFlags = options.judgeProfile || options.judgeProvider || options.judgeModel || options.judgeEndpoint;
125
+ const hasExplicitJudgeFlags = options.judgeProfile ||
126
+ options.judgeProvider ||
127
+ options.judgeProviderId ||
128
+ options.judgeModel ||
129
+ options.judgeEndpoint;
120
130
  const judge = resolveInitJudgeConfig({
121
- isFresh,
131
+ isFresh: isFreshBeforeInstall,
122
132
  hasExplicitJudgeFlags: Boolean(hasExplicitJudgeFlags),
123
133
  judgeProfile: options.judgeProfile,
124
134
  judgeProvider: options.judgeProvider,
135
+ judgeProviderId: options.judgeProviderId,
125
136
  judgeModel: options.judgeModel,
126
137
  judgeEndpoint: options.judgeEndpoint,
127
138
  acceptCloudJudge: options.acceptCloudJudge,
139
+ interactiveConsent: Boolean(options.acceptCloudJudge && process.stdin.isTTY),
140
+ cloudConsentApprovalId: options.cloudConsentApprovalId,
128
141
  existingJudge: mergedConfig.judge,
142
+ adapter: adapterName,
129
143
  });
130
- const configWithJudge = normalizeConfig({ ...mergedConfig, version: 4, judge });
144
+ const migrated = options.migrateJudgeDefault === true
145
+ ? migrateImplicitLocalJudgeIfNeeded(mergedConfig.judge, adapterName)
146
+ : null;
147
+ let finalJudge = migrated ?? judge;
148
+ if (options.judgeCredentialMode) {
149
+ finalJudge = {
150
+ ...finalJudge,
151
+ credential: options.judgeCredentialMode === 'apiKey'
152
+ ? { mode: 'apiKey', ref: 'store:judge' }
153
+ : { mode: 'project' },
154
+ };
155
+ }
156
+ const configWithJudge = normalizeConfig({ ...mergedConfig, version: 4, judge: finalJudge });
157
+ if (isCloudJudgeConfig(configWithJudge.judge) && !hasValidCloudConsent(configWithJudge.judge)) {
158
+ 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');
159
+ }
131
160
  await writeConfigFile(repoRoot, configWithJudge, adapterName);
161
+ if (migrated) {
162
+ await auditJudgeMigrationIfNeeded(repoRoot, adapterName, mergedConfig.judge, finalJudge, options.migrateJudgeDefault ? 'belay init --migrate-judge-default' : 'belay init');
163
+ }
132
164
  }
133
165
  async function refreshIntegrityManifest(repoRoot, adapterName) {
134
166
  const layout = getAdapter(adapterName).layout;
@@ -141,8 +173,19 @@ export async function initProject(options = {}) {
141
173
  const repoRoot = path.resolve(options.targetDir ?? process.cwd());
142
174
  const adapterName = resolveAdapterName(options, repoRoot);
143
175
  const adapter = getAdapter(adapterName);
176
+ const configPath = adapter.layout.configPath(repoRoot);
177
+ let isFreshBeforeInstall = true;
178
+ if (await pathExists(configPath)) {
179
+ try {
180
+ const raw = await readFile(configPath, 'utf8');
181
+ isFreshBeforeInstall = isFreshConfigInput(JSON.parse(raw));
182
+ }
183
+ catch {
184
+ isFreshBeforeInstall = false;
185
+ }
186
+ }
144
187
  const result = await adapter.install(repoRoot, options);
145
- await applyInitJudgeConfig(repoRoot, adapterName, options);
188
+ await applyInitJudgeConfig(repoRoot, adapterName, options, isFreshBeforeInstall);
146
189
  if (options.preset) {
147
190
  const existing = await loadConfigFile(repoRoot, adapterName);
148
191
  const presetConfig = mergeConfig(applyConfigPreset(options.preset));
@@ -164,6 +207,15 @@ export async function upgradeProject(options = {}) {
164
207
  const repoRoot = path.resolve(options.targetDir ?? process.cwd());
165
208
  const adapterName = resolveAdapterName(options, repoRoot);
166
209
  await getAdapter(adapterName).upgrade(repoRoot, options);
210
+ if (options.migrateJudgeDefault === true) {
211
+ const mergedConfig = await loadConfigFile(repoRoot, adapterName);
212
+ const migrated = migrateImplicitLocalJudgeIfNeeded(mergedConfig.judge, adapterName);
213
+ if (migrated) {
214
+ const configWithJudge = normalizeConfig({ ...mergedConfig, version: 4, judge: migrated });
215
+ await writeConfigFile(repoRoot, configWithJudge, adapterName);
216
+ await auditJudgeMigrationIfNeeded(repoRoot, adapterName, mergedConfig.judge, migrated, 'belay upgrade --migrate-judge-default');
217
+ }
218
+ }
167
219
  await refreshIntegrityManifest(repoRoot, adapterName);
168
220
  return { repoRoot, adapter: adapterName };
169
221
  }
package/dist/types.d.ts CHANGED
@@ -20,20 +20,27 @@ export interface InitOptions {
20
20
  adapter?: AdapterName;
21
21
  scope?: InstallScope;
22
22
  preset?: import('./presets.js').ConfigPresetName;
23
- judgeProfile?: 'local-ollama';
23
+ judgeProfile?: 'local-ollama' | 'cursor' | 'claude' | 'codex';
24
24
  judgeProvider?: 'ollama' | 'openai-compatible' | 'cursor';
25
+ judgeProviderId?: 'ollama' | 'codex' | 'claude' | 'cursor' | 'local' | 'openai';
25
26
  judgeModel?: string;
26
27
  judgeEndpoint?: string;
28
+ judgeCredentialMode?: 'project' | 'apiKey';
27
29
  /** Acknowledge cloud judge egress + redaction limits (R19). Required for openai-compatible provider. */
28
30
  acceptCloudJudge?: boolean;
31
+ cloudConsentApprovalId?: string;
29
32
  /** When true, skip writing judge config (used when user declines cloud consent). */
30
33
  skipJudgeWrite?: boolean;
34
+ /** Opt-in: migrate implicit factory-default ollama judge to host default provider. */
35
+ migrateJudgeDefault?: boolean;
31
36
  }
32
37
  export interface UpgradeOptions {
33
38
  targetDir?: string;
34
39
  withSkill?: boolean;
35
40
  adapter?: AdapterName;
36
41
  scope?: InstallScope;
42
+ /** Opt-in: migrate implicit factory-default ollama judge to host default provider. */
43
+ migrateJudgeDefault?: boolean;
37
44
  }
38
45
  export interface DoctorOptions {
39
46
  targetDir?: string;
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const PACKAGE_VERSION = "0.1.1";
1
+ export declare const PACKAGE_VERSION = "0.3.0";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Generated by scripts/sync-version.mjs — do not edit.
2
- export const PACKAGE_VERSION = '0.1.1';
2
+ export const PACKAGE_VERSION = '0.3.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guilz-dev/belay",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "Belay-style approval and audit gating for agent runtimes.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -54,6 +54,7 @@
54
54
  "lint": "biome check src package.json README.md CHANGELOG.md CONTRIBUTING.md SECURITY.md tsconfig.json tsconfig.build.json vitest.config.ts scripts docs",
55
55
  "typecheck": "tsc --noEmit",
56
56
  "test": "pnpm build && vitest run",
57
+ "test:structural": "pnpm build && vitest run src/__tests__/verdict/structural-suite.test.ts",
57
58
  "test:stable": "pnpm build && vitest run && vitest run && vitest run",
58
59
  "corpus": "pnpm build && node scripts/corpus.mjs"
59
60
  },
@@ -29,10 +29,10 @@ you to the CLI. It does not classify commands itself.
29
29
 
30
30
  ## Prerequisites
31
31
 
32
- Run `npx @guilz-dev/belay init` in the project root before relying on enforcement.
33
- If you only installed this skill via `npx skills add`, approval instructions are
34
- available, but the runtime gate is not installed yet. Run `belay doctor` to
35
- check whether hooks are present.
32
+ `npx skills add` installs this skill only — the runtime gate is **not installed**
33
+ until hooks are configured. Run `belay config` in the project root for interactive
34
+ setup (or `npx @guilz-dev/belay init` for non-interactive install). Run
35
+ `belay doctor` to check whether hooks are present.
36
36
 
37
37
  ## When belay blocks an action
38
38
 
@@ -48,16 +48,17 @@ For recovery advice after a block, use `/belay recover` or `belay recover`.
48
48
 
49
49
  ## Install or repair
50
50
 
51
- - Full install: `npx @guilz-dev/belay init --with-skill`
52
- - Interactive wizard: `npx @guilz-dev/belay init-wizard`
51
+ - Interactive setup: `belay config`
52
+ - Non-interactive install: `npx @guilz-dev/belay init --with-skill`
53
53
  - Health check: `belay doctor`
54
54
 
55
- Do not run init or doctor implicitly from this skill — only when the user asks.
55
+ Do not run init, config, or doctor implicitly from this skill — only when the user asks.
56
56
 
57
57
  ## CLI mapping
58
58
 
59
59
  | User intent | Command |
60
60
  | --- | --- |
61
+ | Set up or change judge settings | `belay config` |
61
62
  | Why was this blocked? | `belay explain --command "..."` |
62
63
  | Explain latest pending ask | `belay explain` |
63
64
  | Status / dogfood / audit visibility | `belay status` |
@@ -1,16 +0,0 @@
1
- import type { AdapterName, InitOptions } from '../types.js';
2
- export interface WizardAnswers {
3
- adapter: AdapterName;
4
- scope: 'project' | 'global';
5
- withSkill: boolean;
6
- dogfood: boolean;
7
- }
8
- export declare function buildInitOptionsFromWizard(answers: WizardAnswers, targetDir?: string): InitOptions;
9
- export declare function runInitWizard(options?: {
10
- targetDir?: string;
11
- }): Promise<{
12
- repoRoot: string;
13
- withSkill: boolean;
14
- dogfood: boolean;
15
- adapter: import("../adapters/layouts/types.js").AdapterName;
16
- }>;
@@ -1,50 +0,0 @@
1
- import { stdin as input, stdout as output } from 'node:process';
2
- import readline from 'node:readline/promises';
3
- import { initProject } from '../installer.js';
4
- function parseAdapter(value) {
5
- const normalized = (value ?? 'cursor').trim().toLowerCase();
6
- if (normalized === 'claude' || normalized === 'codex' || normalized === 'cursor') {
7
- return normalized;
8
- }
9
- throw new Error(`Unknown adapter: ${value ?? '(empty)'}`);
10
- }
11
- function parseScope(value) {
12
- const normalized = (value ?? 'project').trim().toLowerCase();
13
- if (normalized === 'global' || normalized === 'project') {
14
- return normalized;
15
- }
16
- throw new Error(`Unknown scope: ${value ?? '(empty)'}`);
17
- }
18
- function parseYesNo(value, defaultValue) {
19
- const normalized = (value ?? (defaultValue ? 'y' : 'n')).trim().toLowerCase();
20
- if (['y', 'yes', 'true', '1'].includes(normalized)) {
21
- return true;
22
- }
23
- if (['n', 'no', 'false', '0'].includes(normalized)) {
24
- return false;
25
- }
26
- return defaultValue;
27
- }
28
- export function buildInitOptionsFromWizard(answers, targetDir) {
29
- return {
30
- targetDir,
31
- adapter: answers.adapter,
32
- scope: answers.scope,
33
- withSkill: answers.withSkill,
34
- dogfood: answers.dogfood,
35
- };
36
- }
37
- export async function runInitWizard(options = {}) {
38
- const rl = readline.createInterface({ input, output });
39
- try {
40
- output.write('belay init wizard\n');
41
- const adapter = parseAdapter(await rl.question('Adapter (cursor/claude/codex) [cursor]: '));
42
- const scope = parseScope(await rl.question('Install scope (project/global) [project]: '));
43
- const withSkill = parseYesNo(await rl.question('Install SKILL.md and slash commands? (y/n) [y]: '), true);
44
- const dogfood = parseYesNo(await rl.question('Start in audit dogfood mode? (y/n) [n]: '), false);
45
- return initProject(buildInitOptionsFromWizard({ adapter, scope, withSkill, dogfood }, options.targetDir));
46
- }
47
- finally {
48
- rl.close();
49
- }
50
- }