@guilz-dev/belay 0.9.3 → 0.10.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 (110) hide show
  1. package/README.md +38 -3
  2. package/dist/adapters/codex/hooks.d.ts +1 -0
  3. package/dist/adapters/codex/hooks.js +3 -0
  4. package/dist/adapters/cursor/dispatcher-generation.d.ts +3 -0
  5. package/dist/adapters/cursor/dispatcher-generation.js +5 -0
  6. package/dist/adapters/cursor/hook-dispatch-entry.d.ts +10 -0
  7. package/dist/adapters/cursor/hook-dispatch-entry.js +137 -0
  8. package/dist/adapters/cursor/hook-router.d.ts +23 -0
  9. package/dist/adapters/cursor/hook-router.js +273 -0
  10. package/dist/adapters/cursor/hooks.d.ts +5 -1
  11. package/dist/adapters/cursor/hooks.js +112 -10
  12. package/dist/adapters/cursor/routing-config-trust.d.ts +1 -0
  13. package/dist/adapters/cursor/routing-config-trust.js +67 -0
  14. package/dist/adapters/cursor/routing-layout.d.ts +5 -0
  15. package/dist/adapters/cursor/routing-layout.js +29 -0
  16. package/dist/adapters/cursor/runtime-entry.d.ts +17 -0
  17. package/dist/adapters/cursor/runtime-entry.js +74 -49
  18. package/dist/adapters/layouts/scope.d.ts +5 -0
  19. package/dist/adapters/layouts/scope.js +71 -0
  20. package/dist/adapters/shared/gate-runtime.js +29 -17
  21. package/dist/bundle/claude-runtime.mjs +2428 -2072
  22. package/dist/bundle/codex-runtime.mjs +2440 -2084
  23. package/dist/bundle/cursor-dispatcher.mjs +517 -0
  24. package/dist/bundle/cursor-runtime.mjs +2521 -2133
  25. package/dist/cli.js +53 -4
  26. package/dist/commands/approval-token.d.ts +10 -0
  27. package/dist/commands/approval-token.js +26 -0
  28. package/dist/commands/audit.d.ts +2 -1
  29. package/dist/commands/audit.js +2 -2
  30. package/dist/commands/config.d.ts +1 -1
  31. package/dist/commands/config.js +28 -2
  32. package/dist/commands/doctor.js +149 -17
  33. package/dist/commands/dogfood-check.d.ts +3 -0
  34. package/dist/commands/dogfood-check.js +116 -0
  35. package/dist/commands/dogfood.d.ts +1 -0
  36. package/dist/commands/dogfood.js +4 -3
  37. package/dist/commands/health-snapshot.js +9 -4
  38. package/dist/commands/judge.js +2 -2
  39. package/dist/commands/recovery-checkpoints.d.ts +3 -3
  40. package/dist/commands/recovery-checkpoints.js +2 -11
  41. package/dist/config-io.d.ts +1 -0
  42. package/dist/config-io.js +19 -3
  43. package/dist/core/audit-legacy-archive.js +43 -25
  44. package/dist/core/classify-tool.js +16 -8
  45. package/dist/core/dogfood-environment.d.ts +8 -0
  46. package/dist/core/dogfood-environment.js +55 -0
  47. package/dist/core/effect-ir/argv-delegate.d.ts +9 -0
  48. package/dist/core/effect-ir/argv-delegate.js +42 -0
  49. package/dist/core/effect-ir/shell-lower/argv-delegate-gate.d.ts +3 -0
  50. package/dist/core/effect-ir/shell-lower/argv-delegate-gate.js +40 -0
  51. package/dist/core/effect-ir/shell-lower/augment.d.ts +4 -0
  52. package/dist/core/effect-ir/shell-lower/augment.js +97 -0
  53. package/dist/core/effect-ir/shell-lower/context.d.ts +10 -0
  54. package/dist/core/effect-ir/shell-lower/context.js +1 -0
  55. package/dist/core/effect-ir/shell-lower/decode-process.d.ts +9 -0
  56. package/dist/core/effect-ir/shell-lower/decode-process.js +201 -0
  57. package/dist/core/effect-ir/shell-lower/decoders/belay.d.ts +2 -0
  58. package/dist/core/effect-ir/shell-lower/decoders/belay.js +41 -0
  59. package/dist/core/effect-ir/shell-lower/decoders/builtins.d.ts +7 -0
  60. package/dist/core/effect-ir/shell-lower/decoders/builtins.js +194 -0
  61. package/dist/core/effect-ir/shell-lower/decoders/decode-package-exec.d.ts +4 -0
  62. package/dist/core/effect-ir/shell-lower/decoders/decode-package-exec.js +68 -0
  63. package/dist/core/effect-ir/shell-lower/decoders/docker.d.ts +3 -0
  64. package/dist/core/effect-ir/shell-lower/decoders/docker.js +43 -0
  65. package/dist/core/effect-ir/shell-lower/decoders/filesystem.d.ts +9 -0
  66. package/dist/core/effect-ir/shell-lower/decoders/filesystem.js +217 -0
  67. package/dist/core/effect-ir/shell-lower/decoders/prisma.d.ts +2 -0
  68. package/dist/core/effect-ir/shell-lower/decoders/prisma.js +54 -0
  69. package/dist/core/effect-ir/shell-lower/decoders/ruby.d.ts +11 -0
  70. package/dist/core/effect-ir/shell-lower/decoders/ruby.js +147 -0
  71. package/dist/core/effect-ir/shell-lower/decoders/toolchain.d.ts +7 -0
  72. package/dist/core/effect-ir/shell-lower/decoders/toolchain.js +155 -0
  73. package/dist/core/effect-ir/shell-lower/requirement.d.ts +12 -0
  74. package/dist/core/effect-ir/shell-lower/requirement.js +65 -0
  75. package/dist/core/effect-ir/shell-lower/segment.d.ts +11 -0
  76. package/dist/core/effect-ir/shell-lower/segment.js +82 -0
  77. package/dist/core/effect-ir/shell-lower/tokens.d.ts +26 -0
  78. package/dist/core/effect-ir/shell-lower/tokens.js +183 -0
  79. package/dist/core/effect-ir/shell-lower.d.ts +2 -7
  80. package/dist/core/effect-ir/shell-lower.js +60 -1502
  81. package/dist/core/egress-approval.js +0 -18
  82. package/dist/core/glob.js +32 -16
  83. package/dist/core/integrity.d.ts +2 -2
  84. package/dist/core/integrity.js +52 -12
  85. package/dist/core/notify.d.ts +8 -2
  86. package/dist/core/notify.js +63 -7
  87. package/dist/core/recovery/operator-guidance.js +4 -4
  88. package/dist/core/replay-scrub.d.ts +2 -0
  89. package/dist/core/replay-scrub.js +8 -0
  90. package/dist/core/repo-config-trust.d.ts +31 -0
  91. package/dist/core/repo-config-trust.js +152 -0
  92. package/dist/core/shell-substitution.js +2 -2
  93. package/dist/core/shell-unparseable.js +3 -1
  94. package/dist/corpus/benign-probe-cores.d.ts +1 -1
  95. package/dist/corpus/benign-probe-cores.js +0 -1
  96. package/dist/defaults.d.ts +7 -3
  97. package/dist/defaults.js +29 -47
  98. package/dist/installer/bootstrap.d.ts +1 -0
  99. package/dist/installer/bootstrap.js +2 -2
  100. package/dist/installer/runtime-artifacts.js +43 -13
  101. package/dist/installer/scope-config.js +2 -2
  102. package/dist/installer.js +48 -13
  103. package/dist/node-resolution.d.ts +1 -0
  104. package/dist/node-resolution.js +61 -0
  105. package/dist/templates.d.ts +7 -4
  106. package/dist/templates.js +42 -4
  107. package/dist/types.d.ts +20 -0
  108. package/dist/version.d.ts +1 -1
  109. package/dist/version.js +1 -1
  110. package/package.json +5 -1
