@guilz-dev/belay 0.9.2 → 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 (55) 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 +1 -0
  7. package/dist/adapters/cursor/hooks.js +21 -0
  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 +855 -338
  13. package/dist/bundle/codex-runtime.mjs +877 -342
  14. package/dist/bundle/cursor-runtime.mjs +3886 -3141
  15. package/dist/cli.js +33 -3
  16. package/dist/commands/doctor.js +24 -1
  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-query.d.ts +1 -0
  28. package/dist/core/audit-query.js +7 -0
  29. package/dist/core/audit-serialize.d.ts +3 -0
  30. package/dist/core/audit-serialize.js +39 -3
  31. package/dist/core/audit-summary.d.ts +9 -0
  32. package/dist/core/audit-summary.js +58 -1
  33. package/dist/core/audit-types.d.ts +4 -0
  34. package/dist/core/effect-ir/shell-lower.js +93 -40
  35. package/dist/core/replay-scrub.d.ts +1 -0
  36. package/dist/core/replay-scrub.js +22 -3
  37. package/dist/core/shell-tokenizer.d.ts +28 -0
  38. package/dist/core/shell-tokenizer.js +111 -29
  39. package/dist/core/verdict/docker-compose-run.d.ts +18 -0
  40. package/dist/core/verdict/docker-compose-run.js +136 -0
  41. package/dist/core/verdict/launcher-resolve.js +28 -28
  42. package/dist/core/verdict/parser.d.ts +3 -0
  43. package/dist/core/verdict/parser.js +23 -40
  44. package/dist/core/verdict/recursive-invocation.d.ts +20 -0
  45. package/dist/core/verdict/recursive-invocation.js +224 -0
  46. package/dist/defaults.js +7 -0
  47. package/dist/installer/scope-config.d.ts +2 -2
  48. package/dist/installer.d.ts +10 -1
  49. package/dist/installer.js +43 -2
  50. package/dist/types.d.ts +34 -1
  51. package/dist/version.d.ts +1 -1
  52. package/dist/version.js +1 -1
  53. package/package.json +5 -2
  54. package/skills/belay/SKILL.md +5 -0
  55. 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) {
@@ -42,6 +42,16 @@ function resolveDoctorAdapter(options, configAdapter) {
42
42
  }
43
43
  return 'cursor';
44
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
+ }
45
55
  export async function doctorProject(options = {}) {
46
56
  const repoRoot = path.resolve(options.targetDir ?? process.cwd());
47
57
  const issues = [];
@@ -97,7 +107,9 @@ export async function doctorProject(options = {}) {
97
107
  hooksPath = scopedPaths.hooksSettingsPath;
98
108
  corePath = path.join(scopedPaths.runtimeDir, 'core.mjs');
99
109
  notes.push(installScope === 'global'
100
- ? `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})`
101
113
  : 'Install scope: project');
102
114
  notes.push(`Config mode: ${loadedConfig.mode}`);
103
115
  const dogfoodActive = loadedConfig.mode === 'audit' && loadedConfig.policy.unknownLocalEffect === 'deny';
@@ -248,6 +260,17 @@ export async function doctorProject(options = {}) {
248
260
  if (runtimeVersions.stamp?.startsWith(`${PACKAGE_VERSION}@`)) {
249
261
  notes.push(`Runtime version matches package (${PACKAGE_VERSION}).`);
250
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
+ }
251
274
  }
252
275
  if (options.fix && loadedConfig) {
253
276
  if (hasForbiddenShellOverrideLists(loadedConfig)) {
@@ -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
@@ -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;
@@ -1,4 +1,4 @@
1
- import { filterAuditRecords, inferWouldBlock, isApprovalRecorded, isGateRecord, parseTimestamp, recordStringField, } from './audit-query.js';
1
+ import { auditToolInvocationCorrelationId, filterAuditRecords, inferWouldBlock, isApprovalRecorded, isGateRecord, parseTimestamp, recordStringField, } from './audit-query.js';
2
2
  export const DEFAULT_SILENT_PASS_THRESHOLD = 0.5;
3
3
  export const MIN_GATE_EVENTS_FOR_FENCE_DRIFT = 20;
4
4
  function isTier0Reason(reason) {
@@ -38,6 +38,7 @@ function isGateEventRecord(record) {
38
38
  export function summarizeAuditVisibility(records, filter = {}, options = {}) {
39
39
  const filtered = filterAuditRecords(records, filter);
40
40
  const gateRecords = filtered.filter(isGateEventRecord);
41
+ const allGateRecords = records.filter(isGateEventRecord);
41
42
  const recentAskLimit = options.recentAskLimit ?? 10;
42
43
  let askCount = 0;
43
44
  let enforceAskCount = 0;
@@ -46,6 +47,23 @@ export function summarizeAuditVisibility(records, filter = {}, options = {}) {
46
47
  let flagCount = 0;
47
48
  let allowCount = 0;
48
49
  const recentAsks = [];
50
+ const allowedByInvocation = new Map();
51
+ const recentHostDenials = [];
52
+ let unrecognizedHostFailureCount = 0;
53
+ const matchedHostDenials = new Set();
54
+ for (const record of allGateRecords) {
55
+ const invocationId = auditToolInvocationCorrelationId(record);
56
+ if (invocationId && !inferWouldBlock(record) && record.permission === 'allow') {
57
+ const existing = allowedByInvocation.get(invocationId);
58
+ const recordMs = parseTimestamp(record.timestamp) ?? Number.NEGATIVE_INFINITY;
59
+ const existingMs = existing
60
+ ? (parseTimestamp(existing.timestamp) ?? Number.NEGATIVE_INFINITY)
61
+ : Number.NEGATIVE_INFINITY;
62
+ if (!existing || recordMs >= existingMs) {
63
+ allowedByInvocation.set(invocationId, record);
64
+ }
65
+ }
66
+ }
49
67
  for (const record of gateRecords) {
50
68
  if (inferWouldBlock(record)) {
51
69
  askCount += 1;
@@ -73,11 +91,47 @@ export function summarizeAuditVisibility(records, filter = {}, options = {}) {
73
91
  allowCount += 1;
74
92
  }
75
93
  }
94
+ for (const record of filtered) {
95
+ if (record.event !== 'postToolUseFailure') {
96
+ continue;
97
+ }
98
+ if (record.failureType !== 'permission_denied') {
99
+ unrecognizedHostFailureCount += 1;
100
+ continue;
101
+ }
102
+ const invocationId = auditToolInvocationCorrelationId(record);
103
+ const gate = invocationId ? allowedByInvocation.get(invocationId) : undefined;
104
+ if (!gate) {
105
+ continue;
106
+ }
107
+ const gateMs = parseTimestamp(gate.timestamp);
108
+ const failureMs = parseTimestamp(record.timestamp);
109
+ if (gateMs !== null && failureMs !== null && failureMs < gateMs) {
110
+ continue;
111
+ }
112
+ if (invocationId && matchedHostDenials.has(invocationId)) {
113
+ continue;
114
+ }
115
+ if (invocationId) {
116
+ matchedHostDenials.add(invocationId);
117
+ }
118
+ recentHostDenials.push({
119
+ gateTimestamp: gate.timestamp,
120
+ failureTimestamp: record.timestamp,
121
+ summary: typeof gate.summary === 'string' ? gate.summary : '',
122
+ errorMessage: typeof record.errorMessage === 'string' ? record.errorMessage : '',
123
+ });
124
+ }
76
125
  recentAsks.sort((left, right) => {
77
126
  const leftMs = parseTimestamp(left.timestamp) ?? 0;
78
127
  const rightMs = parseTimestamp(right.timestamp) ?? 0;
79
128
  return rightMs - leftMs;
80
129
  });
130
+ recentHostDenials.sort((left, right) => {
131
+ const leftMs = parseTimestamp(left.failureTimestamp) ?? 0;
132
+ const rightMs = parseTimestamp(right.failureTimestamp) ?? 0;
133
+ return rightMs - leftMs;
134
+ });
81
135
  const gateEvents = gateRecords.length;
82
136
  const silentPassRate = gateEvents > 0 ? (allowCount + flagCount) / gateEvents : 0;
83
137
  return {
@@ -90,6 +144,9 @@ export function summarizeAuditVisibility(records, filter = {}, options = {}) {
90
144
  allowCount,
91
145
  silentPassRate,
92
146
  recentAsks: recentAsks.slice(0, recentAskLimit),
147
+ hostDeniedAfterAllowCount: recentHostDenials.length,
148
+ recentHostDenials: recentHostDenials.slice(0, recentAskLimit),
149
+ unrecognizedHostFailureCount,
93
150
  };
94
151
  }
95
152
  export function detectFenceDrift(summary, options = {}) {
@@ -12,6 +12,10 @@ export interface AuditRecord {
12
12
  fingerprint?: string;
13
13
  summary?: string;
14
14
  approvalId?: string;
15
+ toolInvocationCorrelationId?: string;
16
+ toolName?: string;
17
+ failureType?: string;
18
+ errorMessage?: string;
15
19
  wouldBlock?: boolean;
16
20
  judgeFallbackReason?: string;
17
21
  permission?: string;