@guilz-dev/belay 0.9.3 → 0.10.0
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 +38 -3
- package/dist/adapters/codex/hooks.d.ts +1 -0
- package/dist/adapters/codex/hooks.js +3 -0
- package/dist/adapters/cursor/dispatcher-generation.d.ts +3 -0
- package/dist/adapters/cursor/dispatcher-generation.js +5 -0
- package/dist/adapters/cursor/hook-dispatch-entry.d.ts +10 -0
- package/dist/adapters/cursor/hook-dispatch-entry.js +137 -0
- package/dist/adapters/cursor/hook-router.d.ts +23 -0
- package/dist/adapters/cursor/hook-router.js +273 -0
- package/dist/adapters/cursor/hooks.d.ts +5 -1
- package/dist/adapters/cursor/hooks.js +112 -10
- package/dist/adapters/cursor/routing-config-trust.d.ts +1 -0
- package/dist/adapters/cursor/routing-config-trust.js +67 -0
- package/dist/adapters/cursor/routing-layout.d.ts +5 -0
- package/dist/adapters/cursor/routing-layout.js +29 -0
- package/dist/adapters/cursor/runtime-entry.d.ts +17 -0
- package/dist/adapters/cursor/runtime-entry.js +74 -49
- package/dist/adapters/layouts/scope.d.ts +5 -0
- package/dist/adapters/layouts/scope.js +71 -0
- package/dist/adapters/shared/gate-runtime.js +29 -17
- package/dist/bundle/claude-runtime.mjs +2428 -2072
- package/dist/bundle/codex-runtime.mjs +2440 -2084
- package/dist/bundle/cursor-dispatcher.mjs +517 -0
- package/dist/bundle/cursor-runtime.mjs +2521 -2133
- package/dist/cli.js +53 -4
- package/dist/commands/approval-token.d.ts +10 -0
- package/dist/commands/approval-token.js +26 -0
- package/dist/commands/audit.d.ts +2 -1
- package/dist/commands/audit.js +2 -2
- package/dist/commands/config.d.ts +1 -1
- package/dist/commands/config.js +28 -2
- package/dist/commands/doctor.js +149 -17
- package/dist/commands/dogfood-check.d.ts +3 -0
- package/dist/commands/dogfood-check.js +116 -0
- package/dist/commands/dogfood.d.ts +1 -0
- package/dist/commands/dogfood.js +4 -3
- package/dist/commands/health-snapshot.js +9 -4
- package/dist/commands/judge.js +2 -2
- package/dist/commands/recovery-checkpoints.d.ts +3 -3
- package/dist/commands/recovery-checkpoints.js +2 -11
- package/dist/config-io.d.ts +1 -0
- package/dist/config-io.js +19 -3
- package/dist/core/audit-legacy-archive.js +43 -25
- package/dist/core/classify-tool.js +16 -8
- package/dist/core/dogfood-environment.d.ts +8 -0
- package/dist/core/dogfood-environment.js +55 -0
- package/dist/core/effect-ir/argv-delegate.d.ts +9 -0
- package/dist/core/effect-ir/argv-delegate.js +42 -0
- package/dist/core/effect-ir/shell-lower/argv-delegate-gate.d.ts +3 -0
- package/dist/core/effect-ir/shell-lower/argv-delegate-gate.js +40 -0
- package/dist/core/effect-ir/shell-lower/augment.d.ts +4 -0
- package/dist/core/effect-ir/shell-lower/augment.js +97 -0
- package/dist/core/effect-ir/shell-lower/context.d.ts +10 -0
- package/dist/core/effect-ir/shell-lower/context.js +1 -0
- package/dist/core/effect-ir/shell-lower/decode-process.d.ts +9 -0
- package/dist/core/effect-ir/shell-lower/decode-process.js +201 -0
- package/dist/core/effect-ir/shell-lower/decoders/belay.d.ts +2 -0
- package/dist/core/effect-ir/shell-lower/decoders/belay.js +41 -0
- package/dist/core/effect-ir/shell-lower/decoders/builtins.d.ts +7 -0
- package/dist/core/effect-ir/shell-lower/decoders/builtins.js +194 -0
- package/dist/core/effect-ir/shell-lower/decoders/decode-package-exec.d.ts +4 -0
- package/dist/core/effect-ir/shell-lower/decoders/decode-package-exec.js +68 -0
- package/dist/core/effect-ir/shell-lower/decoders/docker.d.ts +3 -0
- package/dist/core/effect-ir/shell-lower/decoders/docker.js +43 -0
- package/dist/core/effect-ir/shell-lower/decoders/filesystem.d.ts +9 -0
- package/dist/core/effect-ir/shell-lower/decoders/filesystem.js +217 -0
- package/dist/core/effect-ir/shell-lower/decoders/prisma.d.ts +2 -0
- package/dist/core/effect-ir/shell-lower/decoders/prisma.js +54 -0
- package/dist/core/effect-ir/shell-lower/decoders/ruby.d.ts +11 -0
- package/dist/core/effect-ir/shell-lower/decoders/ruby.js +147 -0
- package/dist/core/effect-ir/shell-lower/decoders/toolchain.d.ts +7 -0
- package/dist/core/effect-ir/shell-lower/decoders/toolchain.js +155 -0
- package/dist/core/effect-ir/shell-lower/requirement.d.ts +12 -0
- package/dist/core/effect-ir/shell-lower/requirement.js +65 -0
- package/dist/core/effect-ir/shell-lower/segment.d.ts +11 -0
- package/dist/core/effect-ir/shell-lower/segment.js +82 -0
- package/dist/core/effect-ir/shell-lower/tokens.d.ts +26 -0
- package/dist/core/effect-ir/shell-lower/tokens.js +183 -0
- package/dist/core/effect-ir/shell-lower.d.ts +2 -7
- package/dist/core/effect-ir/shell-lower.js +60 -1502
- package/dist/core/egress-approval.js +0 -18
- package/dist/core/glob.js +32 -16
- package/dist/core/integrity.d.ts +2 -2
- package/dist/core/integrity.js +52 -12
- package/dist/core/notify.d.ts +8 -2
- package/dist/core/notify.js +63 -7
- package/dist/core/recovery/operator-guidance.js +4 -4
- package/dist/core/replay-scrub.d.ts +2 -0
- package/dist/core/replay-scrub.js +8 -0
- package/dist/core/repo-config-trust.d.ts +31 -0
- package/dist/core/repo-config-trust.js +152 -0
- package/dist/core/shell-substitution.js +2 -2
- package/dist/core/shell-unparseable.js +3 -1
- package/dist/corpus/benign-probe-cores.d.ts +1 -1
- package/dist/corpus/benign-probe-cores.js +0 -1
- package/dist/defaults.d.ts +7 -3
- package/dist/defaults.js +29 -47
- package/dist/installer/bootstrap.d.ts +1 -0
- package/dist/installer/bootstrap.js +2 -2
- package/dist/installer/runtime-artifacts.js +43 -13
- package/dist/installer/scope-config.js +2 -2
- package/dist/installer.js +48 -13
- package/dist/node-resolution.d.ts +1 -0
- package/dist/node-resolution.js +61 -0
- package/dist/templates.d.ts +7 -4
- package/dist/templates.js +42 -4
- package/dist/types.d.ts +20 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +5 -1
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { compactApprovals, createApprovalRecord } from './approval.js';
|
|
3
|
-
import { issueApprovalToken } from './approval-token.js';
|
|
4
3
|
import { mutateApprovalStateWithRetry } from './capability/approval-state-mutation.js';
|
|
5
|
-
import { configuredControlPlaneDir } from './config.js';
|
|
6
4
|
import { addDomainToAllowlist, mutateEgressAllowlist } from './egress/allowlist.js';
|
|
7
5
|
import { parseHostFromSummary } from './egress/fingerprint.js';
|
|
8
6
|
import { notifyDeny } from './notify.js';
|
|
@@ -65,28 +63,12 @@ export async function notifyEgressDeny(params) {
|
|
|
65
63
|
if (!params.config.notifications.webhookUrl && !params.config.notifications.commandHook) {
|
|
66
64
|
return;
|
|
67
65
|
}
|
|
68
|
-
let approvalToken;
|
|
69
|
-
if (params.config.approvalSigning.required) {
|
|
70
|
-
try {
|
|
71
|
-
approvalToken = await issueApprovalToken({
|
|
72
|
-
approvalId: params.approval.approvalId,
|
|
73
|
-
fingerprint: params.approval.fingerprint,
|
|
74
|
-
repoRoot: params.approval.repoRoot,
|
|
75
|
-
issuedAt: params.approval.createdAt,
|
|
76
|
-
expiresAt: params.approval.expiresAt,
|
|
77
|
-
}, configuredControlPlaneDir(params.config));
|
|
78
|
-
}
|
|
79
|
-
catch {
|
|
80
|
-
approvalToken = undefined;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
66
|
await notifyDeny(params.config.notifications, {
|
|
84
67
|
approvalId: params.approval.approvalId,
|
|
85
68
|
reason: params.policyResult.reason,
|
|
86
69
|
summary: params.policyResult.summary,
|
|
87
70
|
repoRoot: params.repoRoot,
|
|
88
71
|
fingerprint: params.policyResult.fingerprint,
|
|
89
|
-
approvalToken,
|
|
90
72
|
});
|
|
91
73
|
}
|
|
92
74
|
export async function recordEgressApproval(params) {
|
package/dist/core/glob.js
CHANGED
|
@@ -1,26 +1,42 @@
|
|
|
1
|
+
import { escapeRegex } from './approval.js';
|
|
2
|
+
function globPatternToRegexSource(pattern) {
|
|
3
|
+
let result = '';
|
|
4
|
+
let index = 0;
|
|
5
|
+
while (index < pattern.length) {
|
|
6
|
+
const char = pattern[index];
|
|
7
|
+
if (char === '*' && pattern[index + 1] === '*') {
|
|
8
|
+
index += 2;
|
|
9
|
+
if (pattern[index] === '/') {
|
|
10
|
+
result += '(?:.*/)?';
|
|
11
|
+
index += 1;
|
|
12
|
+
}
|
|
13
|
+
else {
|
|
14
|
+
result += '.*';
|
|
15
|
+
}
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
if (char === '*') {
|
|
19
|
+
result += '[^/]*';
|
|
20
|
+
index += 1;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
result += escapeRegex(char);
|
|
24
|
+
index += 1;
|
|
25
|
+
}
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
28
|
+
function globPatternMatches(pattern, normalized, baseName) {
|
|
29
|
+
const regex = new RegExp(`^${globPatternToRegexSource(pattern)}$`);
|
|
30
|
+
return regex.test(normalized) || regex.test(baseName);
|
|
31
|
+
}
|
|
1
32
|
export function matchesSensitivePath(filePath, patterns) {
|
|
2
33
|
const normalized = filePath.replaceAll('\\', '/');
|
|
3
34
|
const segments = normalized.split('/');
|
|
4
35
|
const baseName = segments.at(-1) ?? normalized;
|
|
5
36
|
for (const pattern of patterns) {
|
|
6
37
|
const normalizedPattern = pattern.replaceAll('\\', '/');
|
|
7
|
-
if (normalizedPattern.includes('**')) {
|
|
8
|
-
const parts = normalizedPattern.split('**').map((part) => part.replace(/^\/+|\/+$/g, ''));
|
|
9
|
-
const prefix = parts[0]?.replace(/\/+$/, '') ?? '';
|
|
10
|
-
const suffix = parts[1]?.replace(/^\/+/, '') ?? '';
|
|
11
|
-
if (prefix && !normalized.startsWith(prefix)) {
|
|
12
|
-
continue;
|
|
13
|
-
}
|
|
14
|
-
if (suffix && !normalized.includes(suffix)) {
|
|
15
|
-
continue;
|
|
16
|
-
}
|
|
17
|
-
if (prefix || suffix) {
|
|
18
|
-
return true;
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
38
|
if (normalizedPattern.includes('*')) {
|
|
22
|
-
|
|
23
|
-
if (regex.test(normalized) || regex.test(baseName)) {
|
|
39
|
+
if (globPatternMatches(normalizedPattern, normalized, baseName)) {
|
|
24
40
|
return true;
|
|
25
41
|
}
|
|
26
42
|
continue;
|
package/dist/core/integrity.d.ts
CHANGED
|
@@ -8,9 +8,9 @@ export interface IntegrityManifest {
|
|
|
8
8
|
}
|
|
9
9
|
export declare function sha256File(filePath: string): Promise<string>;
|
|
10
10
|
export declare function integrityManifestPath(layout: AdapterLayout, repoRoot: string): string;
|
|
11
|
-
export declare function runtimeIntegrityFiles(
|
|
11
|
+
export declare function runtimeIntegrityFiles(layout: AdapterLayout, paths: ScopedPaths): string[];
|
|
12
12
|
export declare function writeIntegrityManifest(repoRoot: string, layout: AdapterLayout, filePaths: string[]): Promise<void>;
|
|
13
|
-
export declare function verifyIntegrityManifest(repoRoot: string, layout: AdapterLayout): Promise<{
|
|
13
|
+
export declare function verifyIntegrityManifest(repoRoot: string, layout: AdapterLayout, requiredFilePaths?: string[]): Promise<{
|
|
14
14
|
ok: boolean;
|
|
15
15
|
mismatches: string[];
|
|
16
16
|
}>;
|
package/dist/core/integrity.js
CHANGED
|
@@ -3,8 +3,24 @@ import { existsSync } from 'node:fs';
|
|
|
3
3
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { getAdapterLayout } from '../adapters/layouts/index.js';
|
|
6
|
-
import { resolveScopedPaths } from '../adapters/layouts/scope.js';
|
|
6
|
+
import { isPathInside, resolveScopedPaths } from '../adapters/layouts/scope.js';
|
|
7
7
|
import { resolveAdapterName } from '../config-io.js';
|
|
8
|
+
const GLOBAL_INTEGRITY_PREFIX = '@global/';
|
|
9
|
+
function portableRelativePath(from, to) {
|
|
10
|
+
return path.relative(from, to).split(path.sep).join('/');
|
|
11
|
+
}
|
|
12
|
+
function integrityManifestFileKey(repoRoot, layout, filePath) {
|
|
13
|
+
const absolutePath = path.resolve(filePath);
|
|
14
|
+
if (isPathInside(absolutePath, repoRoot)) {
|
|
15
|
+
return portableRelativePath(repoRoot, absolutePath);
|
|
16
|
+
}
|
|
17
|
+
const globalPaths = resolveScopedPaths(layout, 'global', repoRoot);
|
|
18
|
+
const globalAgentDir = path.dirname(globalPaths.hooksSettingsPath);
|
|
19
|
+
if (isPathInside(absolutePath, globalAgentDir)) {
|
|
20
|
+
return `${GLOBAL_INTEGRITY_PREFIX}${portableRelativePath(globalAgentDir, absolutePath)}`;
|
|
21
|
+
}
|
|
22
|
+
throw new Error(`Integrity file is outside the repository and global adapter root: ${filePath}`);
|
|
23
|
+
}
|
|
8
24
|
export async function sha256File(filePath) {
|
|
9
25
|
const content = await readFile(filePath);
|
|
10
26
|
return createHash('sha256').update(content).digest('hex');
|
|
@@ -12,11 +28,8 @@ export async function sha256File(filePath) {
|
|
|
12
28
|
export function integrityManifestPath(layout, repoRoot) {
|
|
13
29
|
return path.join(layout.repoLocalStateDir(repoRoot), 'integrity-manifest.json');
|
|
14
30
|
}
|
|
15
|
-
export function runtimeIntegrityFiles(
|
|
31
|
+
export function runtimeIntegrityFiles(layout, paths) {
|
|
16
32
|
const files = [paths.configPath];
|
|
17
|
-
if (paths.scope !== 'project') {
|
|
18
|
-
return files;
|
|
19
|
-
}
|
|
20
33
|
const hooksDir = paths.hooksDir;
|
|
21
34
|
const runtimeDir = paths.runtimeDir;
|
|
22
35
|
return [
|
|
@@ -28,7 +41,9 @@ export function runtimeIntegrityFiles(_layout, paths) {
|
|
|
28
41
|
path.join(hooksDir, 'belay-audit.mjs'),
|
|
29
42
|
path.join(hooksDir, 'belay-runner'),
|
|
30
43
|
path.join(hooksDir, 'belay-runner.cmd'),
|
|
44
|
+
...(layout.name === 'cursor' ? [path.join(hooksDir, 'belay-runner.ps1')] : []),
|
|
31
45
|
path.join(runtimeDir, 'core.mjs'),
|
|
46
|
+
...(layout.name === 'cursor' ? [path.join(runtimeDir, 'dispatcher.mjs')] : []),
|
|
32
47
|
];
|
|
33
48
|
}
|
|
34
49
|
export async function writeIntegrityManifest(repoRoot, layout, filePaths) {
|
|
@@ -37,8 +52,8 @@ export async function writeIntegrityManifest(repoRoot, layout, filePaths) {
|
|
|
37
52
|
if (!existsSync(filePath)) {
|
|
38
53
|
continue;
|
|
39
54
|
}
|
|
40
|
-
const
|
|
41
|
-
files[
|
|
55
|
+
const manifestKey = integrityManifestFileKey(repoRoot, layout, filePath);
|
|
56
|
+
files[manifestKey] = await sha256File(filePath);
|
|
42
57
|
}
|
|
43
58
|
const manifest = {
|
|
44
59
|
version: 1,
|
|
@@ -49,22 +64,47 @@ export async function writeIntegrityManifest(repoRoot, layout, filePaths) {
|
|
|
49
64
|
await mkdir(path.dirname(manifestPath), { recursive: true });
|
|
50
65
|
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
|
51
66
|
}
|
|
52
|
-
export async function verifyIntegrityManifest(repoRoot, layout) {
|
|
67
|
+
export async function verifyIntegrityManifest(repoRoot, layout, requiredFilePaths = []) {
|
|
53
68
|
const manifestPath = integrityManifestPath(layout, repoRoot);
|
|
54
69
|
if (!existsSync(manifestPath)) {
|
|
55
70
|
return { ok: false, mismatches: ['missing integrity-manifest.json'] };
|
|
56
71
|
}
|
|
57
72
|
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
|
58
73
|
const mismatches = [];
|
|
59
|
-
|
|
60
|
-
|
|
74
|
+
const requiredPathsByKey = new Map(requiredFilePaths.map((requiredPath) => [
|
|
75
|
+
integrityManifestFileKey(repoRoot, layout, requiredPath),
|
|
76
|
+
path.resolve(requiredPath),
|
|
77
|
+
]));
|
|
78
|
+
for (const requiredPath of requiredFilePaths) {
|
|
79
|
+
const requiredKey = integrityManifestFileKey(repoRoot, layout, requiredPath);
|
|
80
|
+
if (!Object.hasOwn(manifest.files ?? {}, requiredKey)) {
|
|
81
|
+
mismatches.push(`missing integrity pin ${requiredKey}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
for (const [manifestKey, expectedHash] of Object.entries(manifest.files ?? {})) {
|
|
85
|
+
let absolutePath;
|
|
86
|
+
if (manifestKey.startsWith(GLOBAL_INTEGRITY_PREFIX)) {
|
|
87
|
+
const requiredPath = requiredPathsByKey.get(manifestKey);
|
|
88
|
+
if (!requiredPath) {
|
|
89
|
+
mismatches.push(`unrecognized global integrity pin ${manifestKey}`);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
absolutePath = requiredPath;
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
absolutePath = path.resolve(repoRoot, manifestKey);
|
|
96
|
+
if (!isPathInside(absolutePath, repoRoot)) {
|
|
97
|
+
mismatches.push(`integrity pin escapes repository ${manifestKey}`);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
61
101
|
if (!existsSync(absolutePath)) {
|
|
62
|
-
mismatches.push(`missing ${
|
|
102
|
+
mismatches.push(`missing ${manifestKey}`);
|
|
63
103
|
continue;
|
|
64
104
|
}
|
|
65
105
|
const actualHash = await sha256File(absolutePath);
|
|
66
106
|
if (actualHash !== expectedHash) {
|
|
67
|
-
mismatches.push(`hash mismatch ${
|
|
107
|
+
mismatches.push(`hash mismatch ${manifestKey}`);
|
|
68
108
|
}
|
|
69
109
|
}
|
|
70
110
|
return { ok: mismatches.length === 0, mismatches };
|
package/dist/core/notify.d.ts
CHANGED
|
@@ -8,6 +8,12 @@ export interface DenyNotificationEvent {
|
|
|
8
8
|
summary: string;
|
|
9
9
|
repoRoot: string;
|
|
10
10
|
fingerprint: string;
|
|
11
|
-
approvalToken?: string;
|
|
12
11
|
}
|
|
13
|
-
export
|
|
12
|
+
export interface NotifyDependencies {
|
|
13
|
+
fetch: typeof globalThis.fetch;
|
|
14
|
+
execFile: (file: string, args: readonly string[], options: {
|
|
15
|
+
env: NodeJS.ProcessEnv;
|
|
16
|
+
}) => Promise<unknown>;
|
|
17
|
+
}
|
|
18
|
+
export declare function notificationConfigIssues(config: DenyNotificationConfig, repoRoot: string): string[];
|
|
19
|
+
export declare function notifyDeny(config: DenyNotificationConfig, event: DenyNotificationEvent, deps?: NotifyDependencies): Promise<void>;
|
package/dist/core/notify.js
CHANGED
|
@@ -1,14 +1,69 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
|
+
import path from 'node:path';
|
|
2
3
|
import { promisify } from 'node:util';
|
|
4
|
+
import { canonicalPath, pathWithinRoot } from './path-utils.js';
|
|
3
5
|
const execFileAsync = promisify(execFile);
|
|
4
|
-
|
|
5
|
-
|
|
6
|
+
const LOOPBACK_WEBHOOK_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
|
|
7
|
+
function webhookConfigIssue(url) {
|
|
8
|
+
let parsed;
|
|
9
|
+
try {
|
|
10
|
+
parsed = new URL(url);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return `notifications.webhookUrl is invalid: ${url}`;
|
|
14
|
+
}
|
|
15
|
+
if (parsed.protocol === 'https:') {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
const normalizedHostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
19
|
+
if (parsed.protocol === 'http:' && LOOPBACK_WEBHOOK_HOSTS.has(normalizedHostname)) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
return `notifications.webhookUrl must use https (http is allowed only for localhost, 127.0.0.1, or ::1): ${url}`;
|
|
23
|
+
}
|
|
24
|
+
function commandHookConfigIssue(commandHook, repoRoot) {
|
|
25
|
+
if (!path.isAbsolute(commandHook)) {
|
|
26
|
+
return `notifications.commandHook must be an absolute path: ${commandHook}`;
|
|
27
|
+
}
|
|
28
|
+
if (pathWithinRoot(canonicalPath(repoRoot), canonicalPath(commandHook))) {
|
|
29
|
+
return `notifications.commandHook must not be inside the repository: ${commandHook}`;
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
export function notificationConfigIssues(config, repoRoot) {
|
|
34
|
+
const issues = [];
|
|
6
35
|
if (config.webhookUrl) {
|
|
36
|
+
const issue = webhookConfigIssue(config.webhookUrl);
|
|
37
|
+
if (issue) {
|
|
38
|
+
issues.push(issue);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (config.commandHook) {
|
|
42
|
+
const issue = commandHookConfigIssue(config.commandHook, repoRoot);
|
|
43
|
+
if (issue) {
|
|
44
|
+
issues.push(issue);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return issues;
|
|
48
|
+
}
|
|
49
|
+
export async function notifyDeny(config, event, deps = {
|
|
50
|
+
fetch: globalThis.fetch.bind(globalThis),
|
|
51
|
+
execFile: (file, args, options) => execFileAsync(file, [...args], options),
|
|
52
|
+
}) {
|
|
53
|
+
const payload = JSON.stringify({
|
|
54
|
+
approvalId: event.approvalId,
|
|
55
|
+
reason: event.reason,
|
|
56
|
+
summary: event.summary,
|
|
57
|
+
repoRoot: event.repoRoot,
|
|
58
|
+
fingerprint: event.fingerprint,
|
|
59
|
+
});
|
|
60
|
+
const webhookIssue = config.webhookUrl ? webhookConfigIssue(config.webhookUrl) : null;
|
|
61
|
+
if (config.webhookUrl && !webhookIssue) {
|
|
7
62
|
try {
|
|
8
63
|
const controller = new AbortController();
|
|
9
64
|
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
10
65
|
try {
|
|
11
|
-
await fetch(config.webhookUrl, {
|
|
66
|
+
await deps.fetch(config.webhookUrl, {
|
|
12
67
|
method: 'POST',
|
|
13
68
|
headers: { 'content-type': 'application/json' },
|
|
14
69
|
body: payload,
|
|
@@ -23,17 +78,18 @@ export async function notifyDeny(config, event) {
|
|
|
23
78
|
// best-effort notification
|
|
24
79
|
}
|
|
25
80
|
}
|
|
26
|
-
|
|
81
|
+
const commandHookIssue = config.commandHook
|
|
82
|
+
? commandHookConfigIssue(config.commandHook, event.repoRoot)
|
|
83
|
+
: null;
|
|
84
|
+
if (config.commandHook && !commandHookIssue) {
|
|
27
85
|
try {
|
|
28
|
-
await
|
|
86
|
+
await deps.execFile(config.commandHook, [], {
|
|
29
87
|
env: {
|
|
30
|
-
...process.env,
|
|
31
88
|
BELAY_APPROVAL_ID: event.approvalId,
|
|
32
89
|
BELAY_REASON: event.reason,
|
|
33
90
|
BELAY_SUMMARY: event.summary,
|
|
34
91
|
BELAY_REPO_ROOT: event.repoRoot,
|
|
35
92
|
BELAY_FINGERPRINT: event.fingerprint,
|
|
36
|
-
BELAY_APPROVAL_TOKEN: event.approvalToken ?? '',
|
|
37
93
|
},
|
|
38
94
|
});
|
|
39
95
|
}
|
|
@@ -3,13 +3,13 @@ export function recoveryNotificationConfigured(config) {
|
|
|
3
3
|
}
|
|
4
4
|
export function recoveryApprovalSetupNotes() {
|
|
5
5
|
return [
|
|
6
|
-
'Recovery restore flow: run `belay recover apply <checkpoint-id>`, approve with `belay approve <approval-id> --token <signed-token>`, then run the same apply command again.',
|
|
7
|
-
'Recovery restore always requires a signed
|
|
6
|
+
'Recovery restore flow: run `belay recover apply <checkpoint-id>`, retrieve the token locally with `belay approval-token <approval-id>`, approve with `belay approve <approval-id> --token <signed-token>`, then run the same apply command again.',
|
|
7
|
+
'Recovery restore always requires a signed local-control-plane token, even when approvalSigning.required is false for general approvals.',
|
|
8
8
|
];
|
|
9
9
|
}
|
|
10
10
|
export function recoveryNotificationSetupWarning() {
|
|
11
|
-
return ('
|
|
12
|
-
'
|
|
11
|
+
return ('No notification channel is configured, so recovery approval alerts are shown only by the local CLI. ' +
|
|
12
|
+
'Optionally set notifications.webhookUrl or notifications.commandHook (e.g. via `belay config`) to receive approval IDs out of band.');
|
|
13
13
|
}
|
|
14
14
|
export function formatRecoveryStateDiagnostic(state, detail) {
|
|
15
15
|
switch (state) {
|
|
@@ -3,6 +3,8 @@ import type { ScrubOptions } from './types.js';
|
|
|
3
3
|
export declare function redactToolInvocationId(value: unknown, rawToolUseId?: string): unknown;
|
|
4
4
|
/** Subagent fingerprint input — must match classify-subagent `fingerprintSource`. */
|
|
5
5
|
export declare function subagentFingerprintSource(payload: Record<string, unknown>, scrubOptions: ScrubOptions): unknown;
|
|
6
|
+
/** Stable tool-input source for fingerprints — redacts volatile ids only, not secret values. */
|
|
7
|
+
export declare function fingerprintToolInputSource(payload: Record<string, unknown>): Record<string, unknown>;
|
|
6
8
|
/**
|
|
7
9
|
* Scrubbed payload used for replay envelope hashing — aligned with classifier fingerprints.
|
|
8
10
|
* Tool: scrubbed `tool_input`. Subagent: description/prompt subset. Other: full payload.
|
|
@@ -40,6 +40,14 @@ export function subagentFingerprintSource(payload, scrubOptions) {
|
|
|
40
40
|
}
|
|
41
41
|
return scrubValue(payload, scrubOptions);
|
|
42
42
|
}
|
|
43
|
+
/** Stable tool-input source for fingerprints — redacts volatile ids only, not secret values. */
|
|
44
|
+
export function fingerprintToolInputSource(payload) {
|
|
45
|
+
const toolInput = payload.tool_input;
|
|
46
|
+
if (!toolInput || typeof toolInput !== 'object') {
|
|
47
|
+
return {};
|
|
48
|
+
}
|
|
49
|
+
return redactToolInvocationId(toolInput, typeof payload.tool_use_id === 'string' ? payload.tool_use_id : undefined);
|
|
50
|
+
}
|
|
43
51
|
/**
|
|
44
52
|
* Scrubbed payload used for replay envelope hashing — aligned with classifier fingerprints.
|
|
45
53
|
* Tool: scrubbed `tool_input`. Subagent: description/prompt subset. Other: full payload.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { AdapterName } from '../types.js';
|
|
2
|
+
export interface RepoConfigTrustRecordV1 {
|
|
3
|
+
schemaVersion: 1;
|
|
4
|
+
repoRoot: string;
|
|
5
|
+
adapter: 'cursor' | 'claude' | 'codex';
|
|
6
|
+
repoConfigFingerprint: string;
|
|
7
|
+
trustedAt: string;
|
|
8
|
+
}
|
|
9
|
+
export type RepoConfigTrustStatus = {
|
|
10
|
+
trusted: true;
|
|
11
|
+
recordPath: string;
|
|
12
|
+
fingerprint: string;
|
|
13
|
+
} | {
|
|
14
|
+
trusted: false;
|
|
15
|
+
recordPath: string;
|
|
16
|
+
reason: 'missing' | 'malformed' | 'identity_mismatch' | 'fingerprint_mismatch';
|
|
17
|
+
};
|
|
18
|
+
export declare function repoConfigFingerprint(rawConfig: unknown): string;
|
|
19
|
+
export declare function repoConfigTrustPath(repoRoot: string, adapter: AdapterName): string;
|
|
20
|
+
export declare function inspectRepoConfigTrust(repoRoot: string, adapter: AdapterName, rawConfig: unknown): Promise<RepoConfigTrustStatus>;
|
|
21
|
+
export declare function trustRepoConfig(repoRoot: string, adapter: AdapterName, rawConfig: unknown): Promise<RepoConfigTrustRecordV1>;
|
|
22
|
+
export declare class RepoConfigTrustError extends Error {
|
|
23
|
+
readonly status: Exclude<RepoConfigTrustStatus, {
|
|
24
|
+
trusted: true;
|
|
25
|
+
}>;
|
|
26
|
+
constructor(status: Exclude<RepoConfigTrustStatus, {
|
|
27
|
+
trusted: true;
|
|
28
|
+
}>);
|
|
29
|
+
}
|
|
30
|
+
export declare function isRepoConfigTrustError(error: unknown): error is RepoConfigTrustError;
|
|
31
|
+
export declare function assertRepoConfigTrusted(repoRoot: string, adapter: AdapterName, rawConfig: unknown): Promise<void>;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { chmod, mkdir, open, readFile, rename, unlink } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { defaultControlPlaneDir } from './config.js';
|
|
6
|
+
import { canonicalStringify, hashValue } from './fingerprint.js';
|
|
7
|
+
import { canonicalPath } from './path-utils.js';
|
|
8
|
+
const TRUST_MESSAGE = 'Repository config is not trusted. Review it, then run `belay config trust`.';
|
|
9
|
+
function isObjectRecord(value) {
|
|
10
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
11
|
+
}
|
|
12
|
+
function hasOnlyExpectedKeys(value) {
|
|
13
|
+
const expected = new Set([
|
|
14
|
+
'schemaVersion',
|
|
15
|
+
'repoRoot',
|
|
16
|
+
'adapter',
|
|
17
|
+
'repoConfigFingerprint',
|
|
18
|
+
'trustedAt',
|
|
19
|
+
]);
|
|
20
|
+
const keys = Object.keys(value);
|
|
21
|
+
if (keys.length !== expected.size) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
return keys.every((key) => expected.has(key));
|
|
25
|
+
}
|
|
26
|
+
function isAdapterName(value) {
|
|
27
|
+
return value === 'cursor' || value === 'claude' || value === 'codex';
|
|
28
|
+
}
|
|
29
|
+
function isIsoTimestamp(value) {
|
|
30
|
+
return Number.isFinite(Date.parse(value));
|
|
31
|
+
}
|
|
32
|
+
function parseStrictTrustRecord(value) {
|
|
33
|
+
if (!isObjectRecord(value) || !hasOnlyExpectedKeys(value)) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
if (value.schemaVersion !== 1) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
if (typeof value.repoRoot !== 'string' || value.repoRoot.trim().length === 0) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
if (!isAdapterName(value.adapter)) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
if (typeof value.repoConfigFingerprint !== 'string' ||
|
|
46
|
+
!/^[a-f0-9]{64}$/.test(value.repoConfigFingerprint)) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
if (typeof value.trustedAt !== 'string' || !isIsoTimestamp(value.trustedAt)) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
schemaVersion: 1,
|
|
54
|
+
repoRoot: value.repoRoot,
|
|
55
|
+
adapter: value.adapter,
|
|
56
|
+
repoConfigFingerprint: value.repoConfigFingerprint,
|
|
57
|
+
trustedAt: value.trustedAt,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export function repoConfigFingerprint(rawConfig) {
|
|
61
|
+
return hashValue(canonicalStringify(rawConfig));
|
|
62
|
+
}
|
|
63
|
+
export function repoConfigTrustPath(repoRoot, adapter) {
|
|
64
|
+
const canonicalRepoRoot = canonicalPath(repoRoot);
|
|
65
|
+
const identity = hashValue(`${canonicalRepoRoot}\u0000${adapter}`);
|
|
66
|
+
return path.join(defaultControlPlaneDir(), 'config-trust', `${identity}.json`);
|
|
67
|
+
}
|
|
68
|
+
async function writeTrustRecordAtomically(recordPath, record) {
|
|
69
|
+
const directory = path.dirname(recordPath);
|
|
70
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
71
|
+
await chmod(directory, 0o700).catch(() => undefined);
|
|
72
|
+
const tempPath = path.join(directory, `.${path.basename(recordPath)}.${process.pid}.${randomUUID()}.tmp`);
|
|
73
|
+
let handle = null;
|
|
74
|
+
try {
|
|
75
|
+
handle = await open(tempPath, 'wx', 0o600);
|
|
76
|
+
await handle.writeFile(`${JSON.stringify(record, null, 2)}\n`, 'utf8');
|
|
77
|
+
await handle.sync();
|
|
78
|
+
await handle.close();
|
|
79
|
+
handle = null;
|
|
80
|
+
await rename(tempPath, recordPath);
|
|
81
|
+
await chmod(recordPath, 0o600);
|
|
82
|
+
const directoryHandle = await open(directory, 'r');
|
|
83
|
+
try {
|
|
84
|
+
await directoryHandle.sync().catch(() => undefined);
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
await directoryHandle.close();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
if (handle) {
|
|
92
|
+
await handle.close().catch(() => undefined);
|
|
93
|
+
}
|
|
94
|
+
await unlink(tempPath).catch(() => undefined);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
export async function inspectRepoConfigTrust(repoRoot, adapter, rawConfig) {
|
|
98
|
+
const canonicalRepoRoot = canonicalPath(repoRoot);
|
|
99
|
+
const recordPath = repoConfigTrustPath(repoRoot, adapter);
|
|
100
|
+
if (!existsSync(recordPath)) {
|
|
101
|
+
return { trusted: false, recordPath, reason: 'missing' };
|
|
102
|
+
}
|
|
103
|
+
let parsed;
|
|
104
|
+
try {
|
|
105
|
+
parsed = JSON.parse(await readFile(recordPath, 'utf8'));
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return { trusted: false, recordPath, reason: 'malformed' };
|
|
109
|
+
}
|
|
110
|
+
const record = parseStrictTrustRecord(parsed);
|
|
111
|
+
if (!record) {
|
|
112
|
+
return { trusted: false, recordPath, reason: 'malformed' };
|
|
113
|
+
}
|
|
114
|
+
if (record.adapter !== adapter || canonicalPath(record.repoRoot) !== canonicalRepoRoot) {
|
|
115
|
+
return { trusted: false, recordPath, reason: 'identity_mismatch' };
|
|
116
|
+
}
|
|
117
|
+
const fingerprint = repoConfigFingerprint(rawConfig);
|
|
118
|
+
if (record.repoConfigFingerprint !== fingerprint) {
|
|
119
|
+
return { trusted: false, recordPath, reason: 'fingerprint_mismatch' };
|
|
120
|
+
}
|
|
121
|
+
return { trusted: true, recordPath, fingerprint };
|
|
122
|
+
}
|
|
123
|
+
export async function trustRepoConfig(repoRoot, adapter, rawConfig) {
|
|
124
|
+
const recordPath = repoConfigTrustPath(repoRoot, adapter);
|
|
125
|
+
const record = {
|
|
126
|
+
schemaVersion: 1,
|
|
127
|
+
repoRoot: canonicalPath(repoRoot),
|
|
128
|
+
adapter,
|
|
129
|
+
repoConfigFingerprint: repoConfigFingerprint(rawConfig),
|
|
130
|
+
trustedAt: new Date().toISOString(),
|
|
131
|
+
};
|
|
132
|
+
await writeTrustRecordAtomically(recordPath, record);
|
|
133
|
+
return record;
|
|
134
|
+
}
|
|
135
|
+
export class RepoConfigTrustError extends Error {
|
|
136
|
+
status;
|
|
137
|
+
constructor(status) {
|
|
138
|
+
super(TRUST_MESSAGE);
|
|
139
|
+
this.name = 'RepoConfigTrustError';
|
|
140
|
+
this.status = status;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
export function isRepoConfigTrustError(error) {
|
|
144
|
+
return error instanceof RepoConfigTrustError;
|
|
145
|
+
}
|
|
146
|
+
export async function assertRepoConfigTrusted(repoRoot, adapter, rawConfig) {
|
|
147
|
+
const status = await inspectRepoConfigTrust(repoRoot, adapter, rawConfig);
|
|
148
|
+
if (status.trusted) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
throw new RepoConfigTrustError(status);
|
|
152
|
+
}
|
|
@@ -16,7 +16,7 @@ export function findCommandSubstitutions(command) {
|
|
|
16
16
|
index += 1;
|
|
17
17
|
continue;
|
|
18
18
|
}
|
|
19
|
-
if (char === '\\' &&
|
|
19
|
+
if (char === '\\' && inDouble) {
|
|
20
20
|
escaping = true;
|
|
21
21
|
index += 1;
|
|
22
22
|
continue;
|
|
@@ -156,7 +156,7 @@ function extractBalancedParenContent(command, start) {
|
|
|
156
156
|
index += 1;
|
|
157
157
|
continue;
|
|
158
158
|
}
|
|
159
|
-
if (char === '\\' &&
|
|
159
|
+
if (char === '\\' && inDouble) {
|
|
160
160
|
escaping = true;
|
|
161
161
|
index += 1;
|
|
162
162
|
continue;
|
|
@@ -3,4 +3,4 @@
|
|
|
3
3
|
* These commands guard classifier availability in tests and never grant runtime authority.
|
|
4
4
|
* @see src/__tests__/verdict/structural-suite.test.ts
|
|
5
5
|
*/
|
|
6
|
-
export declare const BENIGN_PROBE_CORES: readonly ["npm test", "npm run build", "pnpm test", "pnpm build", "pnpm vitest run src/example.test.ts", "bash -lc 'git status'", "
|
|
6
|
+
export declare const BENIGN_PROBE_CORES: readonly ["npm test", "npm run build", "pnpm test", "pnpm build", "pnpm vitest run src/example.test.ts", "bash -lc 'git status'", "bundle -v", "ruby -v", "yarn --version", "make -n test", "bin/rails routes", "bundle exec rubocop --version", "bundle exec rubocop test/upgrade_script_contract_test.rb", "ruby -Itest test/upgrade_script_contract_test.rb"];
|
package/dist/defaults.d.ts
CHANGED
|
@@ -5,13 +5,17 @@ export type ManagedHookDefinition = {
|
|
|
5
5
|
command: string;
|
|
6
6
|
placement: 'prepend' | 'append';
|
|
7
7
|
matcher?: string;
|
|
8
|
+
failClosed?: boolean;
|
|
8
9
|
};
|
|
9
|
-
export
|
|
10
|
+
export type CursorManagedHookDefinition = ManagedHookDefinition & {
|
|
11
|
+
failClosed: true;
|
|
12
|
+
};
|
|
13
|
+
export declare function getManagedHookEntries(platform?: NodeJS.Platform, hooksDir?: string, repoRoot?: string, runnerPathMode?: 'absolute' | 'legacy-relative' | 'legacy-absolute' | 'legacy-quoted-absolute' | 'legacy-bare-powershell-absolute'): Array<{
|
|
10
14
|
event: string;
|
|
11
|
-
definition:
|
|
15
|
+
definition: CursorManagedHookDefinition;
|
|
12
16
|
}>;
|
|
13
17
|
/** @deprecated Use getManagedHookEntries instead. */
|
|
14
|
-
export declare function getManagedHookEvents(platform?: NodeJS.Platform): Record<string,
|
|
18
|
+
export declare function getManagedHookEvents(platform?: NodeJS.Platform): Record<string, CursorManagedHookDefinition>;
|
|
15
19
|
export declare const EMPTY_APPROVALS: {
|
|
16
20
|
readonly version: 1;
|
|
17
21
|
readonly approvals: readonly [];
|