@guilz-dev/belay 0.9.4 → 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 +3 -2
- package/dist/adapters/cursor/hook-dispatch-entry.d.ts +3 -0
- package/dist/adapters/cursor/hook-dispatch-entry.js +11 -0
- package/dist/adapters/cursor/hook-router.js +14 -7
- package/dist/adapters/cursor/hooks.d.ts +1 -1
- package/dist/adapters/cursor/hooks.js +16 -5
- 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/runtime-entry.d.ts +2 -2
- package/dist/adapters/cursor/runtime-entry.js +35 -10
- package/dist/adapters/shared/gate-runtime.js +28 -17
- package/dist/bundle/claude-runtime.mjs +602 -438
- package/dist/bundle/codex-runtime.mjs +612 -448
- package/dist/bundle/cursor-dispatcher.mjs +100 -26
- package/dist/bundle/cursor-runtime.mjs +662 -470
- 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 +10 -54
- 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/judge.js +2 -2
- package/dist/commands/recovery-checkpoints.js +2 -11
- package/dist/config-io.d.ts +1 -0
- package/dist/config-io.js +8 -1
- package/dist/core/dogfood-environment.d.ts +8 -0
- package/dist/core/dogfood-environment.js +55 -0
- package/dist/core/effect-ir/shell-lower/decoders/belay.js +12 -0
- package/dist/core/egress-approval.js +0 -18
- 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/repo-config-trust.d.ts +31 -0
- package/dist/core/repo-config-trust.js +152 -0
- package/dist/corpus/benign-probe-cores.d.ts +1 -1
- package/dist/corpus/benign-probe-cores.js +0 -1
- package/dist/defaults.js +0 -25
- package/dist/installer/scope-config.js +2 -2
- package/dist/installer.js +4 -4
- package/dist/types.d.ts +19 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +5 -1
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { getAdapterLayout } from '../adapters/layouts/index.js';
|
|
2
|
+
import type { AdapterName } from '../types.js';
|
|
3
|
+
export declare function listLinkedWorktreePaths(repoRoot: string): Promise<string[]>;
|
|
4
|
+
export declare function detectUndogfoodedLinkedWorktrees(params: {
|
|
5
|
+
repoRoot: string;
|
|
6
|
+
adapterName: AdapterName;
|
|
7
|
+
layout: ReturnType<typeof getAdapterLayout>;
|
|
8
|
+
}): Promise<string[]>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { loadLayeredConfig } from '../config-io.js';
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
export async function listLinkedWorktreePaths(repoRoot) {
|
|
8
|
+
try {
|
|
9
|
+
const { stdout } = await execFileAsync('git', ['worktree', 'list', '--porcelain'], {
|
|
10
|
+
cwd: repoRoot,
|
|
11
|
+
encoding: 'utf8',
|
|
12
|
+
});
|
|
13
|
+
return stdout
|
|
14
|
+
.split(/\r?\n/)
|
|
15
|
+
.filter((line) => line.startsWith('worktree '))
|
|
16
|
+
.map((line) => line.slice('worktree '.length).trim())
|
|
17
|
+
.filter((entry) => entry.length > 0);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export async function detectUndogfoodedLinkedWorktrees(params) {
|
|
24
|
+
const repoRootCanonical = realpathSync(params.repoRoot);
|
|
25
|
+
const worktrees = await listLinkedWorktreePaths(params.repoRoot);
|
|
26
|
+
const warnings = [];
|
|
27
|
+
for (const worktreePath of worktrees) {
|
|
28
|
+
let canonicalPath = worktreePath;
|
|
29
|
+
try {
|
|
30
|
+
canonicalPath = realpathSync(worktreePath);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// Keep the original path for warning output when the entry is stale.
|
|
34
|
+
}
|
|
35
|
+
if (canonicalPath === repoRootCanonical) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const configPath = params.layout.configPath(worktreePath);
|
|
39
|
+
if (!existsSync(configPath)) {
|
|
40
|
+
warnings.push(`Dogfood is active here but ${path.basename(worktreePath)} has no belay.config.json (defaults to enforce). Run belay dogfood in each worktree you use with Cursor.`);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
const candidate = await loadLayeredConfig(worktreePath, params.adapterName);
|
|
45
|
+
const dogfoodEnabled = candidate.config.mode === 'audit' && candidate.config.policy.unknownLocalEffect === 'deny';
|
|
46
|
+
if (!dogfoodEnabled) {
|
|
47
|
+
warnings.push(`Dogfood is active here but ${path.basename(worktreePath)} is not in dogfood mode (mode=${candidate.config.mode}, unknownLocalEffect=${candidate.config.policy.unknownLocalEffect}). Run belay dogfood in each worktree you use with Cursor.`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
warnings.push(`Dogfood is active here but ${path.basename(worktreePath)} has an unreadable belay.config.json. Run belay doctor and belay dogfood in that worktree.`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return warnings;
|
|
55
|
+
}
|
|
@@ -12,6 +12,13 @@ export function decodeBelay(args, repoRoot, segment) {
|
|
|
12
12
|
const configJudgeMutation = section === 'config' &&
|
|
13
13
|
((['set', 'unset'].includes(operation ?? '') && key?.startsWith('judge.')) ||
|
|
14
14
|
(operation === 'credential' && key === 'mode'));
|
|
15
|
+
const approvalAuthorityCommand = [
|
|
16
|
+
'approval-token',
|
|
17
|
+
'approve',
|
|
18
|
+
'revoke',
|
|
19
|
+
'standing-allow',
|
|
20
|
+
].includes(section ?? '');
|
|
21
|
+
const configTrustMutation = section === 'config' && operation === 'trust';
|
|
15
22
|
if (judgeCommand || configRead || configJudgeMutation) {
|
|
16
23
|
return [
|
|
17
24
|
processRequirement('belay', 'inspect', segment, [
|
|
@@ -20,6 +27,11 @@ export function decodeBelay(args, repoRoot, segment) {
|
|
|
20
27
|
]),
|
|
21
28
|
];
|
|
22
29
|
}
|
|
30
|
+
if (approvalAuthorityCommand || configTrustMutation) {
|
|
31
|
+
return [
|
|
32
|
+
requirement('control_plane.write', 'control_plane.write', { kind: 'path', path: path.join(repoRoot, '.belay-control-plane') }, segment, [approvalAuthorityCommand ? 'belay.approval_authority' : 'belay.config_trust']),
|
|
33
|
+
];
|
|
34
|
+
}
|
|
23
35
|
if (section === 'config' && ['set', 'unset', 'credential'].includes(operation ?? '')) {
|
|
24
36
|
return [
|
|
25
37
|
requirement('control_plane.write', 'control_plane.write', { kind: 'path', path: path.join(repoRoot, '.belay-control-plane') }, segment, ['belay.config_non_judge_mutation']),
|
|
@@ -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/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) {
|
|
@@ -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
|
+
}
|
|
@@ -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.js
CHANGED
|
@@ -44,31 +44,6 @@ export function getManagedHookEntries(platform = process.platform, hooksDir, rep
|
|
|
44
44
|
definition: {
|
|
45
45
|
command: toolGate,
|
|
46
46
|
placement: 'prepend',
|
|
47
|
-
matcher: 'Task',
|
|
48
|
-
},
|
|
49
|
-
},
|
|
50
|
-
{
|
|
51
|
-
event: 'preToolUse',
|
|
52
|
-
definition: {
|
|
53
|
-
command: toolGate,
|
|
54
|
-
placement: 'prepend',
|
|
55
|
-
matcher: 'Write',
|
|
56
|
-
},
|
|
57
|
-
},
|
|
58
|
-
{
|
|
59
|
-
event: 'preToolUse',
|
|
60
|
-
definition: {
|
|
61
|
-
command: toolGate,
|
|
62
|
-
placement: 'prepend',
|
|
63
|
-
matcher: 'StrReplace',
|
|
64
|
-
},
|
|
65
|
-
},
|
|
66
|
-
{
|
|
67
|
-
event: 'preToolUse',
|
|
68
|
-
definition: {
|
|
69
|
-
command: toolGate,
|
|
70
|
-
placement: 'prepend',
|
|
71
|
-
matcher: 'Delete',
|
|
72
47
|
},
|
|
73
48
|
},
|
|
74
49
|
{
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { getAdapterLayout } from '../adapters/layouts/index.js';
|
|
3
3
|
import { resolveInstallScope, resolveScopedPaths, } from '../adapters/layouts/scope.js';
|
|
4
|
-
import { loadConfigFile,
|
|
4
|
+
import { loadConfigFile, writeTrustedConfigFile } from '../config-io.js';
|
|
5
5
|
export async function resolveOperationScope(repoRoot, adapter, options = {}) {
|
|
6
6
|
const layout = getAdapterLayout(adapter);
|
|
7
7
|
let persisted;
|
|
@@ -17,7 +17,7 @@ export async function applyInstallScope(repoRoot, adapter, scope, config) {
|
|
|
17
17
|
return current;
|
|
18
18
|
}
|
|
19
19
|
const updated = { ...current, installScope: scope };
|
|
20
|
-
await
|
|
20
|
+
await writeTrustedConfigFile(repoRoot, updated, adapter);
|
|
21
21
|
return updated;
|
|
22
22
|
}
|
|
23
23
|
export function pathsForOperation(adapter, scope, repoRoot) {
|
package/dist/installer.js
CHANGED
|
@@ -6,7 +6,7 @@ import { cursorLayout } from './adapters/layouts/cursor.js';
|
|
|
6
6
|
import { resolveScopedPaths } from './adapters/layouts/scope.js';
|
|
7
7
|
import { getAdapter } from './adapters/registry.js';
|
|
8
8
|
import { dogfoodProject } from './commands/dogfood.js';
|
|
9
|
-
import { detectAdapterName, loadConfigFile, mergeAndWriteConfig,
|
|
9
|
+
import { detectAdapterName, loadConfigFile, mergeAndWriteConfig, writeTrustedConfigFile, } from './config-io.js';
|
|
10
10
|
import { appendCliAuditEvent } from './core/audit-io.js';
|
|
11
11
|
import { archiveLegacyAuditLogIfNeeded } from './core/audit-legacy-archive.js';
|
|
12
12
|
import { isFreshConfigInput, mergeConfig, normalizeConfig, } from './core/config.js';
|
|
@@ -230,7 +230,7 @@ async function applyInitJudgeConfig(repoRoot, adapterName, options, isFreshBefor
|
|
|
230
230
|
!hasValidCloudConsent(configWithJudge.judge)) {
|
|
231
231
|
process.stderr.write('Warning: Cloud judge saved without recorded consent. Tier1 cloud judge will fail closed until consent is granted (belay judge consent + belay approve, or TTY --accept-cloud-judge).\n');
|
|
232
232
|
}
|
|
233
|
-
await
|
|
233
|
+
await writeTrustedConfigFile(repoRoot, configWithJudge, adapterName);
|
|
234
234
|
if (migrated) {
|
|
235
235
|
await auditJudgeMigrationIfNeeded(repoRoot, adapterName, mergedConfig.judge, finalJudge, options.migrateJudgeDefault ? 'belay init --migrate-judge-default' : 'belay init');
|
|
236
236
|
}
|
|
@@ -263,7 +263,7 @@ export async function initProject(options = {}) {
|
|
|
263
263
|
const existing = await loadConfigFile(repoRoot, adapterName);
|
|
264
264
|
const presetConfig = mergeConfig(applyConfigPreset(options.preset));
|
|
265
265
|
const merged = mergeConfig(presetConfig, existing);
|
|
266
|
-
await
|
|
266
|
+
await writeTrustedConfigFile(repoRoot, merged, adapterName);
|
|
267
267
|
}
|
|
268
268
|
if (options.dogfood === true) {
|
|
269
269
|
await dogfoodProject({ targetDir: repoRoot, adapter: adapterName });
|
|
@@ -285,7 +285,7 @@ export async function upgradeProject(options = {}) {
|
|
|
285
285
|
const migrated = migrateImplicitLocalJudgeIfNeeded(mergedConfig.judge, adapterName);
|
|
286
286
|
if (migrated) {
|
|
287
287
|
const configWithJudge = normalizeConfig({ ...mergedConfig, version: 4, judge: migrated });
|
|
288
|
-
await
|
|
288
|
+
await writeTrustedConfigFile(repoRoot, configWithJudge, adapterName);
|
|
289
289
|
await auditJudgeMigrationIfNeeded(repoRoot, adapterName, mergedConfig.judge, migrated, 'belay upgrade --migrate-judge-default');
|
|
290
290
|
}
|
|
291
291
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -238,6 +238,8 @@ export interface DogfoodOptions {
|
|
|
238
238
|
targetDir?: string;
|
|
239
239
|
enforce?: boolean;
|
|
240
240
|
force?: boolean;
|
|
241
|
+
check?: boolean;
|
|
242
|
+
since?: string;
|
|
241
243
|
adapter?: AdapterName;
|
|
242
244
|
}
|
|
243
245
|
export interface DogfoodResult {
|
|
@@ -248,6 +250,23 @@ export interface DogfoodResult {
|
|
|
248
250
|
mode: string;
|
|
249
251
|
unknownLocalEffect: string;
|
|
250
252
|
}
|
|
253
|
+
export interface DogfoodCheckOptions {
|
|
254
|
+
targetDir?: string;
|
|
255
|
+
adapter?: AdapterName;
|
|
256
|
+
since: string;
|
|
257
|
+
}
|
|
258
|
+
export interface DogfoodCheckResult {
|
|
259
|
+
ok: boolean;
|
|
260
|
+
repoRoot: string;
|
|
261
|
+
since: string;
|
|
262
|
+
gateEvents: number;
|
|
263
|
+
auditModeDenyCount: number;
|
|
264
|
+
hostDeniedAfterAllowCount: number;
|
|
265
|
+
shellPreToolUseCount: number;
|
|
266
|
+
mismatchedCohortCount: number;
|
|
267
|
+
environmentSkewCount: number;
|
|
268
|
+
failures: string[];
|
|
269
|
+
}
|
|
251
270
|
export type ExplainKind = 'shell' | 'tool' | 'subagent';
|
|
252
271
|
export interface ExplainReport {
|
|
253
272
|
repoRoot: string;
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const PACKAGE_VERSION = "0.
|
|
1
|
+
export declare const PACKAGE_VERSION = "0.10.0";
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated by scripts/sync-version.mjs — do not edit.
|
|
2
|
-
export const PACKAGE_VERSION = '0.
|
|
2
|
+
export const PACKAGE_VERSION = '0.10.0';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@guilz-dev/belay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Belay-style approval and audit gating for agent runtimes.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -56,10 +56,14 @@
|
|
|
56
56
|
"lint": "biome check src package.json README.md CHANGELOG.md CONTRIBUTING.md SECURITY.md tsconfig.json tsconfig.build.json vitest.config.ts scripts docs",
|
|
57
57
|
"typecheck": "tsc --noEmit",
|
|
58
58
|
"test": "pnpm build && vitest run",
|
|
59
|
+
"test:run": "vitest run",
|
|
59
60
|
"test:structural": "pnpm build && vitest run src/__tests__/verdict/structural-suite.test.ts",
|
|
61
|
+
"test:structural:run": "vitest run src/__tests__/verdict/structural-suite.test.ts",
|
|
60
62
|
"test:docker": "pnpm build && vitest run --config vitest.live.config.ts src/__tests__/capability/boundary-container-isolation.test.ts src/__tests__/capability/boundary-container-workspace-mount.test.ts src/__tests__/capability/boundary-driver-container.test.ts src/__tests__/contained-execution-docker.integration.test.ts",
|
|
61
63
|
"test:llm": "pnpm build && vitest run --config vitest.live.config.ts src/__tests__/verdict/llm/judge-accuracy.test.ts",
|
|
62
64
|
"test:stable": "pnpm build && vitest run && vitest run && vitest run",
|
|
65
|
+
"test:stable:run": "vitest run && vitest run && vitest run",
|
|
66
|
+
"test:macos": "vitest run src/__tests__/native-seatbelt-boundary-probe.test.ts src/__tests__/git-resource-identity.test.ts src/__tests__/installer-scope.test.ts src/__tests__/cursor-hook-precedence.integration.test.ts src/__tests__/cursor-hooks.test.ts",
|
|
63
67
|
"corpus": "pnpm build && node scripts/corpus.mjs",
|
|
64
68
|
"probe:adversarial": "pnpm build && node scripts/adversarial-probe.mjs",
|
|
65
69
|
"probe:cursor-shell-rewrite": "node scripts/cursor-shell-rewrite-probe.mjs",
|