@guilz-dev/belay 0.9.1 → 0.9.3

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 (61) hide show
  1. package/README.md +1 -1
  2. package/dist/adapters/codex/runtime-entry.d.ts +3 -0
  3. package/dist/adapters/codex/runtime-entry.js +27 -4
  4. package/dist/adapters/cursor/cwd-resolution.d.ts +10 -0
  5. package/dist/adapters/cursor/cwd-resolution.js +58 -0
  6. package/dist/adapters/cursor/hooks.d.ts +5 -3
  7. package/dist/adapters/cursor/hooks.js +29 -20
  8. package/dist/adapters/cursor/runtime-entry.d.ts +1 -0
  9. package/dist/adapters/cursor/runtime-entry.js +107 -6
  10. package/dist/adapters/shared/gate-runtime.js +26 -3
  11. package/dist/adapters/shared/repo-root.js +20 -1
  12. package/dist/bundle/claude-runtime.mjs +1225 -287
  13. package/dist/bundle/codex-runtime.mjs +1247 -291
  14. package/dist/bundle/cursor-runtime.mjs +4320 -3154
  15. package/dist/cli.js +33 -3
  16. package/dist/commands/doctor.js +38 -9
  17. package/dist/commands/health-snapshot.d.ts +3 -0
  18. package/dist/commands/health-snapshot.js +56 -0
  19. package/dist/commands/report.js +14 -0
  20. package/dist/commands/status.js +15 -0
  21. package/dist/commands/where.d.ts +4 -0
  22. package/dist/commands/where.js +52 -0
  23. package/dist/core/approval-repo-lookup.d.ts +16 -0
  24. package/dist/core/approval-repo-lookup.js +48 -0
  25. package/dist/core/audit-io.d.ts +1 -1
  26. package/dist/core/audit-io.js +1 -1
  27. package/dist/core/audit-legacy-archive.d.ts +1 -0
  28. package/dist/core/audit-legacy-archive.js +5 -0
  29. package/dist/core/audit-query.d.ts +1 -0
  30. package/dist/core/audit-query.js +7 -0
  31. package/dist/core/audit-serialize.d.ts +3 -0
  32. package/dist/core/audit-serialize.js +39 -3
  33. package/dist/core/audit-summary.d.ts +9 -0
  34. package/dist/core/audit-summary.js +58 -1
  35. package/dist/core/audit-types.d.ts +4 -0
  36. package/dist/core/effect-ir/shell-lower.js +283 -27
  37. package/dist/core/replay-scrub.d.ts +1 -0
  38. package/dist/core/replay-scrub.js +22 -3
  39. package/dist/core/shell-tokenizer.d.ts +28 -0
  40. package/dist/core/shell-tokenizer.js +111 -29
  41. package/dist/core/verdict/docker-compose-run.d.ts +18 -0
  42. package/dist/core/verdict/docker-compose-run.js +136 -0
  43. package/dist/core/verdict/launcher-resolve.js +66 -25
  44. package/dist/core/verdict/makefile-expand.d.ts +4 -0
  45. package/dist/core/verdict/makefile-expand.js +151 -0
  46. package/dist/core/verdict/parser.d.ts +4 -0
  47. package/dist/core/verdict/parser.js +25 -30
  48. package/dist/core/verdict/recursive-invocation.d.ts +20 -0
  49. package/dist/core/verdict/recursive-invocation.js +224 -0
  50. package/dist/corpus/benign-probe-cores.d.ts +1 -1
  51. package/dist/corpus/benign-probe-cores.js +2 -0
  52. package/dist/defaults.js +16 -0
  53. package/dist/installer/scope-config.d.ts +2 -2
  54. package/dist/installer.d.ts +10 -1
  55. package/dist/installer.js +43 -2
  56. package/dist/types.d.ts +34 -1
  57. package/dist/version.d.ts +1 -1
  58. package/dist/version.js +1 -1
  59. package/package.json +5 -2
  60. package/skills/belay/SKILL.md +5 -0
  61. 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) {
@@ -10,6 +10,7 @@ import { resolveScopedPaths } from '../adapters/layouts/scope.js';
10
10
  import { cleanupOrphanApprovalState } from '../cleanup-orphans.js';
11
11
  import { approvedApprovalsPath, belayStateDir, detectAdapterName, loadLayeredConfig, pendingApprovalsPath, repoLocalStateDirFor, writeConfigFile, } from '../config-io.js';
12
12
  import { approvalSigningKeyPath } from '../core/approval-token.js';
13
+ import { auditRecordHasLegacyCorrelationPlaceholders } from '../core/audit-legacy-archive.js';
13
14
  import { detectFenceDrift, summarizeAuditVisibility } from '../core/audit-summary.js';
14
15
  import { inspectBoundaryAttestationFile } from '../core/capability/boundary-attestation-sign.js';
15
16
  import { boundaryAttestationPath, boundarySessionStatus, } from '../core/capability/boundary-session.js';
@@ -41,6 +42,16 @@ function resolveDoctorAdapter(options, configAdapter) {
41
42
  }
42
43
  return 'cursor';
43
44
  }
