@guilz-dev/belay 0.9.4 → 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 (49) hide show
  1. package/README.md +3 -2
  2. package/dist/adapters/cursor/hook-dispatch-entry.d.ts +3 -0
  3. package/dist/adapters/cursor/hook-dispatch-entry.js +11 -0
  4. package/dist/adapters/cursor/hook-router.js +14 -7
  5. package/dist/adapters/cursor/hooks.d.ts +1 -1
  6. package/dist/adapters/cursor/hooks.js +16 -5
  7. package/dist/adapters/cursor/routing-config-trust.d.ts +1 -0
  8. package/dist/adapters/cursor/routing-config-trust.js +67 -0
  9. package/dist/adapters/cursor/runtime-entry.d.ts +2 -2
  10. package/dist/adapters/cursor/runtime-entry.js +35 -10
  11. package/dist/adapters/shared/gate-runtime.js +28 -17
  12. package/dist/bundle/claude-runtime.mjs +602 -438
  13. package/dist/bundle/codex-runtime.mjs +612 -448
  14. package/dist/bundle/cursor-dispatcher.mjs +100 -26
  15. package/dist/bundle/cursor-runtime.mjs +662 -470
  16. package/dist/cli.js +53 -4
  17. package/dist/commands/approval-token.d.ts +10 -0
  18. package/dist/commands/approval-token.js +26 -0
  19. package/dist/commands/audit.d.ts +2 -1
  20. package/dist/commands/audit.js +2 -2
  21. package/dist/commands/config.d.ts +1 -1
  22. package/dist/commands/config.js +28 -2
  23. package/dist/commands/doctor.js +10 -54
  24. package/dist/commands/dogfood-check.d.ts +3 -0
  25. package/dist/commands/dogfood-check.js +116 -0
  26. package/dist/commands/dogfood.d.ts +1 -0
  27. package/dist/commands/dogfood.js +4 -3
  28. package/dist/commands/judge.js +2 -2
  29. package/dist/commands/recovery-checkpoints.js +2 -11
  30. package/dist/config-io.d.ts +1 -0
  31. package/dist/config-io.js +8 -1
  32. package/dist/core/dogfood-environment.d.ts +8 -0
  33. package/dist/core/dogfood-environment.js +55 -0
  34. package/dist/core/effect-ir/shell-lower/decoders/belay.js +12 -0
  35. package/dist/core/egress-approval.js +0 -18
  36. package/dist/core/notify.d.ts +8 -2
  37. package/dist/core/notify.js +63 -7
  38. package/dist/core/recovery/operator-guidance.js +4 -4
  39. package/dist/core/repo-config-trust.d.ts +31 -0
  40. package/dist/core/repo-config-trust.js +152 -0
  41. package/dist/corpus/benign-probe-cores.d.ts +1 -1
  42. package/dist/corpus/benign-probe-cores.js +0 -1
  43. package/dist/defaults.js +0 -25
  44. package/dist/installer/scope-config.js +2 -2
  45. package/dist/installer.js +4 -4
  46. package/dist/types.d.ts +19 -0
  47. package/dist/version.d.ts +1 -1
  48. package/dist/version.js +1 -1
  49. 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,8 +1,6 @@
1
- import { execFile } from 'node:child_process';
2
1
  import { existsSync, realpathSync } from 'node:fs';
3
2
  import { readFile } from 'node:fs/promises';
4
3
  import path from 'node:path';
5
- import { promisify } from 'node:util';
6
4
  import { getClaudeManagedHookEntries } from '../adapters/claude/hooks.js';
7
5
  import { codexHooksTomlIncludesCommand, getCodexManagedHookEntries, } from '../adapters/codex/hooks.js';
8
6
  import { hasCurrentCursorDispatcherGeneration } from '../adapters/cursor/dispatcher-generation.js';
@@ -11,18 +9,21 @@ import { getAdapterLayout } from '../adapters/layouts/index.js';
11
9
  import { protectedArtifactRoots } from '../adapters/layouts/protected-paths.js';
12
10
  import { resolveScopedPaths } from '../adapters/layouts/scope.js';
13
11
  import { cleanupOrphanApprovalState } from '../cleanup-orphans.js';
14
- 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';
15
13
  import { approvalSigningKeyPath } from '../core/approval-token.js';
16
14
  import { auditRecordHasLegacyCorrelationPlaceholders } from '../core/audit-legacy-archive.js';
17
15
  import { detectFenceDrift, summarizeAuditVisibility } from '../core/audit-summary.js';