package/dist/cli.js CHANGED
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import process from 'node:process';
3
3
  import { CLI_COMMAND } from './branding.js';
4
+ import { issuePendingApprovalToken } from './commands/approval-token.js';
4
5
  import { approvePending } from './commands/approve.js';
5
6
  import { auditProject, formatAuditReport } from './commands/audit.js';
6
7
  import { doctorProject, formatDoctorReport } from './commands/doctor.js';
7
- import { dogfoodProject, formatDogfoodResult } from './commands/dogfood.js';
8
+ import { checkDogfoodProject, dogfoodProject, formatDogfoodCheckResult, formatDogfoodResult, } from './commands/dogfood.js';
8
9
  import { explainCommand, formatExplainReport } from './commands/explain.js';
9
10
  import { formatHarvestReport, harvestApplyProject, harvestListProject } from './commands/harvest.js';
10
11
  import { formatMetricsReport, metricsProject } from './commands/metrics.js';
@@ -51,6 +52,13 @@ function parseArgs(argv) {
51
52
  options.force = true;
52
53
  continue;
53
54
  }
55
+ if (token === '--check') {
56
+ if (command !== 'dogfood') {
57
+ throw new Error('--check is only valid for dogfood.');
58
+ }
59
+ options.dogfoodCheck = true;
60
+ continue;
61
+ }
54
62
  if (token === '--adapter') {
55
63
  const next = rest[index + 1];
56
64
  if (!next || !['cursor', 'claude', 'codex'].includes(next)) {
@@ -472,12 +480,13 @@ function parseArgs(argv) {
472
480
  token === 'get' ||
473
481
  token === 'set' ||
474
482
  token === 'unset' ||
483
+ token === 'trust' ||
475
484
  token === 'credential' ||
476
485
  token === 'judge') {
477
486
  options.configSubcommand = token;
478
487
  continue;
479
488
  }
480
- throw new Error('config requires subcommand: list, get, set, unset, credential, or judge');
489
+ throw new Error('config requires subcommand: list, get, set, unset, trust, credential, or judge');
481
490
  }
482
491
  if (command === 'config' &&
483
492
  options.configSubcommand === 'credential' &&
@@ -519,7 +528,8 @@ function parseArgs(argv) {
519
528
  options.judgeUseProvider = token;
520
529
  continue;
521
530
  }
522
- if ((command === 'revoke' || command === 'approve') && !options.approvalId) {
531
+ if ((command === 'revoke' || command === 'approve' || command === 'approval-token') &&
532
+ !options.approvalId) {
523
533
  options.approvalId = token;
524
534
  continue;
525
535
  }
@@ -534,10 +544,11 @@ function printHelp() {
534
544
  Usage:
535
545
  ${c} init [--target <dir>] [--adapter cursor|claude|codex] [--scope project|global] [--preset strict|standard|audit-first|l1-full-recommended] [--judge-profile local-ollama|cursor|claude|codex] [--judge-provider ollama|openai-compatible] [--judge-model <id>] [--judge-endpoint <url>] [--accept-cloud-judge] [--migrate-judge-default] [--with-skill] [--dogfood]
536
546
  ${c} config [--target <dir>] [--json]
537
- ${c} config list|get|set|unset|judge [--target <dir>] [--json]
547
+ ${c} config list|get|set|unset|trust|judge [--target <dir>] [--json]
538
548
  ${c} config get <judge.path> [--target <dir>] [--json]
539
549
  ${c} config set <judge.path> <value> [--target <dir>]
540
550
  ${c} config unset <judge.path> [--target <dir>]
551
+ ${c} config trust [--target <dir>]
541
552
  ${c} config credential mode <project|apiKey> [--target <dir>]
542
553
  ${c} config credential set [--key-stdin] [--key-env <NAME>] [--target <dir>]
543
554
  ${c} config credential clear [--target <dir>]
@@ -547,6 +558,7 @@ Usage:
547
558
  ${c} uninstall [--target <dir>] [--adapter cursor] [--scope project|global]
548
559
  ${c} where [--target <dir>] [--adapter cursor|claude|codex] [--scope project|global] [--json]
549
560
  ${c} dogfood [--target <dir>] [--adapter cursor|claude|codex] [--enforce] [--force]
561
+ ${c} dogfood --check --since <iso> [--target <dir>] [--adapter cursor|claude|codex] [--json]
550
562
  ${c} doctor [--target <dir>] [--adapter cursor|claude|codex] [--json] [--fix] [--dry-run]
551
563
  ${c} metrics [--target <dir>] [--json]
552
564
  ${c} quality [--target <dir>] [--corpus <path>] [--json]
@@ -568,6 +580,7 @@ Usage:
568
580
  ${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>]
569
581
  ${c} judge consent <ollama|codex|claude|cursor> [--endpoint <url>]
570
582
  ${c} approve <approval-id> [--replay] [--scope once|domain|path|workspace-root] [--path <path>] [--token <signed-token>] [--target <dir>]
583
+ ${c} approval-token <approval-id> [--target <dir>] [--json]
571
584
  ${c} revoke <approval-id> [--target <dir>]
572
585
  ${c} standing-allow revoke --fingerprint <fp> [--kind shell|tool|subagent] [--target <dir>]
573
586
  ${c} harvest list [--target <dir>] [--since <iso>] [--until <iso>] [--json]
@@ -615,6 +628,30 @@ async function main() {
615
628
  return;
616
629
  }
617
630
  if (command === 'dogfood') {
631
+ if (options.dogfoodCheck) {
632
+ if (options.enforce) {
633
+ throw new Error('--check conflicts with --enforce.');
634
+ }
635
+ if (options.force) {
636
+ throw new Error('--check conflicts with --force.');
637
+ }
638
+ if (!options.since) {
639
+ throw new Error('--check requires --since <iso>.');
640
+ }
641
+ const report = await checkDogfoodProject({
642
+ targetDir: options.targetDir,
643
+ adapter: options.adapter,
644
+ since: options.since,
645
+ });
646
+ if (options.json) {
647
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
648
+ }
649
+ else {
650
+ process.stdout.write(formatDogfoodCheckResult(report));
651
+ }
652
+ process.exitCode = report.ok ? 0 : 1;
653
+ return;
654
+ }
618
655
  const result = await dogfoodProject({
619
656
  targetDir: options.targetDir,
620
657
  enforce: options.enforce,
@@ -1031,6 +1068,18 @@ async function main() {
1031
1068
  process.exitCode = result.ok ? 0 : 1;
1032
1069
  return;
1033
1070
  }
1071
+ if (command === 'approval-token') {
1072
+ if (!options.approvalId) {
1073
+ throw new Error('approval-token requires an approval ID.');
1074
+ }
1075
+ const result = await issuePendingApprovalToken({
1076
+ targetDir: options.targetDir,
1077
+ approvalId: options.approvalId,
1078
+ });
1079
+ process.stdout.write(options.json ? `${JSON.stringify(result, null, 2)}\n` : `${result.message}\n`);
1080
+ process.exitCode = result.ok ? 0 : 1;
1081
+ return;
1082
+ }
1034
1083
  if (command === 'revoke') {
1035
1084
  if (!options.approvalId) {
1036
1085
  throw new Error('revoke requires an approval ID.');
@@ -0,0 +1,10 @@
1
+ export interface IssuePendingApprovalTokenOptions {
2
+ targetDir?: string;
3
+ approvalId: string;
4
+ }
5
+ export interface IssuePendingApprovalTokenResult {
6
+ ok: boolean;
7
+ message: string;
8
+ token?: string;
9
+ }
10
+ export declare function issuePendingApprovalToken(options: IssuePendingApprovalTokenOptions): Promise<IssuePendingApprovalTokenResult>;
@@ -0,0 +1,26 @@
1
+ import path from 'node:path';
2
+ import { loadApprovalState, loadConfigFile } from '../config-io.js';
3
+ import { isExpired } from '../core/approval.js';
4
+ import { issueApprovalToken } from '../core/approval-token.js';
5
+ import { configuredControlPlaneDir } from '../core/config.js';
6
+ import { canonicalPath } from '../core/path-utils.js';
7
+ export async function issuePendingApprovalToken(options) {
8
+ const repoRoot = path.resolve(options.targetDir ?? process.cwd());
9
+ const config = await loadConfigFile(repoRoot);
10
+ const pending = await loadApprovalState(repoRoot, 'pending-approvals.json', config);
11
+ const approval = pending.approvals.find((entry) => entry.approvalId === options.approvalId);
12
+ if (!approval || isExpired(approval)) {
13
+ return { ok: false, message: `Pending approval not found: ${options.approvalId}` };
14
+ }
15
+ if (canonicalPath(approval.repoRoot) !== canonicalPath(repoRoot)) {
16
+ return { ok: false, message: `Approval repository does not match: ${options.approvalId}` };
17
+ }
18
+ const token = await issueApprovalToken({
19
+ approvalId: approval.approvalId,
20
+ fingerprint: approval.fingerprint,
21
+ repoRoot: approval.repoRoot,
22
+ issuedAt: approval.createdAt,
23
+ expiresAt: approval.expiresAt,
24
+ }, configuredControlPlaneDir(config));
25
+ return { ok: true, token, message: token };
26
+ }
@@ -1,4 +1,5 @@
1
1
  import type { AuditRecord } from '../core/audit-types.js';
2
+ import type { AdapterName } from '../types.js';
2
3
  export type AuditSubcommand = 'query' | 'summarize' | 'replay';
3
4
  export interface AuditOptions {
4
5
  targetDir?: string;
@@ -18,7 +19,7 @@ export interface AuditOptions {
18
19
  limit?: number;
19
20
  configPath?: string;
20
21
  }
21
- export declare function loadAuditRecords(repoRoot: string): Promise<AuditRecord[]>;
22
+ export declare function loadAuditRecords(repoRoot: string, adapter?: AdapterName): Promise<AuditRecord[]>;
22
23
  export declare function auditProject(options: AuditOptions): Promise<{
23
24
  subcommand: string;
24
25
  records: AuditRecord[];
@@ -7,8 +7,8 @@ import { parseAuditNdjson, toAuditRecord } from '../core/audit-metrics.js';
7
7
  import { buildApprovalRoundTrips, filterAuditRecords, summarizeRoundTrips, } from '../core/audit-query.js';
8
8
  import { mergeConfig } from '../core/config.js';
9
9
  import { diffReclassification } from '../core/reclassify.js';
10
- export async function loadAuditRecords(repoRoot) {
11
- const config = await loadConfigFile(repoRoot);
10
+ export async function loadAuditRecords(repoRoot, adapter) {
11
+ const config = await loadConfigFile(repoRoot, adapter);
12
12
  const auditLogPath = path.join(repoRoot, config.audit.logPath);
13
13
  let raw = '';
14
14
  try {
@@ -1,7 +1,7 @@
1
1
  import { type JudgeProviderId } from '../core/verdict/judge-catalog.js';
2
2
  import type { AdapterName, InitOptions } from '../types.js';
3
3
  import { type SelectOptions } from './tui.js';
4
- export declare const BELAY_CONFIG_SUBCOMMANDS: readonly ["list", "get", "set", "unset", "credential", "judge"];
4
+ export declare const BELAY_CONFIG_SUBCOMMANDS: readonly ["list", "get", "set", "unset", "trust", "credential", "judge"];
5
5
  export type BelayConfigSubcommand = (typeof BELAY_CONFIG_SUBCOMMANDS)[number];
6
6
  export interface BelayConfigOptions {
7
7
  targetDir?: string;
@@ -1,7 +1,8 @@
1
+ import { readFile } from 'node:fs/promises';
1
2
  import path from 'node:path';
2
3
  import { stdin as input, stdout as output } from 'node:process';
3
4
  import readline from 'node:readline/promises';
4
- import { loadConfigFile, repoLocalStateDirFor, resolveAdapterName, writeConfigFile, } from '../config-io.js';
5
+ import { configPathFor, detectAdapterName, loadConfigFile, repoLocalStateDirFor, resolveAdapterName, writeTrustedConfigFile, } from '../config-io.js';
5
6
  import { appendCliAuditEvent } from '../core/audit-io.js';
6
7
  import { belayStateDir, normalizeJudgeConfig } from '../core/config.js';
7
8
  import { clearJudgeCredentialStore, writeJudgeCredentialStore } from '../core/credential-store.js';
@@ -9,6 +10,7 @@ import { refreshIntegrityIfPinned } from '../core/integrity.js';
9
10
  import { defaultJudgeProviderForAdapter, hasValidCloudConsent, isCloudJudgeConfig, resolveJudgeUsePatch, } from '../core/judge-config.js';
10
11
  import { rejectDeprecatedJudgeModelAuto } from '../core/judge-model-policy.js';
11
12
  import { detectJudgeRuntimeCapabilities, resolveJudgeTransport, } from '../core/judge-runtime-detection.js';
13
+ import { trustRepoConfig } from '../core/repo-config-trust.js';
12
14
  import { getJudgeProviderSpec, isJudgeProviderId, JUDGE_PROVIDER_IDS, normalizeLegacyProviderId, } from '../core/verdict/judge-catalog.js';
13
15
  import { normalizeJudgeRuntimeConfig } from '../core/verdict/judge-runtime-config.js';
14
16
  import { initProject } from '../installer.js';
@@ -21,6 +23,7 @@ export const BELAY_CONFIG_SUBCOMMANDS = [
21
23
  'get',
22
24
  'set',
23
25
  'unset',
26
+ 'trust',
24
27
  'credential',
25
28
  'judge',
26
29
  ];
@@ -125,7 +128,7 @@ function warnCloudConsentIfNeeded(judge) {
125
128
  }
126
129
  async function persistJudge(repoRoot, config, judge, adapter) {
127
130
  const updated = { ...config, judge: normalizeJudgeConfig(judge) };
128
- await writeConfigFile(repoRoot, updated, adapter);
131
+ await writeTrustedConfigFile(repoRoot, updated, adapter);
129
132
  await refreshIntegrityIfPinned(repoRoot, updated);
130
133
  return updated;
131
134
  }
@@ -651,6 +654,29 @@ export async function runBelayConfig(options = {}) {
651
654
  return runBelayConfigInteractive({ targetDir: options.targetDir });
652
655
  }
653
656
  const repoRoot = path.resolve(options.targetDir ?? process.cwd());
657
+ if (options.subcommand === 'trust') {
658
+ const adapter = detectAdapterName(repoRoot);
659
+ const configPath = configPathFor(repoRoot, adapter);
660
+ let rawConfig;
661
+ try {
662
+ rawConfig = JSON.parse(await readFile(configPath, 'utf8'));
663
+ }
664
+ catch (error) {
665
+ if (error?.code === 'ENOENT') {
666
+ throw new Error(`Missing repository config: ${configPath}`);
667
+ }
668
+ throw new Error(`Repository config is malformed JSON: ${configPath}`);
669
+ }
670
+ const record = await trustRepoConfig(repoRoot, adapter, rawConfig);
671
+ const merged = await loadConfigFile(repoRoot, adapter);
672
+ await refreshIntegrityIfPinned(repoRoot, merged);
673
+ return [
674
+ 'Repository config trusted:',
675
+ JSON.stringify(rawConfig, null, 2),
676
+ `Trusted fingerprint: ${record.repoConfigFingerprint}`,
677
+ `Repository: ${record.repoRoot}`,
678
+ ].join('\n');
679
+ }
654
680
  const config = await loadConfigFile(repoRoot);
655
681
  if (options.subcommand === 'list') {
656
682
  const entries = listJudgeFields(config.judge);
@@ -1,25 +1,29 @@
1
- import { existsSync } from 'node:fs';
1
+ import { existsSync, realpathSync } from 'node:fs';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { getClaudeManagedHookEntries } from '../adapters/claude/hooks.js';
5
- import { getCodexManagedHookEntries } from '../adapters/codex/hooks.js';
6
- import { hasDuplicateCursorShellGates } from '../adapters/cursor/hooks.js';
5
+ import { codexHooksTomlIncludesCommand, getCodexManagedHookEntries, } from '../adapters/codex/hooks.js';
6
+ import { hasCurrentCursorDispatcherGeneration } from '../adapters/cursor/dispatcher-generation.js';
7
+ import { hasLegacyCursorDoubleShellGates, hasManagedCursorHookEntries, } from '../adapters/cursor/hooks.js';
7
8
  import { getAdapterLayout } from '../adapters/layouts/index.js';
8
9
  import { protectedArtifactRoots } from '../adapters/layouts/protected-paths.js';
9
10
  import { resolveScopedPaths } from '../adapters/layouts/scope.js';
10
11
  import { cleanupOrphanApprovalState } from '../cleanup-orphans.js';
11
- import { approvedApprovalsPath, belayStateDir, detectAdapterName, loadLayeredConfig, pendingApprovalsPath, repoLocalStateDirFor, writeConfigFile, } from '../config-io.js';
12
+ import { approvedApprovalsPath, belayStateDir, detectAdapterName, loadLayeredConfig, pendingApprovalsPath, repoLocalStateDirFor, writeTrustedConfigFile, } from '../config-io.js';
12
13
  import { approvalSigningKeyPath } from '../core/approval-token.js';
13
14
  import { auditRecordHasLegacyCorrelationPlaceholders } from '../core/audit-legacy-archive.js';
14
15
  import { detectFenceDrift, summarizeAuditVisibility } from '../core/audit-summary.js';
15
16
  import { inspectBoundaryAttestationFile } from '../core/capability/boundary-attestation-sign.js';
16
17
  import { boundaryAttestationPath, boundarySessionStatus, } from '../core/capability/boundary-session.js';
17
18
  import { configuredControlPlaneDir, defaultControlPlaneDir, hasForbiddenShellOverrideLists, stripForbiddenShellOverrideLists, } from '../core/config.js';
18
- import { verifyIntegrityManifest } from '../core/integrity.js';
19
+ import { detectUndogfoodedLinkedWorktrees } from '../core/dogfood-environment.js';
20
+ import { runtimeIntegrityFiles, verifyIntegrityManifest } from '../core/integrity.js';
19
21
  import { diagnoseJudge, stopJudgeSessionBrokers } from '../core/judge-doctor.js';
20
22
  import { resolveJudgeTransport } from '../core/judge-runtime-detection.js';
23
+ import { notificationConfigIssues } from '../core/notify.js';
21
24
  import { listRecoveryCheckpoints } from '../core/recovery/checkpoint.js';
22
25
  import { recoveryApprovalSetupNotes, recoveryNotificationConfigured, recoveryNotificationSetupWarning, summarizeRecoveryCheckpointDiagnostics, } from '../core/recovery/operator-guidance.js';
26
+ import { inspectRepoConfigTrust } from '../core/repo-config-trust.js';
23
27
  import { probeFileCheckpointBackend } from '../core/transactional/backend-selector.js';
24
28
  import { fileCheckpointIsolationReason } from '../core/transactional/file-checkpoint-isolation.js';
25
29
  import { probeFileCloneStrategy } from '../core/transactional/file-clone.js';
@@ -52,6 +56,61 @@ function hasCursorGlobalWorkspaceResolver(runtimeSource) {
52
56
  runtimeSource.includes('working_directory') &&
53
57
  runtimeSource.includes('tool_input'));
54
58
  }
59
+ const CURSOR_HOOK_SHIMS = [
60
+ 'belay-before-submit.mjs',
61
+ 'belay-shell-gate.mjs',
62
+ 'belay-tool-gate.mjs',
63
+ 'belay-audit.mjs',
64
+ ];
65
+ function runnerFileName(adapterName, platform) {
66
+ if (platform !== 'win32') {
67
+ return 'belay-runner';
68
+ }
69
+ return adapterName === 'cursor' ? 'belay-runner.ps1' : 'belay-runner.cmd';
70
+ }
71
+ async function cursorOriginIssues(hooksDir, installScope, repoRoot) {
72
+ const canonicalRepoRoot = realpathSync(repoRoot);
73
+ const issues = [];
74
+ for (const fileName of CURSOR_HOOK_SHIMS) {
75
+ const shimPath = path.join(hooksDir, fileName);
76
+ if (!existsSync(shimPath)) {
77
+ continue;
78
+ }
79
+ let source;
80
+ try {
81
+ source = await readFile(shimPath, 'utf8');
82
+ }
83
+ catch (error) {
84
+ const detail = error instanceof Error ? error.message : String(error);
85
+ issues.push(`Unable to inspect Cursor hook shim for intended ${installScope} owner: ${shimPath}: ${detail}. Run belay upgrade --scope ${installScope}.`);
86
+ continue;
87
+ }
88
+ if (!source.includes("from '../belay/runtime/dispatcher.mjs'")) {
89
+ issues.push(`Cursor router generation mismatch for intended ${installScope} owner: ${shimPath}. Run belay upgrade --scope ${installScope}.`);
90
+ continue;
91
+ }
92
+ const originMatch = source.match(/origin:\s*(\{[^\n]+\})/);
93
+ let originMatches = false;
94
+ if (originMatch?.[1]) {
95
+ try {
96
+ const origin = JSON.parse(originMatch[1]);
97
+ originMatches =
98
+ installScope === 'global'
99
+ ? origin.scope === 'global'
100
+ : origin.scope === 'project' &&
101
+ typeof origin.repoRoot === 'string' &&
102
+ realpathSync(origin.repoRoot) === canonicalRepoRoot;
103
+ }
104
+ catch {
105
+ originMatches = false;
106
+ }
107
+ }
108
+ if (!originMatches) {
109
+ issues.push(`Cursor hook origin mismatch for intended ${installScope} owner: ${shimPath}. Run belay upgrade --scope ${installScope}.`);
110
+ }
111
+ }
112
+ return issues;
113
+ }
55
114
  export async function doctorProject(options = {}) {
56
115
  const repoRoot = path.resolve(options.targetDir ?? process.cwd());
57
116
  const issues = [];
@@ -76,12 +135,17 @@ export async function doctorProject(options = {}) {
76
135
  configPath = activeLayout.configPath(repoRoot);
77
136
  hooksPath = activeLayout.hooksSettingsPath(repoRoot);
78
137
  corePath = path.join(activeLayout.runtimeDir(repoRoot), 'core.mjs');
138
+ const trust = await inspectRepoConfigTrust(repoRoot, adapterName, rawConfig);
139
+ if (!trust.trusted) {
140
+ issues.push(`Repository config is not trusted (${trust.reason}) at ${trust.recordPath}. Review it, then run belay config trust.`);
141
+ }
79
142
  if (rawConfig.version === undefined) {
80
143
  warnings.push('Config is missing "version". Set "version": 3 explicitly to avoid ambiguous migration.');
81
144
  }
82
145
  const layered = await loadLayeredConfig(repoRoot, adapterName);
83
146
  loadedConfig = layered.config;
84
147
  configProvenance = layered.provenance;
148
+ issues.push(...notificationConfigIssues(loadedConfig.notifications, repoRoot));
85
149
  for (const entry of layered.provenance) {
86
150
  notes.push(`Config layer [${entry.source}]: ${entry.path}`);
87
151
  }
@@ -145,9 +209,9 @@ export async function doctorProject(options = {}) {
145
209
  }
146
210
  if (loadedConfig.controlPlane.integrity === 'hash-pinned') {
147
211
  notes.push('Integrity: hash-pinned (verify with belay upgrade after runtime changes).');
148
- const integrity = await verifyIntegrityManifest(repoRoot, activeLayout);
212
+ const integrity = await verifyIntegrityManifest(repoRoot, activeLayout, runtimeIntegrityFiles(activeLayout, scopedPaths));
149
213
  if (!integrity.ok) {
150
- issues.push(`Integrity verification failed: ${integrity.mismatches.slice(0, 3).join(', ')}`);
214
+ issues.push(`Integrity verification failed: ${integrity.mismatches.join(', ')}`);
151
215
  }
152
216
  }
153
217
  }
@@ -163,14 +227,16 @@ export async function doctorProject(options = {}) {
163
227
  const repoLocalDir = loadedConfig
164
228
  ? repoLocalStateDirFor(repoRoot, loadedConfig)
165
229
  : activeLayout.repoLocalStateDir(repoRoot);
166
- const requiredPaths = [
167
- path.join(hooksDir, 'belay-runner'),
168
- path.join(hooksDir, 'belay-runner.cmd'),
230
+ const routingOwnerPaths = [
231
+ path.join(hooksDir, runnerFileName(adapterName, process.platform)),
169
232
  path.join(hooksDir, 'belay-before-submit.mjs'),
170
233
  path.join(hooksDir, 'belay-shell-gate.mjs'),
171
234
  path.join(hooksDir, 'belay-tool-gate.mjs'),
172
235
  path.join(hooksDir, 'belay-audit.mjs'),
236
+ ...(adapterName === 'cursor' ? [path.join(scopedPaths.runtimeDir, 'dispatcher.mjs')] : []),
173
237
  corePath,
238
+ ];
239
+ const operationalPaths = [
174
240
  loadedConfig
175
241
  ? pendingApprovalsPath(repoRoot, loadedConfig)
176
242
  : path.join(repoLocalDir, 'pending-approvals.json'),
@@ -179,9 +245,67 @@ export async function doctorProject(options = {}) {
179
245
  : path.join(repoLocalDir, 'approved-approvals.json'),
180
246
  path.join(repoRoot, loadedConfig?.audit.logPath ?? activeLayout.defaultAuditLogPath(repoRoot)),
181
247
  ];
248
+ const requiredPaths = [...routingOwnerPaths, ...operationalPaths];
182
249
  for (const requiredPath of requiredPaths) {
183
250
  if (!existsSync(requiredPath)) {
184
251
  issues.push(`Missing generated file: ${requiredPath}`);
252
+ if (adapterName === 'cursor' && routingOwnerPaths.includes(requiredPath)) {
253
+ issues.push(`Cursor intended ${installScope} owner is incomplete: missing ${requiredPath}. Run belay upgrade --scope ${installScope}.`);
254
+ }
255
+ }
256
+ }
257
+ if (adapterName === 'cursor') {
258
+ issues.push(...(await cursorOriginIssues(hooksDir, installScope, repoRoot)));
259
+ const dispatcherPath = path.join(scopedPaths.runtimeDir, 'dispatcher.mjs');
260
+ if (existsSync(dispatcherPath) &&
261
+ !hasCurrentCursorDispatcherGeneration(await readFile(dispatcherPath, 'utf8'))) {
262
+ issues.push(`Cursor router generation mismatch for intended ${installScope} owner dispatcher: ${dispatcherPath}. Run belay upgrade --scope ${installScope}.`);
263
+ }
264
+ if (installScope === 'project') {
265
+ const globalPaths = resolveScopedPaths(activeLayout, 'global', repoRoot);
266
+ if (existsSync(globalPaths.hooksSettingsPath)) {
267
+ try {
268
+ const { loadHooksFile } = await import('../installer.js');
269
+ const globalHooks = await loadHooksFile(globalPaths.hooksSettingsPath);
270
+ if (hasManagedCursorHookEntries(globalHooks, process.platform, globalPaths.hooksDir, repoRoot)) {
271
+ const globalOwnerFiles = [
272
+ path.join(globalPaths.hooksDir, runnerFileName('cursor', process.platform)),
273
+ path.join(globalPaths.runtimeDir, 'core.mjs'),
274
+ path.join(globalPaths.runtimeDir, 'dispatcher.mjs'),
275
+ ...CURSOR_HOOK_SHIMS.map((fileName) => path.join(globalPaths.hooksDir, fileName)),
276
+ ];
277
+ const globalOriginProblems = await cursorOriginIssues(globalPaths.hooksDir, 'global', repoRoot);
278
+ issues.push(...globalOriginProblems);
279
+ const globalProblems = [
280
+ ...globalOwnerFiles.filter((filePath) => !existsSync(filePath)),
281
+ ...globalOriginProblems,
282
+ ];
283
+ const globalDispatcherPath = path.join(globalPaths.runtimeDir, 'dispatcher.mjs');
284
+ if (existsSync(globalDispatcherPath)) {
285
+ try {
286
+ if (!hasCurrentCursorDispatcherGeneration(await readFile(globalDispatcherPath, 'utf8'))) {
287
+ globalProblems.push(globalDispatcherPath);
288
+ }
289
+ }
290
+ catch (error) {
291
+ const detail = error instanceof Error ? error.message : String(error);
292
+ issues.push(`Unable to inspect shadowed global Cursor dispatcher at ${globalDispatcherPath}: ${detail}.`);
293
+ globalProblems.push(globalDispatcherPath);
294
+ }
295
+ }
296
+ if (globalProblems.length > 0) {
297
+ issues.push('Global Cursor installation cannot yield to the project owner. Run belay upgrade from this project to refresh the global router.');
298
+ }
299
+ else {
300
+ notes.push('Healthy global Cursor install is shadowed by this project owner and will return neutral for this repository.');
301
+ }
302
+ }
303
+ }
304
+ catch (error) {
305
+ const detail = error instanceof Error ? error.message : String(error);
306
+ issues.push(`Unable to inspect shadowed global Cursor hooks at ${globalPaths.hooksSettingsPath}: ${detail}.`);
307
+ }
308
+ }
185
309
  }
186
310
  }
187
311
  let hooksOk = true;
@@ -196,15 +320,17 @@ export async function doctorProject(options = {}) {
196
320
  const hooksFile = await loadHooksFile(hooksPath);
197
321
  for (const { event, definition } of managedEntries) {
198
322
  const entries = hooksFile.hooks[event] ?? [];
199
- const present = entries.some((entry) => entry.command === definition.command && entry.matcher === definition.matcher);
200
- if (!present) {
323
+ const matchingEntry = entries.find((entry) => entry.command === definition.command && entry.matcher === definition.matcher);
324
+ if (!matchingEntry || matchingEntry.failClosed !== true) {
201
325
  hooksOk = false;
202
326
  const matcherSuffix = definition.matcher ? ` (matcher: ${definition.matcher})` : '';
203
- issues.push(`Missing managed hook for ${event}: ${definition.command}${matcherSuffix}`);
327
+ issues.push(matchingEntry
328
+ ? `Managed Cursor hook for ${event} must set failClosed: true: ${definition.command}${matcherSuffix}`
329
+ : `Missing managed hook for ${event}: ${definition.command}${matcherSuffix}`);
204
330
  }
205
331
  }
206
- if (hasDuplicateCursorShellGates(hooksFile, process.platform, hooksDir, repoRoot)) {
207
- warnings.push('Duplicate Cursor Shell preToolUse gates detected. Run belay upgrade to dedupe managed hooks.');
332
+ if (hasLegacyCursorDoubleShellGates(hooksFile, process.platform, hooksDir, repoRoot)) {
333
+ warnings.push('Legacy Cursor Shell preToolUse gate detected alongside beforeShellExecution. Run belay upgrade to remove the managed Shell matcher.');
208
334
  }
209
335
  }
210
336
  else if (adapterName === 'codex') {
@@ -212,7 +338,7 @@ export async function doctorProject(options = {}) {
212
338
  // are present in the rendered TOML block.
213
339
  const toml = await readFile(hooksPath, 'utf8');
214
340
  for (const { event, definition } of managedEntries) {
215
- if (!toml.includes(definition.command)) {
341
+ if (!codexHooksTomlIncludesCommand(toml, definition.command)) {
216
342
  hooksOk = false;
217
343
  issues.push(`Missing Codex managed hook for ${event}: ${definition.command}`);
218
344
  }
@@ -276,7 +402,7 @@ export async function doctorProject(options = {}) {
276
402
  if (hasForbiddenShellOverrideLists(loadedConfig)) {
277
403
  if (options.dryRun !== true) {
278
404
  const stripped = stripForbiddenShellOverrideLists(loadedConfig);
279
- await writeConfigFile(repoRoot, stripped, adapterName);
405
+ await writeTrustedConfigFile(repoRoot, stripped, adapterName);
280
406
  loadedConfig = stripped;
281
407
  notes.push('Removed forbidden legacy shell override lists (overrides.allow / overrides.external).');
282
408
  for (let index = issues.length - 1; index >= 0; index -= 1) {
@@ -346,6 +472,12 @@ export async function doctorProject(options = {}) {
346
472
  };
347
473
  if (dogfood.active) {
348
474
  notes.push(`Dogfood active cohort: ${dogfood.gateEvents} gate events, ${dogfood.wouldBlockCount} would-block (${(dogfood.wouldBlockRate * 100).toFixed(1)}%); ${dogfood.excludedGateEvents} historical/mismatched event(s) excluded.`);
475
+ notes.push(`Host denied after Belay allow (cohort): ${auditVisibility.hostDeniedAfterAllowCount}.`);
476
+ warnings.push(...(await detectUndogfoodedLinkedWorktrees({
477
+ repoRoot,
478
+ adapterName,
479
+ layout: activeLayout,
480
+ })));
349
481
  if (dogfood.readyForEnforce) {
350
482
  notes.push('Dogfood metrics suggest enforce mode is ready (belay dogfood --enforce).');
351
483
  }
@@ -0,0 +1,3 @@
1
+ import type { DogfoodCheckOptions, DogfoodCheckResult } from '../types.js';
2
+ export declare function checkDogfoodProject(options: DogfoodCheckOptions): Promise<DogfoodCheckResult>;
3
+ export declare function formatDogfoodCheckResult(result: DogfoodCheckResult): string;