@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
package/README.md
CHANGED
|
@@ -342,7 +342,7 @@ Notable settings:
|
|
|
342
342
|
uses `git_worktree`; dirty Git and non-Git directories use `file_checkpoint` when separately
|
|
343
343
|
enabled (`policy.transactional.fileCheckpoint.enabled` and, for non-Git roots,
|
|
344
344
|
`allowNonGit: true`) with an attested workspace-isolating boundary. Restore is conflict-checked
|
|
345
|
-
and always requires a signed
|
|
345
|
+
and always requires a signed local-control-plane, exact one-shot approval. Network, remote Git,
|
|
346
346
|
databases, processes, and repo-external effects are outside this guarantee.
|
|
347
347
|
- **Cloud judge** — configure with `belay config` (interactive) or `belay config set judge.providerId <id>`.
|
|
348
348
|
Providers: `ollama`, `codex`, `claude`, `cursor`. **Provider** is the vendor/service
|
|
@@ -390,7 +390,8 @@ belay recover [advice] [--command "rm important.ts"] # advisory candidates only
|
|
|
390
390
|
belay recover status # checkpoint backend, eligibility, and state counts
|
|
391
391
|
belay recover list # proven repo-local recovery points
|
|
392
392
|
belay recover show <checkpoint-id>
|
|
393
|
-
belay recover apply <checkpoint-id> # signed
|
|
393
|
+
belay recover apply <checkpoint-id> # signed exact one-shot approval required
|
|
394
|
+
belay approval-token <approval-id> # operator terminal only; agent shell must ask
|
|
394
395
|
belay explain -- <shell-command> # inspect a verdict
|
|
395
396
|
belay explain --kind subagent -- "deploy to production"
|
|
396
397
|
belay explain --kind tool --tool Write -- .env
|
|
@@ -4,4 +4,7 @@ export interface DispatchCursorHookParams {
|
|
|
4
4
|
kind: CursorHookKind;
|
|
5
5
|
eventName: string;
|
|
6
6
|
}
|
|
7
|
+
type CursorResponse = Record<string, unknown>;
|
|
8
|
+
declare function dispatchCursorHookResponse(params: DispatchCursorHookParams): Promise<CursorResponse>;
|
|
7
9
|
export declare function dispatchCursorHook(params: DispatchCursorHookParams): Promise<void>;
|
|
10
|
+
export { dispatchCursorHookResponse };
|
|
@@ -97,7 +97,17 @@ async function dispatchCursorHookResponse(params) {
|
|
|
97
97
|
if (!input.ok) {
|
|
98
98
|
return failClosedResponse(params.kind, 'belay received malformed Cursor hook input.');
|
|
99
99
|
}
|
|
100
|
+
if (params.kind === 'tool-gate' &&
|
|
101
|
+
(params.eventName === 'preToolUse' || params.eventName === 'PreToolUse') &&
|
|
102
|
+
input.payload.tool_name === 'Shell') {
|
|
103
|
+
return neutralResponse(params.kind);
|
|
104
|
+
}
|
|
100
105
|
if (!isRecognizedPayload(params, input.payload)) {
|
|
106
|
+
if (params.kind === 'tool-gate' &&
|
|
107
|
+
(params.eventName === 'preToolUse' || params.eventName === 'PreToolUse') &&
|
|
108
|
+
isNonEmptyString(input.payload.tool_name)) {
|
|
109
|
+
return failClosedResponse(params.kind, 'belay received a malformed preToolUse payload. Run belay doctor, then retry.');
|
|
110
|
+
}
|
|
101
111
|
return neutralResponse(params.kind);
|
|
102
112
|
}
|
|
103
113
|
try {
|
|
@@ -124,3 +134,4 @@ export async function dispatchCursorHook(params) {
|
|
|
124
134
|
const response = await dispatchCursorHookResponse(params);
|
|
125
135
|
process.stdout.write(`${JSON.stringify(response)}\n`);
|
|
126
136
|
}
|
|
137
|
+
export { dispatchCursorHookResponse };
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { accessSync, constants, existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { resolveCursorActionCwdDetails } from './cwd-resolution.js';
|
|
4
|
+
import { isTrustedCursorRoutingConfig } from './routing-config-trust.js';
|
|
4
5
|
import { cursorRoutingConfigPath, cursorRoutingHooksDir, cursorRoutingHooksSettingsPath, cursorRoutingRuntimeDir, findCursorRoutingRepoRoot, } from './routing-layout.js';
|
|
5
6
|
function canonicalExistingPath(value) {
|
|
6
7
|
if (!existsSync(value)) {
|
|
@@ -103,18 +104,24 @@ function commandInvokesProjectHook(command, runnerPath, kind, eventName) {
|
|
|
103
104
|
].join(' ');
|
|
104
105
|
return Buffer.from(encoded, 'base64').toString('utf16le') === expected;
|
|
105
106
|
}
|
|
106
|
-
function
|
|
107
|
+
function expectedMatchers(eventName, payload) {
|
|
107
108
|
if (eventName === 'preToolUse') {
|
|
108
|
-
|
|
109
|
+
// Managed preToolUse is intentionally unfiltered. Keep the legacy matcher variant so older
|
|
110
|
+
// installed hooks still route through the same owner until upgrade.
|
|
111
|
+
const values = [undefined];
|
|
112
|
+
if (typeof payload.tool_name === 'string') {
|
|
113
|
+
values.push(payload.tool_name);
|
|
114
|
+
}
|
|
115
|
+
return values;
|
|
109
116
|
}
|
|
110
117
|
if (eventName === 'subagentStart') {
|
|
111
|
-
return typeof payload.subagent_type === 'string' ? payload.subagent_type : undefined;
|
|
118
|
+
return [typeof payload.subagent_type === 'string' ? payload.subagent_type : undefined];
|
|
112
119
|
}
|
|
113
|
-
return undefined;
|
|
120
|
+
return [undefined];
|
|
114
121
|
}
|
|
115
122
|
function hasManagedProjectHookEntry(repoRoot, runnerPath, params) {
|
|
116
123
|
const eventName = eventNameFor(params);
|
|
117
|
-
const
|
|
124
|
+
const matchers = new Set(expectedMatchers(eventName, params.payload));
|
|
118
125
|
let parsed;
|
|
119
126
|
try {
|
|
120
127
|
parsed = JSON.parse(readFileSync(cursorRoutingHooksSettingsPath(repoRoot), 'utf8'));
|
|
@@ -139,7 +146,7 @@ function hasManagedProjectHookEntry(repoRoot, runnerPath, params) {
|
|
|
139
146
|
}
|
|
140
147
|
const definition = entry;
|
|
141
148
|
return (definition.failClosed === true &&
|
|
142
|
-
definition.matcher
|
|
149
|
+
matchers.has(definition.matcher) &&
|
|
143
150
|
typeof definition.command === 'string' &&
|
|
144
151
|
commandInvokesProjectHook(definition.command, runnerPath, params.kind, eventName));
|
|
145
152
|
});
|
|
@@ -213,7 +220,7 @@ function readInstallScope(repoRoot) {
|
|
|
213
220
|
typeof parsed === 'object' &&
|
|
214
221
|
!Array.isArray(parsed) &&
|
|
215
222
|
parsed.installScope === 'global') {
|
|
216
|
-
return 'global';
|
|
223
|
+
return isTrustedCursorRoutingConfig(repoRoot, parsed) ? 'global' : 'project';
|
|
217
224
|
}
|
|
218
225
|
}
|
|
219
226
|
catch {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { HookEntry, HooksFile } from '../../types.js';
|
|
2
|
-
/**
|
|
2
|
+
/** Legacy managed Shell preToolUse gate matcher (upgrade/uninstall migration only). */
|
|
3
3
|
export declare function managedShellPreToolUseEntry(platform: NodeJS.Platform, hooksDir: string, repoRoot: string): HookEntry;
|
|
4
4
|
/** @deprecated Use {@link managedShellPreToolUseEntry}. */
|
|
5
5
|
export declare const legacyManagedShellPreToolUseEntry: typeof managedShellPreToolUseEntry;
|
|
@@ -16,13 +16,24 @@ function legacyManagedEntries(platform, hooksDir, repoRoot) {
|
|
|
16
16
|
entries.push(...getManagedHookEntries(platform, canonicalHooksDir, repoRoot, 'legacy-absolute'));
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
|
-
|
|
19
|
+
const legacyMatchedToolNames = ['Task', 'Write', 'StrReplace', 'Delete'];
|
|
20
|
+
return [
|
|
21
|
+
...entries,
|
|
22
|
+
...entries.flatMap(({ event, definition }) => event === 'preToolUse' && definition.matcher === undefined
|
|
23
|
+
? legacyMatchedToolNames.map((matcher) => ({
|
|
24
|
+
event,
|
|
25
|
+
definition: { ...definition, matcher },
|
|
26
|
+
}))
|
|
27
|
+
: []),
|
|
28
|
+
];
|
|
20
29
|
}
|
|
21
30
|
function variantsForDefinition(legacyEntries, event, definition) {
|
|
22
31
|
return [
|
|
23
32
|
definition,
|
|
24
33
|
...legacyEntries
|
|
25
|
-
.filter((entry) => entry.event === event &&
|
|
34
|
+
.filter((entry) => entry.event === event &&
|
|
35
|
+
(entry.definition.matcher === definition.matcher ||
|
|
36
|
+
(event === 'preToolUse' && definition.matcher === undefined)))
|
|
26
37
|
.map((entry) => entry.definition),
|
|
27
38
|
];
|
|
28
39
|
}
|
|
@@ -34,10 +45,10 @@ function mergeHookEntry(current, expected, managedVariants, placement) {
|
|
|
34
45
|
}
|
|
35
46
|
return [...filtered, expected];
|
|
36
47
|
}
|
|
37
|
-
/**
|
|
48
|
+
/** Legacy managed Shell preToolUse gate matcher (upgrade/uninstall migration only). */
|
|
38
49
|
export function managedShellPreToolUseEntry(platform, hooksDir, repoRoot) {
|
|
39
50
|
const managed = getManagedHookEntries(platform, hooksDir, repoRoot);
|
|
40
|
-
const referencePreToolUse = managed.find((entry) => entry.event === 'preToolUse' && entry.definition.matcher ===
|
|
51
|
+
const referencePreToolUse = managed.find((entry) => entry.event === 'preToolUse' && entry.definition.matcher === undefined)?.definition;
|
|
41
52
|
if (!referencePreToolUse) {
|
|
42
53
|
throw new Error('managed hook definitions missing for Shell preToolUse gate');
|
|
43
54
|
}
|
|
@@ -91,7 +102,7 @@ export function legacyManagedShellPreToolUseVariants(platform, hooksDir, repoRoo
|
|
|
91
102
|
const legacyEntries = legacyManagedEntries(platform, hooksDir, repoRoot);
|
|
92
103
|
const commands = new Set([shellEntry.command]);
|
|
93
104
|
for (const entry of legacyEntries) {
|
|
94
|
-
if (entry.event === 'preToolUse'
|
|
105
|
+
if (entry.event === 'preToolUse') {
|
|
95
106
|
commands.add(entry.definition.command);
|
|
96
107
|
}
|
|
97
108
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function isTrustedCursorRoutingConfig(repoRoot: string, rawConfig: unknown): boolean;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
function canonicalStringify(value) {
|
|
5
|
+
if (value === null || typeof value !== 'object') {
|
|
6
|
+
return JSON.stringify(value);
|
|
7
|
+
}
|
|
8
|
+
if (Array.isArray(value)) {
|
|
9
|
+
return `[${value.map((item) => canonicalStringify(item)).join(',')}]`;
|
|
10
|
+
}
|
|
11
|
+
const entries = Object.entries(value).sort(([left], [right]) => left.localeCompare(right));
|
|
12
|
+
return `{${entries.map(([key, child]) => `${JSON.stringify(key)}:${canonicalStringify(child)}`).join(',')}}`;
|
|
13
|
+
}
|
|
14
|
+
function hashValue(value) {
|
|
15
|
+
return createHash('sha256').update(value).digest('hex');
|
|
16
|
+
}
|
|
17
|
+
function defaultControlPlaneDir() {
|
|
18
|
+
if (process.platform === 'win32' && process.env.APPDATA?.trim()) {
|
|
19
|
+
return path.join(process.env.APPDATA.trim(), 'agent-belay');
|
|
20
|
+
}
|
|
21
|
+
const base = process.env.XDG_CONFIG_HOME?.trim() || path.join(process.env.HOME ?? '', '.config');
|
|
22
|
+
return path.join(base, 'agent-belay');
|
|
23
|
+
}
|
|
24
|
+
function canonicalRepoRoot(repoRoot) {
|
|
25
|
+
try {
|
|
26
|
+
return realpathSync(repoRoot);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return path.resolve(repoRoot);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function isTrustedCursorRoutingConfig(repoRoot, rawConfig) {
|
|
33
|
+
const canonicalRoot = canonicalRepoRoot(repoRoot);
|
|
34
|
+
const identity = hashValue(`${canonicalRoot}\u0000cursor`);
|
|
35
|
+
const recordPath = path.join(defaultControlPlaneDir(), 'config-trust', `${identity}.json`);
|
|
36
|
+
let parsed;
|
|
37
|
+
try {
|
|
38
|
+
parsed = JSON.parse(readFileSync(recordPath, 'utf8'));
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
const record = parsed;
|
|
47
|
+
const expectedKeys = new Set([
|
|
48
|
+
'schemaVersion',
|
|
49
|
+
'repoRoot',
|
|
50
|
+
'adapter',
|
|
51
|
+
'repoConfigFingerprint',
|
|
52
|
+
'trustedAt',
|
|
53
|
+
]);
|
|
54
|
+
if (Object.keys(record).length !== expectedKeys.size ||
|
|
55
|
+
!Object.keys(record).every((key) => expectedKeys.has(key)) ||
|
|
56
|
+
record.schemaVersion !== 1 ||
|
|
57
|
+
record.adapter !== 'cursor' ||
|
|
58
|
+
typeof record.repoRoot !== 'string' ||
|
|
59
|
+
canonicalRepoRoot(record.repoRoot) !== canonicalRoot ||
|
|
60
|
+
typeof record.repoConfigFingerprint !== 'string' ||
|
|
61
|
+
!/^[a-f0-9]{64}$/.test(record.repoConfigFingerprint) ||
|
|
62
|
+
typeof record.trustedAt !== 'string' ||
|
|
63
|
+
!Number.isFinite(Date.parse(record.trustedAt))) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
return record.repoConfigFingerprint === hashValue(canonicalStringify(rawConfig));
|
|
67
|
+
}
|
|
@@ -12,10 +12,10 @@ export declare function handleShellGateHook(payload: Record<string, unknown>): P
|
|
|
12
12
|
export declare function runShellGateHook(): Promise<void>;
|
|
13
13
|
export declare function handleToolGateHook(eventName: string, payload: Record<string, unknown>): Promise<import("../../core/gate-contract.js").GatePermissionResponse | {
|
|
14
14
|
permission: string;
|
|
15
|
-
user_message
|
|
15
|
+
user_message?: undefined;
|
|
16
16
|
} | {
|
|
17
17
|
permission: string;
|
|
18
|
-
user_message
|
|
18
|
+
user_message: string;
|
|
19
19
|
}>;
|
|
20
20
|
export declare function runToolGateHook(eventName: string): Promise<void>;
|
|
21
21
|
export declare function handleAuditHook(eventName: string, payload: Record<string, unknown>): Promise<{}>;
|
|
@@ -3,6 +3,7 @@ import process from 'node:process';
|
|
|
3
3
|
import { approvalCommandMatch } from '../../core/approval.js';
|
|
4
4
|
import { findApprovalRepoRoots, formatAmbiguousApprovalRepoMessage, } from '../../core/approval-repo-lookup.js';
|
|
5
5
|
import { DEFAULT_CONFIG_V4 } from '../../core/config.js';
|
|
6
|
+
import { isRepoConfigTrustError } from '../../core/repo-config-trust.js';
|
|
6
7
|
import { cursorLayout } from '../layouts/cursor.js';
|
|
7
8
|
import { appendObservedAudit, createDefaultGateRuntimeDeps, evaluateGatedAction, gateVerdictToCursorResponse, processApprovalPrompt, resolveGateConfig, } from '../shared/gate-runtime.js';
|
|
8
9
|
import { findRepoRoot } from '../shared/repo-root.js';
|
|
@@ -104,6 +105,9 @@ function isSubagentEvent(payload, eventName) {
|
|
|
104
105
|
function isFileMutationTool(toolName) {
|
|
105
106
|
return toolName === 'Write' || toolName === 'StrReplace' || toolName === 'Delete';
|
|
106
107
|
}
|
|
108
|
+
function isCursorPreToolUseEvent(eventName) {
|
|
109
|
+
return eventName === 'preToolUse' || eventName === 'PreToolUse';
|
|
110
|
+
}
|
|
107
111
|
export async function handleBeforeSubmitPromptHook(payload) {
|
|
108
112
|
try {
|
|
109
113
|
const prompt = String(payload.prompt ?? '');
|
|
@@ -123,7 +127,13 @@ export async function handleBeforeSubmitPromptHook(payload) {
|
|
|
123
127
|
...(result.replay ? { replay: result.replay } : {}),
|
|
124
128
|
};
|
|
125
129
|
}
|
|
126
|
-
catch {
|
|
130
|
+
catch (error) {
|
|
131
|
+
if (isRepoConfigTrustError(error)) {
|
|
132
|
+
return {
|
|
133
|
+
continue: false,
|
|
134
|
+
user_message: error.message,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
127
137
|
return {
|
|
128
138
|
continue: false,
|
|
129
139
|
user_message: 'belay failed while processing approval state. Run belay doctor, then retry.',
|
|
@@ -155,7 +165,13 @@ export async function handleShellGateHook(payload) {
|
|
|
155
165
|
});
|
|
156
166
|
return gateVerdictToCursorResponse(verdict);
|
|
157
167
|
}
|
|
158
|
-
catch {
|
|
168
|
+
catch (error) {
|
|
169
|
+
if (isRepoConfigTrustError(error)) {
|
|
170
|
+
return {
|
|
171
|
+
permission: 'deny',
|
|
172
|
+
user_message: error.message,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
159
175
|
return {
|
|
160
176
|
permission: 'deny',
|
|
161
177
|
user_message: 'belay failed while classifying this shell command. Run belay doctor, then retry.',
|
|
@@ -168,6 +184,9 @@ export async function runShellGateHook() {
|
|
|
168
184
|
export async function handleToolGateHook(eventName, payload) {
|
|
169
185
|
try {
|
|
170
186
|
const toolName = String(payload.tool_name ?? '');
|
|
187
|
+
if (isCursorPreToolUseEvent(eventName) && toolName === 'Shell') {
|
|
188
|
+
return { permission: 'allow' };
|
|
189
|
+
}
|
|
171
190
|
const resolution = resolveCursorToolActionCwdDetails(payload, process.cwd(), eventName, toolName);
|
|
172
191
|
if (isUnsafeGlobalHookFallback(resolution)) {
|
|
173
192
|
return {
|
|
@@ -187,9 +206,9 @@ export async function handleToolGateHook(eventName, payload) {
|
|
|
187
206
|
});
|
|
188
207
|
return gateVerdictToCursorResponse(verdict);
|
|
189
208
|
}
|
|
190
|
-
if (toolName
|
|
209
|
+
if (isFileMutationTool(toolName)) {
|
|
191
210
|
const verdict = await evaluateGatedAction(ctx, deps, {
|
|
192
|
-
kind: '
|
|
211
|
+
kind: 'tool',
|
|
193
212
|
cwd,
|
|
194
213
|
payload,
|
|
195
214
|
toolName,
|
|
@@ -197,28 +216,34 @@ export async function handleToolGateHook(eventName, payload) {
|
|
|
197
216
|
});
|
|
198
217
|
return gateVerdictToCursorResponse(verdict);
|
|
199
218
|
}
|
|
200
|
-
if (
|
|
219
|
+
if (payload.tool_name === 'Task') {
|
|
201
220
|
const verdict = await evaluateGatedAction(ctx, deps, {
|
|
202
|
-
kind: '
|
|
221
|
+
kind: 'subagent',
|
|
203
222
|
cwd,
|
|
204
223
|
payload,
|
|
205
|
-
toolName,
|
|
206
224
|
sourceEvent: eventName,
|
|
207
225
|
});
|
|
208
226
|
return gateVerdictToCursorResponse(verdict);
|
|
209
227
|
}
|
|
210
|
-
if (
|
|
228
|
+
if (isCursorPreToolUseEvent(eventName)) {
|
|
211
229
|
const verdict = await evaluateGatedAction(ctx, deps, {
|
|
212
|
-
kind: '
|
|
230
|
+
kind: 'tool',
|
|
213
231
|
cwd,
|
|
214
232
|
payload,
|
|
233
|
+
toolName,
|
|
215
234
|
sourceEvent: eventName,
|
|
216
235
|
});
|
|
217
236
|
return gateVerdictToCursorResponse(verdict);
|
|
218
237
|
}
|
|
219
238
|
return { permission: 'allow' };
|
|
220
239
|
}
|
|
221
|
-
catch {
|
|
240
|
+
catch (error) {
|
|
241
|
+
if (isRepoConfigTrustError(error)) {
|
|
242
|
+
return {
|
|
243
|
+
permission: 'deny',
|
|
244
|
+
user_message: error.message,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
222
247
|
return {
|
|
223
248
|
permission: 'deny',
|
|
224
249
|
user_message: 'belay failed while classifying this tool action. Run belay doctor, then retry.',
|
|
@@ -5,7 +5,6 @@ import path from 'node:path';
|
|
|
5
5
|
import { compactApprovals, createApprovalRecordWithEnvelope } from '../../core/approval.js';
|
|
6
6
|
import { buildReplayHint, buildRetryInstructionForConfig, canAutoReplay, getExecutionLeaseMs, validateReplayEnvelope, } from '../../core/approval-replay.js';
|
|
7
7
|
import { claimApprovedForReplay, gateApprovalStoreFromDeps, recordApproval, } from '../../core/approval-service.js';
|
|
8
|
-
import { issueApprovalToken } from '../../core/approval-token.js';
|
|
9
8
|
import { buildAuditActionSnapshot, buildAuditReplayContext, } from '../../core/audit-replay-context.js';
|
|
10
9
|
import { approvalCorrelationId, serializeAuditRecordV3, toolInvocationCorrelationId, } from '../../core/audit-serialize.js';
|
|
11
10
|
import { boundedUtf8Tail } from '../../core/bounded-output.js';
|
|
@@ -37,6 +36,7 @@ import { notifyDeny } from '../../core/notify.js';
|
|
|
37
36
|
import { canonicalPath } from '../../core/path-utils.js';
|
|
38
37
|
import { recoveryFailClosedResult, recoveryFailReasonFromSkip, } from '../../core/recovery/fail-closed.js';
|
|
39
38
|
import { fingerprintReplayPayload, redactToolInvocationId } from '../../core/replay-scrub.js';
|
|
39
|
+
import { assertRepoConfigTrusted } from '../../core/repo-config-trust.js';
|
|
40
40
|
import { FILE_CHECKPOINT_ISOLATION_UNAVAILABLE, isTransactionalEligible, runTransactionalExecution, TRANSACTIONAL_ALREADY_APPLIED, TRANSACTIONAL_APPROVAL_BYPASS_REASONS, } from '../../core/transactional/index.js';
|
|
41
41
|
import { buildAuditProvenanceFields } from '../../runtime-provenance.js';
|
|
42
42
|
import { egressStatus } from '../../services/egress-service.js';
|
|
@@ -91,7 +91,15 @@ async function loadJsonFile(filePath, fallback) {
|
|
|
91
91
|
export function createDefaultGateRuntimeDeps() {
|
|
92
92
|
return {
|
|
93
93
|
async readConfig(configPath) {
|
|
94
|
-
|
|
94
|
+
try {
|
|
95
|
+
return JSON.parse(await readFile(configPath, 'utf8'));
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
if (error.code === 'ENOENT') {
|
|
99
|
+
return {};
|
|
100
|
+
}
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
95
103
|
},
|
|
96
104
|
async appendAudit(ctx, event) {
|
|
97
105
|
const auditPath = path.join(ctx.repoRoot, ctx.config.audit.logPath);
|
|
@@ -149,6 +157,7 @@ export function createDefaultGateRuntimeDeps() {
|
|
|
149
157
|
}
|
|
150
158
|
export async function resolveGateConfig(ctx, deps) {
|
|
151
159
|
const loaded = await deps.readConfig(ctx.configPath);
|
|
160
|
+
await assertRepoConfigTrusted(ctx.repoRoot, ctx.layout.name, loaded);
|
|
152
161
|
let teamConfig = null;
|
|
153
162
|
const teamPath = teamConfigPath();
|
|
154
163
|
if (existsSync(teamPath)) {
|
|
@@ -675,7 +684,23 @@ export async function evaluateGatedAction(ctx, deps, params) {
|
|
|
675
684
|
...authorization,
|
|
676
685
|
egressProxyActive,
|
|
677
686
|
};
|
|
678
|
-
|
|
687
|
+
let predicted = await classifyGatedActionAsync(action, ctx.config, enrichedClassifierOptions);
|
|
688
|
+
if (action.kind === 'tool' &&
|
|
689
|
+
predicted.reason === 'unclassified_tool' &&
|
|
690
|
+
(ctx.config.policy.codexUnmappedTool ?? 'deny') === 'deny') {
|
|
691
|
+
predicted = {
|
|
692
|
+
...predicted,
|
|
693
|
+
verdict: 'deny_pending_approval',
|
|
694
|
+
reason: 'unmapped_tool',
|
|
695
|
+
assessment: {
|
|
696
|
+
reversibility: 'irreversible',
|
|
697
|
+
external: false,
|
|
698
|
+
blastRadius: 'unknown tool action',
|
|
699
|
+
confidence: 0.5,
|
|
700
|
+
signals: ['unmapped_tool'],
|
|
701
|
+
},
|
|
702
|
+
};
|
|
703
|
+
}
|
|
679
704
|
if (action.kind === 'shell' &&
|
|
680
705
|
action.command &&
|
|
681
706
|
isContainedUnknownExecutionEligible(ctx.config, action, predicted)) {
|
|
@@ -937,19 +962,6 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
|
|
|
937
962
|
if (created) {
|
|
938
963
|
await recordGateApprovalAsk(stateDir, result.reason, false);
|
|
939
964
|
}
|
|
940
|
-
let approvalToken;
|
|
941
|
-
try {
|
|
942
|
-
approvalToken = await issueApprovalToken({
|
|
943
|
-
approvalId: approval.approvalId,
|
|
944
|
-
fingerprint: approval.fingerprint,
|
|
945
|
-
repoRoot: approval.repoRoot,
|
|
946
|
-
issuedAt: approval.createdAt,
|
|
947
|
-
expiresAt: approval.expiresAt,
|
|
948
|
-
}, configuredControlPlaneDir(ctx.config));
|
|
949
|
-
}
|
|
950
|
-
catch {
|
|
951
|
-
approvalToken = undefined;
|
|
952
|
-
}
|
|
953
965
|
const denialReason = failure?.reason ?? result.reason;
|
|
954
966
|
if (ctx.config.notifications.webhookUrl || ctx.config.notifications.commandHook) {
|
|
955
967
|
await notifyDeny(ctx.config.notifications, {
|
|
@@ -958,7 +970,6 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
|
|
|
958
970
|
summary: result.normalizedCommand ?? result.summary ?? '',
|
|
959
971
|
repoRoot: ctx.repoRoot,
|
|
960
972
|
fingerprint: result.fingerprint,
|
|
961
|
-
approvalToken,
|
|
962
973
|
});
|
|
963
974
|
}
|
|
964
975
|
const failureAuditFields = typeof failure?.auditFields === 'function'
|