18
16
  import { inspectBoundaryAttestationFile } from '../core/capability/boundary-attestation-sign.js';
19
17
  import { boundaryAttestationPath, boundarySessionStatus, } from '../core/capability/boundary-session.js';
20
18
  import { configuredControlPlaneDir, defaultControlPlaneDir, hasForbiddenShellOverrideLists, stripForbiddenShellOverrideLists, } from '../core/config.js';
19
+ import { detectUndogfoodedLinkedWorktrees } from '../core/dogfood-environment.js';
21
20
  import { runtimeIntegrityFiles, verifyIntegrityManifest } from '../core/integrity.js';
22
21
  import { diagnoseJudge, stopJudgeSessionBrokers } from '../core/judge-doctor.js';
23
22
  import { resolveJudgeTransport } from '../core/judge-runtime-detection.js';
23
+ import { notificationConfigIssues } from '../core/notify.js';
24
24
  import { listRecoveryCheckpoints } from '../core/recovery/checkpoint.js';
25
25
  import { recoveryApprovalSetupNotes, recoveryNotificationConfigured, recoveryNotificationSetupWarning, summarizeRecoveryCheckpointDiagnostics, } from '../core/recovery/operator-guidance.js';
26
+ import { inspectRepoConfigTrust } from '../core/repo-config-trust.js';
26
27
  import { probeFileCheckpointBackend } from '../core/transactional/backend-selector.js';
27
28
  import { fileCheckpointIsolationReason } from '../core/transactional/file-checkpoint-isolation.js';
28
29
  import { probeFileCloneStrategy } from '../core/transactional/file-clone.js';
@@ -36,7 +37,6 @@ import { PACKAGE_VERSION } from '../version.js';
36
37
  import { loadAuditRecords } from './audit.js';
37
38
  import { collectHealthSnapshot } from './health-snapshot.js';
38
39
  import { metricsProject } from './metrics.js';
