@guilz-dev/belay 0.9.2 → 0.9.4

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 (118) hide show
  1. package/README.md +36 -2
  2. package/dist/adapters/codex/hooks.d.ts +1 -0
  3. package/dist/adapters/codex/hooks.js +3 -0
  4. package/dist/adapters/codex/runtime-entry.d.ts +3 -0
  5. package/dist/adapters/codex/runtime-entry.js +27 -4
  6. package/dist/adapters/cursor/cwd-resolution.d.ts +10 -0
  7. package/dist/adapters/cursor/cwd-resolution.js +58 -0
  8. package/dist/adapters/cursor/dispatcher-generation.d.ts +3 -0
  9. package/dist/adapters/cursor/dispatcher-generation.js +5 -0
  10. package/dist/adapters/cursor/hook-dispatch-entry.d.ts +7 -0
  11. package/dist/adapters/cursor/hook-dispatch-entry.js +126 -0
  12. package/dist/adapters/cursor/hook-router.d.ts +23 -0
  13. package/dist/adapters/cursor/hook-router.js +266 -0
  14. package/dist/adapters/cursor/hooks.d.ts +5 -0
  15. package/dist/adapters/cursor/hooks.js +115 -3
  16. package/dist/adapters/cursor/routing-layout.d.ts +5 -0
  17. package/dist/adapters/cursor/routing-layout.js +29 -0
  18. package/dist/adapters/cursor/runtime-entry.d.ts +18 -0
  19. package/dist/adapters/cursor/runtime-entry.js +135 -34
  20. package/dist/adapters/layouts/scope.d.ts +5 -0
  21. package/dist/adapters/layouts/scope.js +71 -0
  22. package/dist/adapters/shared/gate-runtime.js +27 -3
  23. package/dist/adapters/shared/repo-root.js +20 -1
  24. package/dist/bundle/claude-runtime.mjs +2660 -1951
  25. package/dist/bundle/codex-runtime.mjs +2680 -1953
  26. package/dist/bundle/cursor-dispatcher.mjs +443 -0
  27. package/dist/bundle/cursor-runtime.mjs +5638 -4697
  28. package/dist/cli.js +33 -3
  29. package/dist/commands/doctor.js +215 -16
  30. package/dist/commands/health-snapshot.d.ts +3 -0
  31. package/dist/commands/health-snapshot.js +61 -0
  32. package/dist/commands/recovery-checkpoints.d.ts +3 -3
  33. package/dist/commands/report.js +14 -0
  34. package/dist/commands/status.js +15 -0
  35. package/dist/commands/where.d.ts +4 -0
  36. package/dist/commands/where.js +52 -0
  37. package/dist/config-io.js +11 -2
  38. package/dist/core/approval-repo-lookup.d.ts +16 -0
  39. package/dist/core/approval-repo-lookup.js +48 -0
  40. package/dist/core/audit-io.d.ts +1 -1
  41. package/dist/core/audit-io.js +1 -1
  42. package/dist/core/audit-legacy-archive.js +43 -25
  43. package/dist/core/audit-query.d.ts +1 -0
  44. package/dist/core/audit-query.js +7 -0
  45. package/dist/core/audit-serialize.d.ts +3 -0
  46. package/dist/core/audit-serialize.js +39 -3
  47. package/dist/core/audit-summary.d.ts +9 -0
  48. package/dist/core/audit-summary.js +58 -1
  49. package/dist/core/audit-types.d.ts +4 -0
  50. package/dist/core/classify-tool.js +16 -8
  51. package/dist/core/effect-ir/argv-delegate.d.ts +9 -0
  52. package/dist/core/effect-ir/argv-delegate.js +42 -0
  53. package/dist/core/effect-ir/shell-lower/argv-delegate-gate.d.ts +3 -0
  54. package/dist/core/effect-ir/shell-lower/argv-delegate-gate.js +40 -0
  55. package/dist/core/effect-ir/shell-lower/augment.d.ts +4 -0
  56. package/dist/core/effect-ir/shell-lower/augment.js +97 -0
  57. package/dist/core/effect-ir/shell-lower/context.d.ts +10 -0
  58. package/dist/core/effect-ir/shell-lower/context.js +1 -0
  59. package/dist/core/effect-ir/shell-lower/decode-process.d.ts +9 -0
  60. package/dist/core/effect-ir/shell-lower/decode-process.js +201 -0
  61. package/dist/core/effect-ir/shell-lower/decoders/belay.d.ts +2 -0
  62. package/dist/core/effect-ir/shell-lower/decoders/belay.js +29 -0
  63. package/dist/core/effect-ir/shell-lower/decoders/builtins.d.ts +7 -0
  64. package/dist/core/effect-ir/shell-lower/decoders/builtins.js +194 -0
  65. package/dist/core/effect-ir/shell-lower/decoders/decode-package-exec.d.ts +4 -0
  66. package/dist/core/effect-ir/shell-lower/decoders/decode-package-exec.js +68 -0
  67. package/dist/core/effect-ir/shell-lower/decoders/docker.d.ts +3 -0
  68. package/dist/core/effect-ir/shell-lower/decoders/docker.js +43 -0
  69. package/dist/core/effect-ir/shell-lower/decoders/filesystem.d.ts +9 -0
  70. package/dist/core/effect-ir/shell-lower/decoders/filesystem.js +217 -0
  71. package/dist/core/effect-ir/shell-lower/decoders/prisma.d.ts +2 -0
  72. package/dist/core/effect-ir/shell-lower/decoders/prisma.js +54 -0
  73. package/dist/core/effect-ir/shell-lower/decoders/ruby.d.ts +11 -0
  74. package/dist/core/effect-ir/shell-lower/decoders/ruby.js +147 -0
  75. package/dist/core/effect-ir/shell-lower/decoders/toolchain.d.ts +7 -0
  76. package/dist/core/effect-ir/shell-lower/decoders/toolchain.js +155 -0
  77. package/dist/core/effect-ir/shell-lower/requirement.d.ts +12 -0
  78. package/dist/core/effect-ir/shell-lower/requirement.js +65 -0
  79. package/dist/core/effect-ir/shell-lower/segment.d.ts +11 -0
  80. package/dist/core/effect-ir/shell-lower/segment.js +82 -0
  81. package/dist/core/effect-ir/shell-lower/tokens.d.ts +26 -0
  82. package/dist/core/effect-ir/shell-lower/tokens.js +183 -0
  83. package/dist/core/effect-ir/shell-lower.d.ts +2 -7
  84. package/dist/core/effect-ir/shell-lower.js +122 -1511
  85. package/dist/core/glob.js +32 -16
  86. package/dist/core/integrity.d.ts +2 -2
  87. package/dist/core/integrity.js +52 -12
  88. package/dist/core/replay-scrub.d.ts +3 -0
  89. package/dist/core/replay-scrub.js +30 -3
  90. package/dist/core/shell-substitution.js +2 -2
  91. package/dist/core/shell-tokenizer.d.ts +28 -0
  92. package/dist/core/shell-tokenizer.js +111 -29
  93. package/dist/core/shell-unparseable.js +3 -1
  94. package/dist/core/verdict/docker-compose-run.d.ts +18 -0
  95. package/dist/core/verdict/docker-compose-run.js +136 -0
  96. package/dist/core/verdict/launcher-resolve.js +28 -28
  97. package/dist/core/verdict/parser.d.ts +3 -0
  98. package/dist/core/verdict/parser.js +23 -40
  99. package/dist/core/verdict/recursive-invocation.d.ts +20 -0
  100. package/dist/core/verdict/recursive-invocation.js +224 -0
  101. package/dist/defaults.d.ts +7 -3
  102. package/dist/defaults.js +35 -21
  103. package/dist/installer/bootstrap.d.ts +1 -0
  104. package/dist/installer/bootstrap.js +2 -2
  105. package/dist/installer/runtime-artifacts.js +43 -13
  106. package/dist/installer/scope-config.d.ts +2 -2
  107. package/dist/installer.d.ts +10 -1
  108. package/dist/installer.js +83 -7
  109. package/dist/node-resolution.d.ts +1 -0
  110. package/dist/node-resolution.js +61 -0
  111. package/dist/templates.d.ts +7 -4
  112. package/dist/templates.js +42 -4
  113. package/dist/types.d.ts +35 -1
  114. package/dist/version.d.ts +1 -1
  115. package/dist/version.js +1 -1
  116. package/package.json +5 -2
  117. package/skills/belay/SKILL.md +5 -0
  118. package/skills/belay/belay-report.md +4 -1
