@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.
- package/README.md +1 -1
- package/dist/adapters/codex/runtime-entry.d.ts +3 -0
- package/dist/adapters/codex/runtime-entry.js +27 -4
- package/dist/adapters/cursor/cwd-resolution.d.ts +10 -0
- package/dist/adapters/cursor/cwd-resolution.js +58 -0
- package/dist/adapters/cursor/hooks.d.ts +1 -0
- package/dist/adapters/cursor/hooks.js +21 -0
- package/dist/adapters/cursor/runtime-entry.d.ts +1 -0
- package/dist/adapters/cursor/runtime-entry.js +107 -6
- package/dist/adapters/shared/gate-runtime.js +26 -3
- package/dist/adapters/shared/repo-root.js +20 -1
- package/dist/bundle/claude-runtime.mjs +855 -338
- package/dist/bundle/codex-runtime.mjs +877 -342
- package/dist/bundle/cursor-runtime.mjs +3886 -3141
- package/dist/cli.js +33 -3
- package/dist/commands/doctor.js +24 -1
- package/dist/commands/health-snapshot.d.ts +3 -0
- package/dist/commands/health-snapshot.js +56 -0
- package/dist/commands/report.js +14 -0
- package/dist/commands/status.js +15 -0
- package/dist/commands/where.d.ts +4 -0
- package/dist/commands/where.js +52 -0
- package/dist/core/approval-repo-lookup.d.ts +16 -0
- package/dist/core/approval-repo-lookup.js +48 -0
- package/dist/core/audit-io.d.ts +1 -1
- package/dist/core/audit-io.js +1 -1
- package/dist/core/audit-query.d.ts +1 -0
- package/dist/core/audit-query.js +7 -0
- package/dist/core/audit-serialize.d.ts +3 -0
- package/dist/core/audit-serialize.js +39 -3
- package/dist/core/audit-summary.d.ts +9 -0
- package/dist/core/audit-summary.js +58 -1
- package/dist/core/audit-types.d.ts +4 -0
- package/dist/core/effect-ir/shell-lower.js +93 -40
- package/dist/core/replay-scrub.d.ts +1 -0
- package/dist/core/replay-scrub.js +22 -3
- package/dist/core/shell-tokenizer.d.ts +28 -0
- package/dist/core/shell-tokenizer.js +111 -29
- package/dist/core/verdict/docker-compose-run.d.ts +18 -0
- package/dist/core/verdict/docker-compose-run.js +136 -0
- package/dist/core/verdict/launcher-resolve.js +28 -28
- package/dist/core/verdict/parser.d.ts +3 -0
- package/dist/core/verdict/parser.js +23 -40
- package/dist/core/verdict/recursive-invocation.d.ts +20 -0
- package/dist/core/verdict/recursive-invocation.js +224 -0
- package/dist/defaults.js +7 -0
- package/dist/installer/scope-config.d.ts +2 -2
- package/dist/installer.d.ts +10 -1
- package/dist/installer.js +43 -2
- package/dist/types.d.ts +34 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +5 -2
- package/skills/belay/SKILL.md +5 -0
- 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' ||
|
|
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) {
|
package/dist/commands/doctor.js
CHANGED
|
@@ -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
|
-
?
|
|
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;
|
package/dist/commands/report.js
CHANGED
|
@@ -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
|
}
|
package/dist/commands/status.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/core/audit-io.d.ts
CHANGED
|
@@ -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>;
|
package/dist/core/audit-io.js
CHANGED
|
@@ -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;
|
package/dist/core/audit-query.js
CHANGED
|
@@ -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
|
-
|
|
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' ||
|
|
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;
|