39
- const execFileAsync = promisify(execFile);
40
40
  function resolveDoctorAdapter(options, configAdapter) {
41
41
  if (options.adapter) {
42
42
  return options.adapter;
@@ -111,55 +111,6 @@ async function cursorOriginIssues(hooksDir, installScope, repoRoot) {
111
111
  }
112
112
  return issues;
113
113
  }
114
- async function listLinkedWorktreePaths(repoRoot) {
115
- try {
116
- const { stdout } = await execFileAsync('git', ['worktree', 'list', '--porcelain'], {
117
- cwd: repoRoot,
118
- encoding: 'utf8',
119
- });
120
- return stdout
121
- .split(/\r?\n/)
122
- .filter((line) => line.startsWith('worktree '))
123
- .map((line) => line.slice('worktree '.length).trim())
124
- .filter((entry) => entry.length > 0);
125
- }
126
- catch {
127
- return [];
128
- }
129
- }
130
- async function detectUndogfoodedLinkedWorktrees(params) {
131
- const repoRootCanonical = realpathSync(params.repoRoot);
132
- const worktrees = await listLinkedWorktreePaths(params.repoRoot);
133
- const warnings = [];
134
- for (const worktreePath of worktrees) {
135
- let canonicalPath = worktreePath;
136
- try {
137
- canonicalPath = realpathSync(worktreePath);
138
- }
139
- catch {
140
- // Keep the original path for warning output when the entry is stale.
141
- }
142
- if (canonicalPath === repoRootCanonical) {
143
- continue;
144
- }
145
- const configPath = params.layout.configPath(worktreePath);
146
- if (!existsSync(configPath)) {
147
- warnings.push(`Dogfood is active here but ${path.basename(worktreePath)} has no belay.config.json (defaults to enforce). Run belay dogfood in each worktree you use with Cursor.`);
148
- continue;
149
- }
150
- try {
151
- const candidate = await loadLayeredConfig(worktreePath, params.adapterName);
152
- const dogfoodEnabled = candidate.config.mode === 'audit' && candidate.config.policy.unknownLocalEffect === 'deny';
153
- if (!dogfoodEnabled) {
154
- warnings.push(`Dogfood is active here but ${path.basename(worktreePath)} is not in dogfood mode (mode=${candidate.config.mode}, unknownLocalEffect=${candidate.config.policy.unknownLocalEffect}). Run belay dogfood in each worktree you use with Cursor.`);
155
- }
156
- }
157
- catch {
158
- warnings.push(`Dogfood is active here but ${path.basename(worktreePath)} has an unreadable belay.config.json. Run belay doctor and belay dogfood in that worktree.`);
159
- }
160
- }
161
- return warnings;
162
- }
163
114
  export async function doctorProject(options = {}) {
164
115
  const repoRoot = path.resolve(options.targetDir ?? process.cwd());
165
116
  const issues = [];
@@ -184,12 +135,17 @@ export async function doctorProject(options = {}) {
184
135
  configPath = activeLayout.configPath(repoRoot);
185
136
  hooksPath = activeLayout.hooksSettingsPath(repoRoot);
186
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
+ }
187
142
  if (rawConfig.version === undefined) {
188
143
  warnings.push('Config is missing "version". Set "version": 3 explicitly to avoid ambiguous migration.');
189
144
  }
190
145
  const layered = await loadLayeredConfig(repoRoot, adapterName);
191
146
  loadedConfig = layered.config;
192
147
  configProvenance = layered.provenance;
148
+ issues.push(...notificationConfigIssues(loadedConfig.notifications, repoRoot));
193
149
  for (const entry of layered.provenance) {
194
150
  notes.push(`Config layer [${entry.source}]: ${entry.path}`);
195
151
  }
@@ -446,7 +402,7 @@ export async function doctorProject(options = {}) {
446
402
  if (hasForbiddenShellOverrideLists(loadedConfig)) {
447
403
  if (options.dryRun !== true) {
448
404
  const stripped = stripForbiddenShellOverrideLists(loadedConfig);
449
- await writeConfigFile(repoRoot, stripped, adapterName);
405
+ await writeTrustedConfigFile(repoRoot, stripped, adapterName);
450
406
  loadedConfig = stripped;
451
407
  notes.push('Removed forbidden legacy shell override lists (overrides.allow / overrides.external).');
452
408
  for (let index = issues.length - 1; index >= 0; index -= 1) {
@@ -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;
@@ -0,0 +1,116 @@
1
+ import path from 'node:path';
2
+ import { getAdapterLayout } from '../adapters/layouts/index.js';
3
+ import { detectAdapterName, loadConfigFile } from '../config-io.js';
4
+ import { isGateRecord, parseTimestamp } from '../core/audit-query.js';
5
+ import { summarizeAuditVisibility } from '../core/audit-summary.js';
6
+ import { detectUndogfoodedLinkedWorktrees } from '../core/dogfood-environment.js';
7
+ import { matchesAuditCohort, resolveActiveAuditCohort } from '../runtime-provenance.js';
8
+ import { loadAuditRecords } from './audit.js';
9
+ function isDogfoodMode(config) {
10
+ return config.mode === 'audit' && config.policy?.unknownLocalEffect === 'deny';
11
+ }
12
+ function baseResult(repoRoot, since) {
13
+ return {
14
+ ok: false,
15
+ repoRoot,
16
+ since,
17
+ gateEvents: 0,
18
+ auditModeDenyCount: 0,
19
+ hostDeniedAfterAllowCount: 0,
20
+ shellPreToolUseCount: 0,
21
+ mismatchedCohortCount: 0,
22
+ environmentSkewCount: 0,
23
+ failures: [],
24
+ };
25
+ }
26
+ export async function checkDogfoodProject(options) {
27
+ const repoRoot = path.resolve(options.targetDir ?? process.cwd());
28
+ const since = options.since;
29
+ const result = baseResult(repoRoot, since);
30
+ const sinceMs = parseTimestamp(since);
31
+ if (sinceMs === null) {
32
+ result.failures.push('invalid_since');
33
+ return result;
34
+ }
35
+ const adapter = options.adapter ?? detectAdapterName(repoRoot);
36
+ const config = await loadConfigFile(repoRoot, adapter);
37
+ const dogfoodActive = isDogfoodMode(config);
38
+ if (!dogfoodActive) {
39
+ result.failures.push('dogfood_inactive');
40
+ }
41
+ const records = await loadAuditRecords(repoRoot, adapter);
42
+ const invalidTimestampRecord = records.some((record) => parseTimestamp(record.timestamp) === null);
43
+ if (invalidTimestampRecord) {
44
+ result.failures.push('invalid_timestamp_record');
45
+ }
46
+ const inWindowRecords = records.filter((record) => {
47
+ const recordMs = parseTimestamp(record.timestamp);
48
+ return recordMs !== null && recordMs >= sinceMs;
49
+ });
50
+ const activeCohort = await resolveActiveAuditCohort(repoRoot, config);
51
+ const inWindowGateRecords = inWindowRecords.filter((record) => isGateRecord(record));
52
+ let cohortGateRecords = inWindowGateRecords;
53
+ let cohortScopedRecords = inWindowRecords;
54
+ if (activeCohort) {
55
+ cohortGateRecords = inWindowGateRecords.filter((record) => matchesAuditCohort(record, activeCohort));
56
+ const cohortEventRecords = new Set(cohortGateRecords);
57
+ cohortScopedRecords = inWindowRecords.filter((record) => !isGateRecord(record) || cohortEventRecords.has(record));
58
+ result.mismatchedCohortCount = inWindowGateRecords.length - cohortGateRecords.length;
59
+ }
60
+ else {
61
+ result.mismatchedCohortCount = inWindowGateRecords.length;
62
+ if (inWindowGateRecords.length > 0) {
63
+ result.failures.push('active_cohort_unavailable');
64
+ }
65
+ }
66
+ result.gateEvents = cohortGateRecords.length;
67
+ result.auditModeDenyCount = cohortGateRecords.filter((record) => record.mode === 'audit' && record.permission === 'deny').length;
68
+ result.shellPreToolUseCount = cohortGateRecords.filter((record) => record.kind === 'shell' && record.event === 'preToolUse').length;
69
+ result.hostDeniedAfterAllowCount =
70
+ summarizeAuditVisibility(cohortScopedRecords).hostDeniedAfterAllowCount;
71
+ if (result.gateEvents === 0) {
72
+ result.failures.push('no_gate_events_since_cutoff');
73
+ }
74
+ if (result.auditModeDenyCount > 0) {
75
+ result.failures.push('audit_mode_permission_deny');
76
+ }
77
+ if (result.hostDeniedAfterAllowCount > 0) {
78
+ result.failures.push('host_denied_after_allow');
79
+ }
80
+ if (result.shellPreToolUseCount > 0) {
81
+ result.failures.push('shell_event_recorded_as_preToolUse');
82
+ }
83
+ if (result.mismatchedCohortCount > 0) {
84
+ result.failures.push('mismatched_active_cohort');
85
+ }
86
+ if (dogfoodActive) {
87
+ const environmentWarnings = await detectUndogfoodedLinkedWorktrees({
88
+ repoRoot,
89
+ adapterName: adapter,
90
+ layout: getAdapterLayout(adapter),
91
+ });
92
+ result.environmentSkewCount = environmentWarnings.length;
93
+ if (result.environmentSkewCount > 0) {
94
+ result.failures.push('environment_skew');
95
+ }
96
+ }
97
+ result.ok = result.failures.length === 0;
98
+ return result;
99
+ }
100
+ export function formatDogfoodCheckResult(result) {
101
+ const lines = [
102
+ `dogfood check for ${result.repoRoot}`,
103
+ `since: ${result.since}`,
104
+ `gate events: ${result.gateEvents}`,
105
+ `audit-mode deny count: ${result.auditModeDenyCount}`,
106
+ `host denied-after-allow count: ${result.hostDeniedAfterAllowCount}`,
107
+ `shell preToolUse count: ${result.shellPreToolUseCount}`,
108
+ `mismatched active cohort count: ${result.mismatchedCohortCount}`,
109
+ `environment skew count: ${result.environmentSkewCount}`,
110
+ `status: ${result.ok ? 'ok' : 'fail'}`,
111
+ ];
112
+ if (!result.ok) {
113
+ lines.push(`failures: ${result.failures.join(', ')}`);
114
+ }
115
+ return `${lines.join('\n')}\n`;
116
+ }
@@ -2,4 +2,5 @@ import { isDogfoodConfig, loadOperationalInsights } from '../operational-insight
2
2
  import type { DogfoodOptions, DogfoodResult } from '../types.js';
3
3
  export declare function dogfoodProject(options?: DogfoodOptions): Promise<DogfoodResult>;
4
4
  export declare function formatDogfoodResult(result: DogfoodResult): string;
5
+ export { checkDogfoodProject, formatDogfoodCheckResult } from './dogfood-check.js';
5
6
  export { isDogfoodConfig, loadOperationalInsights };
@@ -1,5 +1,5 @@
1
1
  import path from 'node:path';
2
- import { configPathFor, loadConfigFile, writeConfigFile } from '../config-io.js';
2
+ import { configPathFor, loadConfigFile, writeTrustedConfigFile } from '../config-io.js';
3
3
  import { mergeConfig } from '../core/config.js';
4
4
  import { isDogfoodConfig, loadOperationalInsights } from '../operational-insights.js';
5
5
  import { metricsProject } from './metrics.js';
@@ -19,7 +19,7 @@ export async function dogfoodProject(options = {}) {
19
19
  unknownLocalEffect: 'deny',
20
20
  },
21
21
  });
22
- await writeConfigFile(repoRoot, updated, adapter);
22
+ await writeTrustedConfigFile(repoRoot, updated, adapter);
23
23
  return {
24
24
  ok: true,
25
25
  repoRoot,
@@ -53,7 +53,7 @@ async function promoteDogfoodToEnforce(repoRoot, configPath, force, adapter = 'c
53
53
  ...existing,
54
54
  mode: 'enforce',
55
55
  });
56
- await writeConfigFile(repoRoot, updated, adapter);
56
+ await writeTrustedConfigFile(repoRoot, updated, adapter);
57
57
  return {
58
58
  ok: true,
59
59
  repoRoot,
@@ -68,4 +68,5 @@ async function promoteDogfoodToEnforce(repoRoot, configPath, force, adapter = 'c
68
68
  export function formatDogfoodResult(result) {
69
69
  return `${result.message}\n`;
70
70
  }
71
+ export { checkDogfoodProject, formatDogfoodCheckResult } from './dogfood-check.js';
71
72
  export { isDogfoodConfig, loadOperationalInsights };
@@ -1,7 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import { stdin as input, stdout as output } from 'node:process';
3
3
  import { createInterface } from 'node:readline/promises';
4
- import { configPathFor, loadApprovalState, loadConfigFile, repoLocalStateDirFor, resolveAdapterName, writeConfigFile, } from '../config-io.js';
4
+ import { configPathFor, loadApprovalState, loadConfigFile, repoLocalStateDirFor, resolveAdapterName, writeTrustedConfigFile, } from '../config-io.js';
5
5
  import { appendCliAuditEvent } from '../core/audit-io.js';
6
6
  import { JUDGE_CLOUD_CONSENT_REASON } from '../core/capability/reasons.js';
7
7
  import { belayStateDir, normalizeJudgeConfig } from '../core/config.js';
@@ -176,7 +176,7 @@ export async function judgeUse(options) {
176
176
  }
177
177
  const after = normalizeJudgeConfig(patch.judge);
178
178
  const updated = { ...config, judge: after };
179
- await writeConfigFile(repoRoot, updated, adapter);
179
+ await writeTrustedConfigFile(repoRoot, updated, adapter);
180
180
  await refreshIntegrityIfPinned(repoRoot, updated);
181
181
  await appendCliAuditEvent(repoRoot, updated, {
182
182
  event: 'judge_provider_changed',
@@ -5,7 +5,6 @@ import { protectedArtifactRoots } from '../adapters/layouts/protected-paths.js';
5
5
  import { ensureBelayStateDir, loadConfigFile } from '../config-io.js';
6
6
  import { compactApprovals, createApprovalRecordWithEnvelope } from '../core/approval.js';
7
7
  import { createGateApprovalStore } from '../core/approval-service.js';
8
- import { issueApprovalToken } from '../core/approval-token.js';
9
8
  import { appendCliAuditEvent } from '../core/audit-io.js';
10
9
  import { mutateApprovalStateWithRetry } from '../core/capability/approval-state-mutation.js';
11
10
  import { boundarySessionStatus } from '../core/capability/boundary-session.js';
@@ -253,20 +252,12 @@ export async function recoveryCheckpointCommand(options) {
253
252
  }
254
253
  const notificationConfigured = recoveryNotificationConfigured(config);
255
254
  if (notificationConfigured) {
256
- const approvalToken = await issueApprovalToken({
257
- approvalId: request.approvalId,
258
- fingerprint: request.fingerprint,
259
- repoRoot: request.repoRoot,
260
- issuedAt: request.createdAt,
261
- expiresAt: request.expiresAt,
262
- }, configuredControlPlaneDir(config));
263
255
  await notifyDeny(config.notifications, {
264
256
  approvalId: request.approvalId,
265
257
  reason: request.reason,
266
258
  summary: request.summary,
267
259
  repoRoot: request.repoRoot,
268
260
  fingerprint: request.fingerprint,
269
- approvalToken,
270
261
  });
271
262
  }
272
263
  const auditRecorded = await appendRecoveryAudit(repoRoot, config, {
@@ -287,8 +278,8 @@ export async function recoveryCheckpointCommand(options) {
287
278
  paths: binding.paths,
288
279
  auditRecorded,
289
280
  message: notificationConfigured
290
- ? `Signed out-of-band approval required for ${request.approvalId}. Use the token delivered through the configured notification channel, then run \`belay approve ${request.approvalId} --token <signed-token>\` and repeat this command.`
291
- : `${recoveryNotificationSetupWarning()} Pending approval id: ${request.approvalId}.`,
281
+ ? `Signed approval required for ${request.approvalId}. Run \`belay approval-token ${request.approvalId}\` locally, then \`belay approve ${request.approvalId} --token <signed-token>\`, and repeat this command.`
282
+ : `${recoveryNotificationSetupWarning()} Pending approval id: ${request.approvalId}. Run \`belay approval-token ${request.approvalId}\` locally to retrieve the signed token.`,
292
283
  };
293
284
  }
294
285
  try {
@@ -17,6 +17,7 @@ export declare function migrateControlPlaneApprovalsToRepoLocal(repoRoot: string
17
17
  export declare function loadLayeredConfig(repoRoot: string, adapter?: AdapterName): Promise<LayeredConfigResult>;
18
18
  export declare function loadConfigFile(repoRoot: string, adapter?: AdapterName): Promise<BelayConfigV3>;
19
19
  export declare function writeConfigFile(repoRoot: string, config: BelayConfigV3, adapter?: AdapterName): Promise<void>;
20
+ export declare function writeTrustedConfigFile(repoRoot: string, config: BelayConfigV3, adapter?: AdapterName): Promise<void>;
20
21
  export declare function mergeAndWriteConfig(repoRoot: string, adapter?: AdapterName): Promise<BelayConfigV3>;
21
22
  export declare function loadApprovalState(repoRoot: string, fileName: 'pending-approvals.json' | 'approved-approvals.json', config: BelayConfigV3): Promise<ApprovalStateFile>;
22
23
  export declare function saveApprovalState(repoRoot: string, fileName: 'pending-approvals.json' | 'approved-approvals.json', state: ApprovalStateFile, config: BelayConfigV3): Promise<void>;
package/dist/config-io.js CHANGED
@@ -7,6 +7,7 @@ import { compactApprovals, isExpired, mergeApprovalStates } from './core/approva
7
7
  import { mutateApprovalStateWithRetry } from './core/capability/approval-state-mutation.js';
8
8
  import { approvedApprovalsFile, belayStateDir, configuredControlPlaneDir, mergeConfig, pendingApprovalsFile, stripForbiddenShellOverrideLists, } from './core/config.js';
9
9
  import { resolveLayeredConfig, teamConfigPath, } from './core/config-layers.js';
10
+ import { trustRepoConfig } from './core/repo-config-trust.js';
10
11
  export function resolveAdapterName(config) {
11
12
  if (config.adapter === 'claude') {
12
13
  return 'claude';
@@ -221,6 +222,12 @@ export async function writeConfigFile(repoRoot, config, adapter = resolveAdapter
221
222
  await unlink(temporaryPath).catch(() => undefined);
222
223
  }
223
224
  }
225
+ export async function writeTrustedConfigFile(repoRoot, config, adapter = resolveAdapterName(config)) {
226
+ await writeConfigFile(repoRoot, config, adapter);
227
+ const configPath = configPathFor(repoRoot, adapter);
228
+ const parsed = JSON.parse(await readFile(configPath, 'utf8'));
229
+ await trustRepoConfig(repoRoot, adapter, parsed);
230
+ }
224
231
  export async function mergeAndWriteConfig(repoRoot, adapter = 'cursor') {
225
232
  const layout = getAdapterLayout(adapter);
226
233
  const configPath = layout.configPath(repoRoot);
@@ -229,7 +236,7 @@ export async function mergeAndWriteConfig(repoRoot, adapter = 'cursor') {
229
236
  existing = JSON.parse(await readFile(configPath, 'utf8'));
230
237
  }
231
238
  const merged = mergeConfig(existing, layout.defaultConfig(repoRoot));
232
- await writeConfigFile(repoRoot, merged, adapter);
239
+ await writeTrustedConfigFile(repoRoot, merged, adapter);
233
240
  await ensureBelayStateDir(merged, repoRoot);
234
241
  if (merged.controlPlane.enabled) {
235
242
  await migrateRepoLocalApprovalsToControlPlane(repoRoot, merged);