45
+ function hasCursorGlobalWorkspaceResolver(runtimeSource) {
46
+ // Cursor global hooks must derive workspace cwd from payload fields, not process cwd.
47
+ const hasScopedToolResolver = runtimeSource.includes('resolveCursorToolActionCwd') ||
48
+ runtimeSource.includes('includeToolInputCwd');
49
+ return (runtimeSource.includes('resolveCursorActionCwd') &&
50
+ hasScopedToolResolver &&
51
+ runtimeSource.includes('workspace_roots') &&
52
+ runtimeSource.includes('working_directory') &&
53
+ runtimeSource.includes('tool_input'));
54
+ }
44
55
  export async function doctorProject(options = {}) {
45
56
  const repoRoot = path.resolve(options.targetDir ?? process.cwd());
46
57
  const issues = [];
@@ -96,9 +107,19 @@ export async function doctorProject(options = {}) {
96
107
  hooksPath = scopedPaths.hooksSettingsPath;
97
108
  corePath = path.join(scopedPaths.runtimeDir, 'core.mjs');
98
109
  notes.push(installScope === 'global'
99
- ? `Install scope: global (hooks/runtime at ${scopedPaths.hooksDir})`
110
+ ? adapterName === 'cursor'
111
+ ? `Install scope: global (hooks/runtime at ${scopedPaths.hooksDir}). To remove global hooks: belay uninstall --scope global`
112
+ : `Install scope: global (hooks/runtime at ${scopedPaths.hooksDir})`
100
113
  : 'Install scope: project');
101
114
  notes.push(`Config mode: ${loadedConfig.mode}`);
115
+ const dogfoodActive = loadedConfig.mode === 'audit' && loadedConfig.policy.unknownLocalEffect === 'deny';
116
+ const allGatesDisabled = !loadedConfig.gates.shell &&
117
+ !loadedConfig.gates.toolShell &&
118
+ !loadedConfig.gates.fileMutation &&
119
+ !loadedConfig.gates.subagent;
120
+ if (dogfoodActive && allGatesDisabled) {
121
+ issues.push('Dogfood audit mode is active but all gates are disabled; gate events will not be recorded. Enable gates.shell, gates.toolShell, gates.fileMutation, and/or gates.subagent.');
122
+ }
102
123
  notes.push('Verdict engine (Tier0 + Tier1; location × opacity × effect × confidence). Audit records include schemaVersion 2 axes when available.');
103
124
  const repoLocalDir = repoLocalStateDirFor(repoRoot, loadedConfig);
104
125
  if (loadedConfig.controlPlane.enabled) {
@@ -183,7 +204,7 @@ export async function doctorProject(options = {}) {
183
204
  }
184
205
  }
185
206
  if (hasDuplicateCursorShellGates(hooksFile, process.platform, hooksDir, repoRoot)) {
186
- warnings.push('Duplicate Cursor shell gates detected (beforeShellExecution + preToolUse Shell). Run belay upgrade to remove the legacy Shell matcher.');
207
+ warnings.push('Duplicate Cursor Shell preToolUse gates detected. Run belay upgrade to dedupe managed hooks.');
187
208
  }
188
209
  }
189
210
  else if (adapterName === 'codex') {
@@ -239,6 +260,17 @@ export async function doctorProject(options = {}) {
239
260
  if (runtimeVersions.stamp?.startsWith(`${PACKAGE_VERSION}@`)) {
240
261
  notes.push(`Runtime version matches package (${PACKAGE_VERSION}).`);
241
262
  }
263
+ if (adapterName === 'cursor' && installScope === 'global') {
264
+ try {
265
+ const runtimeSource = await readFile(corePath, 'utf8');
266
+ if (!hasCursorGlobalWorkspaceResolver(runtimeSource)) {
267
+ 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.');
268
+ }
269
+ }
270
+ catch {
271
+ warnings.push('Unable to inspect global Cursor runtime for workspace-cwd resolution. Run belay upgrade --scope global from the latest package.');
272
+ }
273
+ }
242
274
  }
243
275
  if (options.fix && loadedConfig) {
244
276
  if (hasForbiddenShellOverrideLists(loadedConfig)) {
@@ -286,14 +318,11 @@ export async function doctorProject(options = {}) {
286
318
  const cohortAuditRecords = cohortIdentity
287
319
  ? auditRecords.filter((record) => matchesAuditCohort(record, cohortIdentity))
288
320
  : [];
289
- const auditSample = auditRecords
321
+ const gateDecisionRecords = cohortAuditRecords.filter((record) => record.kind === 'shell' || record.kind === 'tool' || record.kind === 'subagent');
322
+ if (gateDecisionRecords
290
323
  .slice(0, 200)
291
- .map((record) => JSON.stringify(record))
292
- .join('\n');
293
- if (auditSample.includes('"<timestamp>"') ||
294
- auditSample.includes('"<high-entropy>"') ||
295
- auditSample.includes('"<approval-id>"')) {
296
- warnings.push('Audit log contains scrub placeholders in correlation fields (<timestamp>, <high-entropy>, <approval-id>). Historical metrics are unreliable until a schema v3 cohort is collected.');
324
+ .some((record) => auditRecordHasLegacyCorrelationPlaceholders(record))) {
325
+ warnings.push('Gate audit records contain scrub placeholders in correlation fields (<timestamp>, <high-entropy>, <approval-id>). Historical metrics are unreliable until a schema v3 cohort is collected.');
297
326
  }
298
327
  const auditVisibility = summarizeAuditVisibility(cohortAuditRecords);
299
328
  const drift = detectFenceDrift(auditVisibility, {
@@ -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,53 @@ 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 cursorConfigDir = env.CURSOR_CONFIG_DIR?.trim();
55
+ if (cursorConfigDir) {
56
+ return [path.join(cursorConfigDir, 'cli-config.json')];
57
+ }
58
+ const candidates = [];
59
+ const xdgConfigHome = env.XDG_CONFIG_HOME?.trim();
60
+ if (xdgConfigHome) {
61
+ candidates.push(path.join(xdgConfigHome, 'cursor', 'cli-config.json'));
62
+ }
63
+ candidates.push(path.join(homeDir, '.cursor', 'cli-config.json'));
64
+ return candidates;
65
+ }
66
+ export function cursorApprovalModeRequiresHostDenialWarning(approvalMode) {
67
+ if (typeof approvalMode !== 'string' || !approvalMode.trim()) {
68
+ return true;
69
+ }
70
+ return !CURSOR_APPROVAL_MODES_WITHOUT_HOST_DENIAL.has(approvalMode);
71
+ }
72
+ export function formatCursorApprovalModeLabel(approvalMode) {
73
+ if (typeof approvalMode !== 'string' || !approvalMode.trim()) {
74
+ return 'default (prompts on each action)';
75
+ }
76
+ return approvalMode;
77
+ }
78
+ async function cursorRunModeRiskSignal(homeDir, belayMode, env) {
79
+ const configPath = cursorCliConfigPaths(homeDir, env).find((candidate) => existsSync(candidate));
80
+ if (!configPath) {
81
+ return null;
82
+ }
83
+ try {
84
+ const config = JSON.parse(await readFile(configPath, 'utf8'));
85
+ if (!cursorApprovalModeRequiresHostDenialWarning(config.approvalMode)) {
86
+ return null;
87
+ }
88
+ const approvalModeLabel = formatCursorApprovalModeLabel(config.approvalMode);
89
+ const auditGuidance = belayMode === 'audit'
90
+ ? ' 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.'
91
+ : ' If Belay is intended to be authoritative, verify fail-closed hook health before configuring Cursor unrestricted mode.';
92
+ 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}`;
93
+ }
94
+ catch {
95
+ return null;
96
+ }
97
+ }
51
98
  /** Lightweight floor check without judge doctor / sandbox probes. */
52
99
  export async function isBelayFloorInstalled(options = {}) {
53
100
  const repoRoot = path.resolve(options.targetDir ?? process.cwd());
@@ -125,6 +172,15 @@ export async function collectHealthSnapshot(options = {}) {
125
172
  if (layered.config.judge.provider === 'openai-compatible') {
126
173
  additionalRiskSignals.push('cloud judge enabled: redacted command text may be sent to an external provider');
127
174
  }
175
+ if (adapter === 'cursor') {
176
+ const cursorRunModeRisk = await cursorRunModeRiskSignal(options.homeDir ?? os.homedir(), layered.config.mode, options.cursorConfigEnv ?? {
177
+ CURSOR_CONFIG_DIR: process.env.CURSOR_CONFIG_DIR,
178
+ XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,
179
+ });
180
+ if (cursorRunModeRisk) {
181
+ additionalRiskSignals.push(cursorRunModeRisk);
182
+ }
183
+ }
128
184
  }
129
185
  catch {
130
186
  configPresent = false;
@@ -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
+ }
@@ -0,0 +1,16 @@
1
+ import type { AdapterName } from '../adapters/layouts/types.js';
2
+ export type ApprovalRepoLookupResult = {
3
+ status: 'found';
4
+ repoRoot: string;
5
+ } | {
6
+ status: 'ambiguous';
7
+ repoRoots: string[];
8
+ } | {
9
+ status: 'not_found';
10
+ };
11
+ export declare function findApprovalRepoRoots(params: {
12
+ approvalId: string;
13
+ candidateRepoRoots: string[];
14
+ adapter: AdapterName;
15
+ }): Promise<ApprovalRepoLookupResult>;
16
+ export declare function formatAmbiguousApprovalRepoMessage(repoRoots: string[]): string;
@@ -0,0 +1,48 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { approvedApprovalsPath, loadConfigFile, pendingApprovalsPath } from '../config-io.js';
4
+ async function approvalStateContainsId(filePath, approvalId) {
5
+ try {
6
+ const raw = await readFile(filePath, 'utf8');
7
+ const parsed = JSON.parse(raw);
8
+ return parsed.approvals?.some((entry) => entry.approvalId === approvalId) ?? false;
9
+ }
10
+ catch {
11
+ return false;
12
+ }
13
+ }
14
+ export async function findApprovalRepoRoots(params) {
15
+ const matches = [];
16
+ const seen = new Set();
17
+ for (const candidate of params.candidateRepoRoots) {
18
+ const repoRoot = path.resolve(candidate);
19
+ if (seen.has(repoRoot)) {
20
+ continue;
21
+ }
22
+ seen.add(repoRoot);
23
+ try {
24
+ const config = await loadConfigFile(repoRoot, params.adapter);
25
+ const pendingPath = pendingApprovalsPath(repoRoot, config);
26
+ const approvedPath = approvedApprovalsPath(repoRoot, config);
27
+ const hasPending = await approvalStateContainsId(pendingPath, params.approvalId);
28
+ const hasApproved = await approvalStateContainsId(approvedPath, params.approvalId);
29
+ if (hasPending || hasApproved) {
30
+ matches.push(repoRoot);
31
+ }
32
+ }
33
+ catch {
34
+ continue;
35
+ }
36
+ }
37
+ if (matches.length === 1) {
38
+ return { status: 'found', repoRoot: matches[0] };
39
+ }
40
+ if (matches.length > 1) {
41
+ return { status: 'ambiguous', repoRoots: matches };
42
+ }
43
+ return { status: 'not_found' };
44
+ }
45
+ export function formatAmbiguousApprovalRepoMessage(repoRoots) {
46
+ return ('Belay found the same approval ID in multiple repositories. ' +
47
+ `Open one workspace and retry: ${repoRoots.join(', ')}`);
48
+ }
@@ -1,3 +1,3 @@
1
1
  import type { BelayConfigV4 } from './config.js';
2
- export { AUDIT_SCHEMA_VERSION, appendAuditRecord, approvalCorrelationId, isValidAuditFingerprint, isValidAuditTimestamp, parseAuditNdjsonLine, serializeAuditRecordV3, } from './audit-serialize.js';
2
+ export { AUDIT_SCHEMA_VERSION, appendAuditRecord, approvalCorrelationId, canonicalToolUseIdForCorrelation, isValidAuditFingerprint, isValidAuditTimestamp, parseAuditNdjsonLine, serializeAuditRecordV3, toolInvocationCorrelationId, } from './audit-serialize.js';
3
3
  export declare function appendCliAuditEvent(repoRoot: string, config: BelayConfigV4, event: Record<string, unknown>): Promise<void>;
@@ -2,7 +2,7 @@ import path from 'node:path';
2
2
  import { resolveActiveAuditCohort } from '../runtime-provenance.js';
3
3
  import { appendAuditRecord } from './audit-serialize.js';
4
4
  import { scrubOptionsFromConfig } from './config.js';
5
- export { AUDIT_SCHEMA_VERSION, appendAuditRecord, approvalCorrelationId, isValidAuditFingerprint, isValidAuditTimestamp, parseAuditNdjsonLine, serializeAuditRecordV3, } from './audit-serialize.js';
5
+ export { AUDIT_SCHEMA_VERSION, appendAuditRecord, approvalCorrelationId, canonicalToolUseIdForCorrelation, isValidAuditFingerprint, isValidAuditTimestamp, parseAuditNdjsonLine, serializeAuditRecordV3, toolInvocationCorrelationId, } from './audit-serialize.js';
6
6
  export async function appendCliAuditEvent(repoRoot, config, event) {
7
7
  const auditPath = path.isAbsolute(config.audit.logPath)
8
8
  ? config.audit.logPath
@@ -1,5 +1,6 @@
1
1
  import type { BelayConfigV3 } from './config.js';
2
2
  export declare function auditLogHasLegacyScrubPlaceholders(sample: string): boolean;
3
+ export declare function auditRecordHasLegacyCorrelationPlaceholders(record: Record<string, unknown>): boolean;
3
4
  export declare function archiveLegacyAuditLogIfNeeded(repoRoot: string, config: BelayConfigV3): Promise<{
4
5
  archived: boolean;
5
6
  archivedPath?: string;
@@ -11,6 +11,11 @@ const AUDIT_SCAN_OVERLAP_CHARS = 256;
11
11
  export function auditLogHasLegacyScrubPlaceholders(sample) {
12
12
  return LEGACY_PLACEHOLDER_PATTERNS.some((pattern) => pattern.test(sample));
13
13
  }
14
+ export function auditRecordHasLegacyCorrelationPlaceholders(record) {
15
+ return (record.timestamp === '<timestamp>' ||
16
+ record.fingerprint === '<high-entropy>' ||
17
+ record.approvalId === '<approval-id>');
18
+ }
14
19
  async function auditFileHasLegacyScrubPlaceholders(auditPath) {
15
20
  const handle = await open(auditPath, 'r');
16
21
  const buffer = Buffer.allocUnsafe(AUDIT_SCAN_CHUNK_BYTES);
@@ -3,6 +3,7 @@ export declare function toAuditRecord(value: Record<string, unknown>): AuditReco
3
3
  export declare function parseTimestamp(value?: string): number | null;
4
4
  export declare function auditFingerprint(record: AuditRecord): string | undefined;
5
5
  export declare function auditApprovalCorrelationId(record: AuditRecord): string | undefined;
6
+ export declare function auditToolInvocationCorrelationId(record: AuditRecord): string | undefined;
6
7
  export declare function isGateRecord(record: AuditRecord): boolean;
7
8
  export declare function isShellGateRecord(record: AuditRecord): boolean;
8
9
  export declare function isApprovalRecorded(record: AuditRecord): boolean;
@@ -27,6 +27,13 @@ export function auditApprovalCorrelationId(record) {
27
27
  }
28
28
  return undefined;
29
29
  }
30
+ export function auditToolInvocationCorrelationId(record) {
31
+ if (typeof record.toolInvocationCorrelationId === 'string' &&
32
+ isValidApprovalCorrelationId(record.toolInvocationCorrelationId)) {
33
+ return record.toolInvocationCorrelationId;
34
+ }
35
+ return undefined;
36
+ }
30
37
  export function isGateRecord(record) {
31
38
  return typeof record.event === 'string' && GATE_EVENTS.has(record.event);
32
39
  }
@@ -1,6 +1,9 @@
1
1
  import type { ScrubOptions } from './types.js';
2
2
  export declare const AUDIT_SCHEMA_VERSION = 3;
3
3
  export declare function approvalCorrelationId(approvalId: string): string;
4
+ /** Normalize Cursor host tool_use_id values before correlation hashing. */
5
+ export declare function canonicalToolUseIdForCorrelation(toolUseId: string): string;
6
+ export declare function toolInvocationCorrelationId(toolUseId: string): string;
4
7
  export declare function isValidApprovalCorrelationId(value: string): boolean;
5
8
  export declare function isValidAuditTimestamp(value: string): boolean;
6
9
  export declare function isValidAuditFingerprint(value: string): boolean;
@@ -19,6 +19,7 @@ const PRESERVED_HASH_FIELDS = new Set([
19
19
  const PRESERVED_LITERAL_FIELDS = new Set([
20
20
  'timestamp',
21
21
  'approvalCorrelationId',
22
+ 'toolInvocationCorrelationId',
22
23
  'runtimeVersion',
23
24
  'runtimeBuildStamp',
24
25
  'boundaryProfile',
@@ -42,6 +43,27 @@ const SCRUBBED_CONTAINER_FIELDS = new Set([
42
43
  export function approvalCorrelationId(approvalId) {
43
44
  return createHash('sha256').update(approvalId).digest('hex').slice(0, 16);
44
45
  }
46
+ const TOOL_USE_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
47
+ /** Normalize Cursor host tool_use_id values before correlation hashing. */
48
+ export function canonicalToolUseIdForCorrelation(toolUseId) {
49
+ const trimmed = toolUseId.trim();
50
+ if (trimmed.startsWith('tool_')) {
51
+ const remainder = trimmed.slice('tool_'.length);
52
+ if (TOOL_USE_UUID_PATTERN.test(remainder)) {
53
+ return remainder.toLowerCase();
54
+ }
55
+ }
56
+ if (TOOL_USE_UUID_PATTERN.test(trimmed)) {
57
+ return trimmed.toLowerCase();
58
+ }
59
+ return trimmed;
60
+ }
61
+ export function toolInvocationCorrelationId(toolUseId) {
62
+ return createHash('sha256')
63
+ .update(canonicalToolUseIdForCorrelation(toolUseId))
64
+ .digest('hex')
65
+ .slice(0, 16);
66
+ }
45
67
  export function isValidApprovalCorrelationId(value) {
46
68
  return /^[a-f0-9]{16}$/.test(value);
47
69
  }
@@ -64,7 +86,17 @@ function isValidPreservedHashField(field, value) {
64
86
  return isValidAuditFingerprint(value);
65
87
  }
66
88
  function scrubAuditContainer(value, options) {
67
- return scrubValue(value, {
89
+ const withoutRawToolIds = (input) => {
90
+ if (Array.isArray(input))
91
+ return input.map(withoutRawToolIds);
92
+ if (input && typeof input === 'object') {
93
+ return Object.fromEntries(Object.entries(input)
94
+ .filter(([key]) => key !== 'tool_use_id')
95
+ .map(([key, child]) => [key, withoutRawToolIds(child)]));
96
+ }
97
+ return input;
98
+ };
99
+ return scrubValue(withoutRawToolIds(value), {
68
100
  ...options,
69
101
  maskHighEntropyStrings: true,
70
102
  });
@@ -83,7 +115,7 @@ function serializeAuditField(key, value, options) {
83
115
  if (key === 'timestamp' && typeof value === 'string' && isValidAuditTimestamp(value)) {
84
116
  return value;
85
117
  }
86
- if (key === 'approvalCorrelationId' &&
118
+ if ((key === 'approvalCorrelationId' || key === 'toolInvocationCorrelationId') &&
87
119
  typeof value === 'string' &&
88
120
  isValidApprovalCorrelationId(value)) {
89
121
  return value;
@@ -145,7 +177,11 @@ export function serializeAuditRecordV3(record, options) {
145
177
  serialized.approvalCorrelationId = record.approvalCorrelationId;
146
178
  }
147
179
  for (const [key, value] of Object.entries(record)) {
148
- if (key === 'timestamp' || key === 'ts' || key === 'approvalId' || key === 'schemaVersion') {
180
+ if (key === 'timestamp' ||
181
+ key === 'ts' ||
182
+ key === 'approvalId' ||
183
+ key === 'tool_use_id' ||
184
+ key === 'schemaVersion') {
149
185
  continue;
150
186
  }
151
187
  const next = serializeAuditField(key, value, options);
@@ -6,6 +6,12 @@ export interface RecentAskEntry {
6
6
  reason: string;
7
7
  tier: AuditTier;
8
8
  }
9
+ export interface RecentHostDenialEntry {
10
+ gateTimestamp?: string;
11
+ failureTimestamp?: string;
12
+ summary: string;
13
+ errorMessage: string;
14
+ }
9
15
  export interface AuditVisibilitySummary {
10
16
  gateEvents: number;
11
17
  askCount: number;
@@ -16,6 +22,9 @@ export interface AuditVisibilitySummary {
16
22
  allowCount: number;
17
23
  silentPassRate: number;
18
24
  recentAsks: RecentAskEntry[];
25
+ hostDeniedAfterAllowCount: number;
26
+ recentHostDenials: RecentHostDenialEntry[];
27
+ unrecognizedHostFailureCount: number;
19
28
  }
20
29
  export declare const DEFAULT_SILENT_PASS_THRESHOLD = 0.5;
21
30
  export declare const MIN_GATE_EVENTS_FOR_FENCE_DRIFT = 20;