package/dist/cli.js CHANGED
@@ -17,9 +17,10 @@ import { formatSessionStatusReport, sessionStartProject, sessionStatusProject, }
17
17
  import { formatSimulateReport, simulateProject } from './commands/simulate.js';
18
18
  import { revokeStandingAllow } from './commands/standing-allow.js';
19
19
  import { formatStatusReport, statusProject } from './commands/status.js';
20
+ import { formatWhereReport, whereProject } from './commands/where.js';
20
21
  import { loadConfigFile } from './config-io.js';
21
22
  import { rejectDeprecatedJudgeModelAuto } from './core/judge-model-policy.js';
22
- import { initProject, upgradeProject } from './installer.js';
23
+ import { initProject, uninstallProject, upgradeProject } from './installer.js';
23
24
  import { egressEnv, egressStatus, formatEgressStatusReport, startEgressProxy, stopEgressProxy, } from './services/egress-service.js';
24
25
  import { formatSandboxStatusReport, sandboxStatus } from './services/sandbox-service.js';
25
26
  import { PACKAGE_VERSION } from './version.js';
@@ -297,14 +298,17 @@ function parseArgs(argv) {
297
298
  }
298
299
  options.approveScope = next;
299
300
  }
300
- else if (command === 'init' || command === 'upgrade') {
301
+ else if (command === 'init' ||
302
+ command === 'upgrade' ||
303
+ command === 'uninstall' ||
304
+ command === 'where') {
301
305
  if (!next || !['project', 'global'].includes(next)) {
302
306
  throw new Error('--scope requires project or global.');
303
307
  }
304
308
  options.installScope = next;
305
309
  }
306
310
  else {
307
- throw new Error('--scope is only valid for init, upgrade, or approve.');
311
+ throw new Error('--scope is only valid for init, upgrade, uninstall, where, or approve.');
308
312
  }
309
313
  index += 1;
310
314
  continue;
@@ -540,6 +544,8 @@ Usage:
540
544
  (--adapter selects host; fresh init picks matching judge providerId: cursor/claude/codex)
541
545
  (--dogfood runs after --preset and sets mode: audit, overriding preset enforce mode)
542
546
  ${c} upgrade [--target <dir>] [--adapter cursor|claude|codex] [--scope project|global] [--with-skill] [--migrate-judge-default]
547
+ ${c} uninstall [--target <dir>] [--adapter cursor] [--scope project|global]
548
+ ${c} where [--target <dir>] [--adapter cursor|claude|codex] [--scope project|global] [--json]
543
549
  ${c} dogfood [--target <dir>] [--adapter cursor|claude|codex] [--enforce] [--force]
544
550
  ${c} doctor [--target <dir>] [--adapter cursor|claude|codex] [--json] [--fix] [--dry-run]
545
551
  ${c} metrics [--target <dir>] [--json]
@@ -634,6 +640,30 @@ async function main() {
634
640
  process.stdout.write(`Upgraded belay (${result.adapter}) in ${result.repoRoot}.\n`);
635
641
  return;
636
642
  }
643
+ if (command === 'uninstall') {
644
+ const result = await uninstallProject({
645
+ targetDir: options.targetDir,
646
+ adapter: options.adapter,
647
+ scope: options.installScope,
648
+ });
649
+ process.stdout.write(`Removed belay hooks (${result.adapter}, scope=${result.scope}) from ${result.repoRoot}.\n`);
650
+ return;
651
+ }
652
+ if (command === 'where') {
653
+ const report = await whereProject({
654
+ targetDir: options.targetDir,
655
+ adapter: options.adapter,
656
+ scope: options.installScope,
657
+ json: options.json,
658
+ });
659
+ if (options.json) {
660
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
661
+ }
662
+ else {
663
+ process.stdout.write(formatWhereReport(report));
664
+ }
665
+ return;
666
+ }
637
667
  if (command === 'judge') {
638
668
  const { runJudgeCommand } = await import('./commands/judge.js');
639
669
  if (!options.judgeSubcommand) {
@@ -1,9 +1,12 @@
1
- import { existsSync } from 'node:fs';
1
+ import { execFile } from 'node:child_process';
2
+ import { existsSync, realpathSync } from 'node:fs';
2
3
  import { readFile } from 'node:fs/promises';
3
4
  import path from 'node:path';
5
+ import { promisify } from 'node:util';
4
6
  import { getClaudeManagedHookEntries } from '../adapters/claude/hooks.js';
5
- import { getCodexManagedHookEntries } from '../adapters/codex/hooks.js';
6
- import { hasDuplicateCursorShellGates } from '../adapters/cursor/hooks.js';
7
+ import { codexHooksTomlIncludesCommand, getCodexManagedHookEntries, } from '../adapters/codex/hooks.js';
8
+ import { hasCurrentCursorDispatcherGeneration } from '../adapters/cursor/dispatcher-generation.js';
9
+ import { hasLegacyCursorDoubleShellGates, hasManagedCursorHookEntries, } from '../adapters/cursor/hooks.js';
7
10
  import { getAdapterLayout } from '../adapters/layouts/index.js';
8
11
  import { protectedArtifactRoots } from '../adapters/layouts/protected-paths.js';
9
12
  import { resolveScopedPaths } from '../adapters/layouts/scope.js';
@@ -15,7 +18,7 @@ import { detectFenceDrift, summarizeAuditVisibility } from '../core/audit-summar
15
18
  import { inspectBoundaryAttestationFile } from '../core/capability/boundary-attestation-sign.js';
16
19
  import { boundaryAttestationPath, boundarySessionStatus, } from '../core/capability/boundary-session.js';
17
20
  import { configuredControlPlaneDir, defaultControlPlaneDir, hasForbiddenShellOverrideLists, stripForbiddenShellOverrideLists, } from '../core/config.js';
18
- import { verifyIntegrityManifest } from '../core/integrity.js';
21
+ import { runtimeIntegrityFiles, verifyIntegrityManifest } from '../core/integrity.js';
19
22
  import { diagnoseJudge, stopJudgeSessionBrokers } from '../core/judge-doctor.js';
20
23
  import { resolveJudgeTransport } from '../core/judge-runtime-detection.js';
21
24
  import { listRecoveryCheckpoints } from '../core/recovery/checkpoint.js';
@@ -33,6 +36,7 @@ import { PACKAGE_VERSION } from '../version.js';
33
36
  import { loadAuditRecords } from './audit.js';
34
37
  import { collectHealthSnapshot } from './health-snapshot.js';
35
38
  import { metricsProject } from './metrics.js';
39
+ const execFileAsync = promisify(execFile);
36
40
  function resolveDoctorAdapter(options, configAdapter) {
37
41
  if (options.adapter) {
38
42
  return options.adapter;
@@ -42,6 +46,120 @@ function resolveDoctorAdapter(options, configAdapter) {
42
46
  }
43
47
  return 'cursor';
44
48
  }
49
+ function hasCursorGlobalWorkspaceResolver(runtimeSource) {
50
+ // Cursor global hooks must derive workspace cwd from payload fields, not process cwd.
51
+ const hasScopedToolResolver = runtimeSource.includes('resolveCursorToolActionCwd') ||
52
+ runtimeSource.includes('includeToolInputCwd');
53
+ return (runtimeSource.includes('resolveCursorActionCwd') &&
54
+ hasScopedToolResolver &&
55
+ runtimeSource.includes('workspace_roots') &&
56
+ runtimeSource.includes('working_directory') &&
57
+ runtimeSource.includes('tool_input'));
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
+ }
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
+ }
45
163
  export async function doctorProject(options = {}) {
46
164
  const repoRoot = path.resolve(options.targetDir ?? process.cwd());
47
165
  const issues = [];
@@ -97,7 +215,9 @@ export async function doctorProject(options = {}) {
97
215
  hooksPath = scopedPaths.hooksSettingsPath;
98
216
  corePath = path.join(scopedPaths.runtimeDir, 'core.mjs');
99
217
  notes.push(installScope === 'global'
100
- ? `Install scope: global (hooks/runtime at ${scopedPaths.hooksDir})`
218
+ ? adapterName === 'cursor'
219
+ ? `Install scope: global (hooks/runtime at ${scopedPaths.hooksDir}). To remove global hooks: belay uninstall --scope global`
220
+ : `Install scope: global (hooks/runtime at ${scopedPaths.hooksDir})`
101
221
  : 'Install scope: project');
102
222
  notes.push(`Config mode: ${loadedConfig.mode}`);
103
223
  const dogfoodActive = loadedConfig.mode === 'audit' && loadedConfig.policy.unknownLocalEffect === 'deny';
@@ -133,9 +253,9 @@ export async function doctorProject(options = {}) {
133
253
  }
134
254
  if (loadedConfig.controlPlane.integrity === 'hash-pinned') {
135
255
  notes.push('Integrity: hash-pinned (verify with belay upgrade after runtime changes).');
136
- const integrity = await verifyIntegrityManifest(repoRoot, activeLayout);
256
+ const integrity = await verifyIntegrityManifest(repoRoot, activeLayout, runtimeIntegrityFiles(activeLayout, scopedPaths));
137
257
  if (!integrity.ok) {
138
- issues.push(`Integrity verification failed: ${integrity.mismatches.slice(0, 3).join(', ')}`);
258
+ issues.push(`Integrity verification failed: ${integrity.mismatches.join(', ')}`);
139
259
  }
140
260
  }
141
261
  }
@@ -151,14 +271,16 @@ export async function doctorProject(options = {}) {
151
271
  const repoLocalDir = loadedConfig
152
272
  ? repoLocalStateDirFor(repoRoot, loadedConfig)
153
273
  : activeLayout.repoLocalStateDir(repoRoot);
154
- const requiredPaths = [
155
- path.join(hooksDir, 'belay-runner'),
156
- path.join(hooksDir, 'belay-runner.cmd'),
274
+ const routingOwnerPaths = [
275
+ path.join(hooksDir, runnerFileName(adapterName, process.platform)),
157
276
  path.join(hooksDir, 'belay-before-submit.mjs'),
158
277
  path.join(hooksDir, 'belay-shell-gate.mjs'),
159
278
  path.join(hooksDir, 'belay-tool-gate.mjs'),
160
279
  path.join(hooksDir, 'belay-audit.mjs'),
280
+ ...(adapterName === 'cursor' ? [path.join(scopedPaths.runtimeDir, 'dispatcher.mjs')] : []),
161
281
  corePath,
282
+ ];
283
+ const operationalPaths = [
162
284
  loadedConfig
163
285
  ? pendingApprovalsPath(repoRoot, loadedConfig)
164
286
  : path.join(repoLocalDir, 'pending-approvals.json'),
@@ -167,9 +289,67 @@ export async function doctorProject(options = {}) {
167
289
  : path.join(repoLocalDir, 'approved-approvals.json'),
168
290
  path.join(repoRoot, loadedConfig?.audit.logPath ?? activeLayout.defaultAuditLogPath(repoRoot)),
169
291
  ];
292
+ const requiredPaths = [...routingOwnerPaths, ...operationalPaths];
170
293
  for (const requiredPath of requiredPaths) {
171
294
  if (!existsSync(requiredPath)) {
172
295
  issues.push(`Missing generated file: ${requiredPath}`);
296
+ if (adapterName === 'cursor' && routingOwnerPaths.includes(requiredPath)) {
297
+ issues.push(`Cursor intended ${installScope} owner is incomplete: missing ${requiredPath}. Run belay upgrade --scope ${installScope}.`);
298
+ }
299
+ }
300
+ }
301
+ if (adapterName === 'cursor') {
302
+ issues.push(...(await cursorOriginIssues(hooksDir, installScope, repoRoot)));
303
+ const dispatcherPath = path.join(scopedPaths.runtimeDir, 'dispatcher.mjs');
304
+ if (existsSync(dispatcherPath) &&
305
+ !hasCurrentCursorDispatcherGeneration(await readFile(dispatcherPath, 'utf8'))) {
306
+ issues.push(`Cursor router generation mismatch for intended ${installScope} owner dispatcher: ${dispatcherPath}. Run belay upgrade --scope ${installScope}.`);
307
+ }
308
+ if (installScope === 'project') {
309
+ const globalPaths = resolveScopedPaths(activeLayout, 'global', repoRoot);
310
+ if (existsSync(globalPaths.hooksSettingsPath)) {
311
+ try {
312
+ const { loadHooksFile } = await import('../installer.js');
313
+ const globalHooks = await loadHooksFile(globalPaths.hooksSettingsPath);
314
+ if (hasManagedCursorHookEntries(globalHooks, process.platform, globalPaths.hooksDir, repoRoot)) {
315
+ const globalOwnerFiles = [
316
+ path.join(globalPaths.hooksDir, runnerFileName('cursor', process.platform)),
317
+ path.join(globalPaths.runtimeDir, 'core.mjs'),
318
+ path.join(globalPaths.runtimeDir, 'dispatcher.mjs'),
319
+ ...CURSOR_HOOK_SHIMS.map((fileName) => path.join(globalPaths.hooksDir, fileName)),
320
+ ];
321
+ const globalOriginProblems = await cursorOriginIssues(globalPaths.hooksDir, 'global', repoRoot);
322
+ issues.push(...globalOriginProblems);
323
+ const globalProblems = [
324
+ ...globalOwnerFiles.filter((filePath) => !existsSync(filePath)),
325
+ ...globalOriginProblems,
326
+ ];
327
+ const globalDispatcherPath = path.join(globalPaths.runtimeDir, 'dispatcher.mjs');
328
+ if (existsSync(globalDispatcherPath)) {
329
+ try {
330
+ if (!hasCurrentCursorDispatcherGeneration(await readFile(globalDispatcherPath, 'utf8'))) {
331
+ globalProblems.push(globalDispatcherPath);
332
+ }
333
+ }
334
+ catch (error) {
335
+ const detail = error instanceof Error ? error.message : String(error);
336
+ issues.push(`Unable to inspect shadowed global Cursor dispatcher at ${globalDispatcherPath}: ${detail}.`);
337
+ globalProblems.push(globalDispatcherPath);
338
+ }
339
+ }
340
+ if (globalProblems.length > 0) {
341
+ issues.push('Global Cursor installation cannot yield to the project owner. Run belay upgrade from this project to refresh the global router.');
342
+ }
343
+ else {
344
+ notes.push('Healthy global Cursor install is shadowed by this project owner and will return neutral for this repository.');
345
+ }
346
+ }
347
+ }
348
+ catch (error) {
349
+ const detail = error instanceof Error ? error.message : String(error);
350
+ issues.push(`Unable to inspect shadowed global Cursor hooks at ${globalPaths.hooksSettingsPath}: ${detail}.`);
351
+ }
352
+ }
173
353
  }
174
354
  }
175
355
  let hooksOk = true;
@@ -184,15 +364,17 @@ export async function doctorProject(options = {}) {
184
364
  const hooksFile = await loadHooksFile(hooksPath);
185
365
  for (const { event, definition } of managedEntries) {
186
366
  const entries = hooksFile.hooks[event] ?? [];
187
- const present = entries.some((entry) => entry.command === definition.command && entry.matcher === definition.matcher);
188
- if (!present) {
367
+ const matchingEntry = entries.find((entry) => entry.command === definition.command && entry.matcher === definition.matcher);
368
+ if (!matchingEntry || matchingEntry.failClosed !== true) {
189
369
  hooksOk = false;
190
370
  const matcherSuffix = definition.matcher ? ` (matcher: ${definition.matcher})` : '';
191
- issues.push(`Missing managed hook for ${event}: ${definition.command}${matcherSuffix}`);
371
+ issues.push(matchingEntry
372
+ ? `Managed Cursor hook for ${event} must set failClosed: true: ${definition.command}${matcherSuffix}`
373
+ : `Missing managed hook for ${event}: ${definition.command}${matcherSuffix}`);
192
374
  }
193
375
  }
194
- if (hasDuplicateCursorShellGates(hooksFile, process.platform, hooksDir, repoRoot)) {
195
- warnings.push('Duplicate Cursor Shell preToolUse gates detected. Run belay upgrade to dedupe managed hooks.');
376
+ if (hasLegacyCursorDoubleShellGates(hooksFile, process.platform, hooksDir, repoRoot)) {
377
+ warnings.push('Legacy Cursor Shell preToolUse gate detected alongside beforeShellExecution. Run belay upgrade to remove the managed Shell matcher.');
196
378
  }
197
379
  }
198
380
  else if (adapterName === 'codex') {
@@ -200,7 +382,7 @@ export async function doctorProject(options = {}) {
200
382
  // are present in the rendered TOML block.
201
383
  const toml = await readFile(hooksPath, 'utf8');
202
384
  for (const { event, definition } of managedEntries) {
203
- if (!toml.includes(definition.command)) {
385
+ if (!codexHooksTomlIncludesCommand(toml, definition.command)) {
204
386
  hooksOk = false;
205
387
  issues.push(`Missing Codex managed hook for ${event}: ${definition.command}`);
206
388
  }
@@ -248,6 +430,17 @@ export async function doctorProject(options = {}) {
248
430
  if (runtimeVersions.stamp?.startsWith(`${PACKAGE_VERSION}@`)) {
249
431
  notes.push(`Runtime version matches package (${PACKAGE_VERSION}).`);
250
432
  }
433
+ if (adapterName === 'cursor' && installScope === 'global') {
434
+ try {
435
+ const runtimeSource = await readFile(corePath, 'utf8');
436
+ if (!hasCursorGlobalWorkspaceResolver(runtimeSource)) {
437
+ warnings.push('Global Cursor runtime appears to resolve hook context from hook process cwd. Per-repository belay.config.json can be bypassed (audit may act as enforce). Run belay upgrade --scope global from the latest package.');
438
+ }
439
+ }
440
+ catch {
441
+ warnings.push('Unable to inspect global Cursor runtime for workspace-cwd resolution. Run belay upgrade --scope global from the latest package.');
442
+ }
443
+ }
251
444
  }
252
445
  if (options.fix && loadedConfig) {
253
446
  if (hasForbiddenShellOverrideLists(loadedConfig)) {
@@ -323,6 +516,12 @@ export async function doctorProject(options = {}) {
323
516
  };
324
517
  if (dogfood.active) {
325
518
  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.`);
519
+ notes.push(`Host denied after Belay allow (cohort): ${auditVisibility.hostDeniedAfterAllowCount}.`);
520
+ warnings.push(...(await detectUndogfoodedLinkedWorktrees({
521
+ repoRoot,
522
+ adapterName,
523
+ layout: activeLayout,
524
+ })));
326
525
  if (dogfood.readyForEnforce) {
327
526
  notes.push('Dogfood metrics suggest enforce mode is ready (belay dogfood --enforce).');
328
527
  }
@@ -1,5 +1,8 @@
1
1
  import type { AdapterName } from '../adapters/layouts/types.js';
2
2
  import type { HealthSnapshot, HealthSnapshotOptions } from '../types.js';
3
+ export declare function cursorCliConfigPaths(homeDir: string, env: Partial<Pick<NodeJS.ProcessEnv, 'CURSOR_CONFIG_DIR' | 'XDG_CONFIG_HOME'>>): string[];
4
+ export declare function cursorApprovalModeRequiresHostDenialWarning(approvalMode: unknown): boolean;
5
+ export declare function formatCursorApprovalModeLabel(approvalMode: unknown): string;
3
6
  /** Lightweight floor check without judge doctor / sandbox probes. */
4
7
  export declare function isBelayFloorInstalled(options?: {
5
8
  targetDir?: string;
@@ -48,6 +48,58 @@ async function managedHooksPresent(adapter, hooksPath, hooksDir, repoRoot) {
48
48
  }
49
49
  return managedEntries.every(({ definition }) => content.includes(definition.command));
50
50
  }
51
+ /** Cursor modes confirmed not to apply host-level denial after Belay allows. */
52
+ const CURSOR_APPROVAL_MODES_WITHOUT_HOST_DENIAL = new Set(['unrestricted']);
53
+ export function cursorCliConfigPaths(homeDir, env) {
54
+ const candidates = [];
55
+ const addCandidate = (candidate) => {
56
+ if (!candidates.includes(candidate)) {
57
+ candidates.push(candidate);
58
+ }
59
+ };
60
+ const cursorConfigDir = env.CURSOR_CONFIG_DIR?.trim();
61
+ if (cursorConfigDir) {
62
+ addCandidate(path.join(cursorConfigDir, 'cli-config.json'));
63
+ }
64
+ const xdgConfigHome = env.XDG_CONFIG_HOME?.trim();
65
+ if (xdgConfigHome) {
66
+ addCandidate(path.join(xdgConfigHome, 'cursor', 'cli-config.json'));
67
+ }
68
+ addCandidate(path.join(homeDir, '.cursor', 'cli-config.json'));
69
+ return candidates;
70
+ }
71
+ export function cursorApprovalModeRequiresHostDenialWarning(approvalMode) {
72
+ if (typeof approvalMode !== 'string' || !approvalMode.trim()) {
73
+ return true;
74
+ }
75
+ return !CURSOR_APPROVAL_MODES_WITHOUT_HOST_DENIAL.has(approvalMode);
76
+ }
77
+ export function formatCursorApprovalModeLabel(approvalMode) {
78
+ if (typeof approvalMode !== 'string' || !approvalMode.trim()) {
79
+ return 'default (prompts on each action)';
80
+ }
81
+ return approvalMode;
82
+ }
83
+ async function cursorRunModeRiskSignal(homeDir, belayMode, env) {
84
+ const configPath = cursorCliConfigPaths(homeDir, env).find((candidate) => existsSync(candidate));
85
+ if (!configPath) {
86
+ return null;
87
+ }
88
+ try {
89
+ const config = JSON.parse(await readFile(configPath, 'utf8'));
90
+ if (!cursorApprovalModeRequiresHostDenialWarning(config.approvalMode)) {
91
+ return null;
92
+ }
93
+ const approvalModeLabel = formatCursorApprovalModeLabel(config.approvalMode);
94
+ const auditGuidance = belayMode === 'audit'
95
+ ? ' Belay is in audit mode; keep the Cursor protection or approve the exact host prompt, and do not switch Cursor to unrestricted until Belay enforce mode and fail-closed hook health are verified.'
96
+ : ' If Belay is intended to be authoritative, verify fail-closed hook health before configuring Cursor unrestricted mode.';
97
+ return `Cursor approval mode is ${approvalModeLabel}; Cursor can deny shell actions after Belay allows them. Such a denial is a host-policy denial, not a Belay approval.${auditGuidance}`;
98
+ }
99
+ catch {
100
+ return null;
101
+ }
102
+ }
51
103
  /** Lightweight floor check without judge doctor / sandbox probes. */
52
104
  export async function isBelayFloorInstalled(options = {}) {
53
105
  const repoRoot = path.resolve(options.targetDir ?? process.cwd());
@@ -125,6 +177,15 @@ export async function collectHealthSnapshot(options = {}) {
125
177
  if (layered.config.judge.provider === 'openai-compatible') {
126
178
  additionalRiskSignals.push('cloud judge enabled: redacted command text may be sent to an external provider');
127
179
  }
180
+ if (adapter === 'cursor') {
181
+ const cursorRunModeRisk = await cursorRunModeRiskSignal(options.homeDir ?? os.homedir(), layered.config.mode, options.cursorConfigEnv ?? {
182
+ CURSOR_CONFIG_DIR: process.env.CURSOR_CONFIG_DIR,
183
+ XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,
184
+ });
185
+ if (cursorRunModeRisk) {
186
+ additionalRiskSignals.push(cursorRunModeRisk);
187
+ }
188
+ }
128
189
  }
129
190
  catch {
130
191
  configPresent = false;
@@ -86,7 +86,7 @@ export declare function recoveryCheckpointCommand(options: {
86
86
  state: import("../core/recovery/types.js").RecoveryCheckpointStateV1;
87
87
  manifestHash: string;
88
88
  receipt: import("../core/index.js").RecoveryReceiptV1 | undefined;
89
- repoRoot?: undefined;
89
+ readonly repoRoot?: undefined;
90
90
  backend?: undefined;
91
91
  availableBackends?: undefined;
92
92
  backendCounts?: undefined;
@@ -118,7 +118,7 @@ export declare function recoveryCheckpointCommand(options: {
118
118
  paths: string[];
119
119
  auditRecorded: boolean;
120
120
  message: string;
121
- repoRoot?: undefined;
121
+ readonly repoRoot?: undefined;
122
122
  backend?: undefined;
123
123
  availableBackends?: undefined;
124
124
  backendCounts?: undefined;
@@ -146,7 +146,7 @@ export declare function recoveryCheckpointCommand(options: {
146
146
  subcommand: string;
147
147
  verdict: string;
148
148
  checkpointId: string;
149
- repoRoot?: undefined;
149
+ readonly repoRoot?: undefined;
150
150
  backend?: undefined;
151
151
  availableBackends?: undefined;
152
152
  backendCounts?: undefined;
@@ -26,6 +26,7 @@ export async function reportProject(options = {}) {
26
26
  };
27
27
  }
28
28
  export function formatReport(report) {
29
+ const recentHostDenials = report.recentHostDenials ?? [];
29
30
  const lines = [
30
31
  `belay report for ${report.repoRoot}`,
31
32
  `Audit log: ${report.auditLogPath}`,
@@ -34,6 +35,10 @@ export function formatReport(report) {
34
35
  ...formatAskBreakdown(report),
35
36
  `Flag (allow_flagged): ${report.flagCount}`,
36
37
  `Allow (silent pass): ${report.allowCount}`,
38
+ `Host denied after Belay allow: ${report.hostDeniedAfterAllowCount ?? 0}`,
39
+ ...(report.unrecognizedHostFailureCount && report.unrecognizedHostFailureCount > 0
40
+ ? [`Unrecognized host tool failures: ${report.unrecognizedHostFailureCount}`]
41
+ : []),
37
42
  `Silent-pass rate: ${(report.silentPassRate * 100).toFixed(1)}%`,
38
43
  '',
39
44
  ];
@@ -51,6 +56,15 @@ export function formatReport(report) {
51
56
  }
52
57
  lines.push('');
53
58
  }
59
+ if (recentHostDenials.length > 0) {
60
+ lines.push('Host denials after Belay allow:');
61
+ for (const denial of recentHostDenials) {
62
+ const when = denial.failureTimestamp ?? 'unknown-time';
63
+ const detail = denial.errorMessage ? ` — ${denial.errorMessage}` : '';
64
+ lines.push(`- [${when}] ${denial.summary}${detail}`);
65
+ }
66
+ lines.push('');
67
+ }
54
68
  if (report.recentAsks.length === 0) {
55
69
  lines.push('No recent asks in the selected period.');
56
70
  }
@@ -31,6 +31,7 @@ export async function statusProject(options = {}) {
31
31
  };
32
32
  }
33
33
  export function formatStatusReport(report) {
34
+ const recentHostDenials = report.visibility.recentHostDenials ?? [];
34
35
  const { health } = report;
35
36
  const lines = [
36
37
  `belay status for ${report.repoRoot}`,
@@ -64,6 +65,11 @@ export function formatStatusReport(report) {
64
65
  ...formatAskBreakdown(report.visibility, ' '),
65
66
  ` Flag (allow_flagged): ${report.visibility.flagCount}`,
66
67
  ` Allow (silent pass): ${report.visibility.allowCount}`,
68
+ ` Host denied after Belay allow: ${report.visibility.hostDeniedAfterAllowCount ?? 0}`,
69
+ ...(report.visibility.unrecognizedHostFailureCount &&
70
+ report.visibility.unrecognizedHostFailureCount > 0
71
+ ? [` Unrecognized host tool failures: ${report.visibility.unrecognizedHostFailureCount}`]
72
+ : []),
67
73
  ` Silent-pass rate: ${(report.visibility.silentPassRate * 100).toFixed(1)}%`,
68
74
  '',
69
75
  ];
@@ -81,6 +87,15 @@ export function formatStatusReport(report) {
81
87
  }
82
88
  lines.push('');
83
89
  }
90
+ if (recentHostDenials.length > 0) {
91
+ lines.push('Host denials after Belay allow:');
92
+ for (const denial of recentHostDenials.slice(0, 5)) {
93
+ const when = denial.failureTimestamp ?? 'unknown-time';
94
+ const detail = denial.errorMessage ? ` — ${denial.errorMessage}` : '';
95
+ lines.push(`- [${when}] ${denial.summary}${detail}`);
96
+ }
97
+ lines.push('');
98
+ }
84
99
  if (report.visibility.recentAsks.length > 0) {
85
100
  lines.push('Recent asks:');
86
101
  for (const ask of report.visibility.recentAsks.slice(0, 5)) {
@@ -0,0 +1,4 @@
1
+ import type { WhereOptions, WhereReport } from '../types.js';
2
+ export declare function resolveCliPackageRoot(): string;
3
+ export declare function whereProject(options?: WhereOptions): Promise<WhereReport>;
4
+ export declare function formatWhereReport(report: WhereReport): string;
@@ -0,0 +1,52 @@
1
+ import { existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { getAdapterLayout } from '../adapters/layouts/index.js';
5
+ import { resolveScopedPaths } from '../adapters/layouts/scope.js';
6
+ import { detectAdapterName } from '../config-io.js';
7
+ import { resolveOperationScope } from '../installer/scope-config.js';
8
+ export function resolveCliPackageRoot() {
9
+ return path.resolve(fileURLToPath(new URL('../..', import.meta.url)));
10
+ }
11
+ export async function whereProject(options = {}) {
12
+ const cwd = process.cwd();
13
+ const repoRoot = path.resolve(options.targetDir ?? cwd);
14
+ const adapter = options.adapter ?? detectAdapterName(repoRoot);
15
+ const scope = await resolveOperationScope(repoRoot, adapter, options);
16
+ const paths = resolveScopedPaths(getAdapterLayout(adapter), scope, repoRoot);
17
+ const configPresent = existsSync(paths.configPath);
18
+ return {
19
+ cwd,
20
+ repoRoot,
21
+ adapter,
22
+ installScope: scope,
23
+ configPresent,
24
+ cliExecutable: process.argv[1] ? path.resolve(process.argv[1]) : undefined,
25
+ cliPackageRoot: resolveCliPackageRoot(),
26
+ configPath: paths.configPath,
27
+ hooksSettingsPath: paths.hooksSettingsPath,
28
+ hooksDir: paths.hooksDir,
29
+ runtimeDir: paths.runtimeDir,
30
+ skillsDir: paths.skillsDir,
31
+ commandsDir: paths.commandsDir,
32
+ };
33
+ }
34
+ export function formatWhereReport(report) {
35
+ const lines = [
36
+ `cwd: ${report.cwd}`,
37
+ `target dir: ${report.repoRoot}`,
38
+ `adapter: ${report.adapter} (scope=${report.installScope})`,
39
+ `config present: ${report.configPresent ? 'yes' : 'no'}`,
40
+ `cli executable: ${report.cliExecutable ?? '(unknown)'}`,
41
+ `cli package: ${report.cliPackageRoot}`,
42
+ `config: ${report.configPath}`,
43
+ `hooks settings: ${report.hooksSettingsPath}`,
44
+ `hooks: ${report.hooksDir}`,
45
+ `runtime: ${report.runtimeDir}`,
46
+ `skills: ${report.skillsDir}`,
47
+ ];
48
+ if (report.commandsDir) {
49
+ lines.push(`commands: ${report.commandsDir}`);
50
+ }
51
+ return `${lines.join('\n')}\n`;
52
+ }
package/dist/config-io.js CHANGED
@@ -209,8 +209,17 @@ export async function loadConfigFile(repoRoot, adapter = detectAdapterName(repoR
209
209
  }
210
210
  export async function writeConfigFile(repoRoot, config, adapter = resolveAdapterName(config)) {
211
211
  const configPath = configPathFor(repoRoot, adapter);
212
- await mkdir(path.dirname(configPath), { recursive: true });
213
- await writeFile(configPath, `${JSON.stringify(stripForbiddenShellOverrideLists(config), null, 2)}\n`, 'utf8');
212
+ const directory = path.dirname(configPath);
213
+ const temporaryPath = path.join(directory, `.${path.basename(configPath)}.${process.pid}.${randomUUID()}.tmp`);
214
+ const content = `${JSON.stringify(stripForbiddenShellOverrideLists(config), null, 2)}\n`;
215
+ await mkdir(directory, { recursive: true });
216
+ try {
217
+ await writeFile(temporaryPath, content, { encoding: 'utf8', flag: 'wx' });
218
+ await rename(temporaryPath, configPath);
219
+ }
220
+ finally {
221
+ await unlink(temporaryPath).catch(() => undefined);
222
+ }
214
223
  }
215
224
  export async function mergeAndWriteConfig(repoRoot, adapter = 'cursor') {
216
225
  const layout = getAdapterLayout(adapter);