@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.
- 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 +5 -3
- package/dist/adapters/cursor/hooks.js +29 -20
- 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 +1225 -287
- package/dist/bundle/codex-runtime.mjs +1247 -291
- package/dist/bundle/cursor-runtime.mjs +4320 -3154
- package/dist/cli.js +33 -3
- package/dist/commands/doctor.js +38 -9
- 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-legacy-archive.d.ts +1 -0
- package/dist/core/audit-legacy-archive.js +5 -0
- 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 +283 -27
- 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 +66 -25
- package/dist/core/verdict/makefile-expand.d.ts +4 -0
- package/dist/core/verdict/makefile-expand.js +151 -0
- package/dist/core/verdict/parser.d.ts +4 -0
- package/dist/core/verdict/parser.js +25 -30
- package/dist/core/verdict/recursive-invocation.d.ts +20 -0
- package/dist/core/verdict/recursive-invocation.js +224 -0
- package/dist/corpus/benign-probe-cores.d.ts +1 -1
- package/dist/corpus/benign-probe-cores.js +2 -0
- package/dist/defaults.js +16 -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
|
@@ -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
|
-
?
|
|
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
|
|
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
|
|
321
|
+
const gateDecisionRecords = cohortAuditRecords.filter((record) => record.kind === 'shell' || record.kind === 'tool' || record.kind === 'subagent');
|
|
322
|
+
if (gateDecisionRecords
|
|
290
323
|
.slice(0, 200)
|
|
291
|
-
.
|
|
292
|
-
.
|
|
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;
|
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
|
|
@@ -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;
|
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;
|