@guilz-dev/belay 0.9.4 → 0.10.1
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/claude/runtime-entry.js +5 -18
- package/dist/adapters/codex/runtime-entry.js +10 -11
- 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.d.ts +1 -0
- package/dist/adapters/cursor/hook-router.js +39 -9
- package/dist/adapters/cursor/hook-routing-health.d.ts +1 -0
- package/dist/adapters/cursor/hook-routing-health.js +53 -0
- 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.d.ts +0 -2
- package/dist/adapters/shared/gate-runtime.js +12 -52
- package/dist/bundle/claude-runtime.mjs +1083 -669
- package/dist/bundle/codex-runtime.mjs +1092 -698
- package/dist/bundle/cursor-dispatcher.mjs +119 -28
- package/dist/bundle/cursor-runtime.mjs +1132 -677
- 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 +14 -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/capability/index.d.ts +1 -1
- package/dist/core/capability/index.js +1 -1
- package/dist/core/capability/policy-engine.d.ts +19 -0
- package/dist/core/capability/policy-engine.js +31 -0
- package/dist/core/classify-tool.js +372 -148
- package/dist/core/config.js +2 -2
- 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/gate-engine.js +2 -9
- 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
|
|
@@ -209,25 +209,12 @@ export async function runToolGateHook(_eventName) {
|
|
|
209
209
|
return;
|
|
210
210
|
}
|
|
211
211
|
if (!mappedKind) {
|
|
212
|
-
const verdict =
|
|
213
|
-
toolName,
|
|
214
|
-
payload,
|
|
215
|
-
repoRoot: ctx.repoRoot,
|
|
216
|
-
reason: 'unmapped_tool',
|
|
217
|
-
mode: ctx.config.mode,
|
|
218
|
-
userMessage: 'belay does not recognize this tool action. Run belay doctor, then retry.',
|
|
219
|
-
agentMessage: 'Belay denied this action because the tool could not be normalized.',
|
|
220
|
-
});
|
|
221
|
-
await deps.appendAudit(ctx, {
|
|
222
|
-
event: 'preToolUse',
|
|
212
|
+
const verdict = await evaluateGatedAction(ctx, deps, {
|
|
223
213
|
kind: 'tool',
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
permission: 'deny',
|
|
229
|
-
summary: toolName,
|
|
230
|
-
...effectPlanAuditFields(verdict.effectPlan),
|
|
214
|
+
cwd,
|
|
215
|
+
payload,
|
|
216
|
+
toolName,
|
|
217
|
+
sourceEvent: 'PreToolUse',
|
|
231
218
|
});
|
|
232
219
|
jsonResponse(gateVerdictToClaudePreToolUseResponse(verdict));
|
|
233
220
|
return;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import process from 'node:process';
|
|
3
3
|
import { codexLayout } from '../layouts/codex.js';
|
|
4
|
-
import { appendObservedAudit, createDefaultGateRuntimeDeps, evaluateGatedAction,
|
|
4
|
+
import { appendObservedAudit, createDefaultGateRuntimeDeps, evaluateGatedAction, gateVerdictToCodexPreToolUseResponse, gateVerdictToCodexUserPromptResponse, processApprovalPrompt, resolveGateConfig, } from '../shared/gate-runtime.js';
|
|
5
5
|
import { findRepoRoot } from '../shared/repo-root.js';
|
|
6
6
|
async function readStdinJson() {
|
|
7
7
|
const chunks = [];
|
|
@@ -169,7 +169,7 @@ export async function runBeforeSubmitPromptHook() {
|
|
|
169
169
|
}
|
|
170
170
|
// Codex routes all PreToolUse through this unified handler (matcher ".*"), since the exact
|
|
171
171
|
// shell tool name is not yet confirmed. SubagentStart is also routed to this handler.
|
|
172
|
-
// Unmapped tools
|
|
172
|
+
// Unmapped tools use the shared effect-based classifier (no tool-name denylist).
|
|
173
173
|
export async function runToolGateHook(eventName) {
|
|
174
174
|
try {
|
|
175
175
|
const payload = await readStdinJson();
|
|
@@ -180,15 +180,14 @@ export async function runToolGateHook(eventName) {
|
|
|
180
180
|
const ctx = await loadRuntimeContext(cwd);
|
|
181
181
|
const deps = createDefaultGateRuntimeDeps();
|
|
182
182
|
if (!kind) {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
}
|
|
191
|
-
const verdict = await gateUnmappedToolVerdict(ctx, deps, toolName, payload);
|
|
183
|
+
const normalizedPayload = normalizeCodexToolPayload('tool', payload);
|
|
184
|
+
const verdict = await evaluateGatedAction(ctx, deps, {
|
|
185
|
+
kind: 'tool',
|
|
186
|
+
cwd,
|
|
187
|
+
payload: normalizedPayload,
|
|
188
|
+
toolName,
|
|
189
|
+
sourceEvent: eventName,
|
|
190
|
+
});
|
|
192
191
|
jsonResponse(gateVerdictToCodexPreToolUseResponse(verdict));
|
|
193
192
|
return;
|
|
194
193
|
}
|
|
@@ -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 };
|
|
@@ -14,6 +14,7 @@ export type CursorHookRoute = {
|
|
|
14
14
|
decision: 'fail_closed';
|
|
15
15
|
message: string;
|
|
16
16
|
};
|
|
17
|
+
export declare const CURSOR_GLOBAL_SENTINEL_BLOCK_MESSAGE = "belay project hook owner is unavailable; the global sentinel blocked this action.";
|
|
17
18
|
export interface RouteCursorHookParams {
|
|
18
19
|
origin: CursorHookOrigin;
|
|
19
20
|
kind: CursorHookKind;
|
|
@@ -1,7 +1,9 @@
|
|
|
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';
|
|
6
|
+
export const CURSOR_GLOBAL_SENTINEL_BLOCK_MESSAGE = 'belay project hook owner is unavailable; the global sentinel blocked this action.';
|
|
5
7
|
function canonicalExistingPath(value) {
|
|
6
8
|
if (!existsSync(value)) {
|
|
7
9
|
return undefined;
|
|
@@ -103,18 +105,24 @@ function commandInvokesProjectHook(command, runnerPath, kind, eventName) {
|
|
|
103
105
|
].join(' ');
|
|
104
106
|
return Buffer.from(encoded, 'base64').toString('utf16le') === expected;
|
|
105
107
|
}
|
|
106
|
-
function
|
|
108
|
+
function expectedMatchers(eventName, payload) {
|
|
107
109
|
if (eventName === 'preToolUse') {
|
|
108
|
-
|
|
110
|
+
// Managed preToolUse is intentionally unfiltered. Keep the legacy matcher variant so older
|
|
111
|
+
// installed hooks still route through the same owner until upgrade.
|
|
112
|
+
const values = [undefined];
|
|
113
|
+
if (typeof payload.tool_name === 'string') {
|
|
114
|
+
values.push(payload.tool_name);
|
|
115
|
+
}
|
|
116
|
+
return values;
|
|
109
117
|
}
|
|
110
118
|
if (eventName === 'subagentStart') {
|
|
111
|
-
return typeof payload.subagent_type === 'string' ? payload.subagent_type : undefined;
|
|
119
|
+
return [typeof payload.subagent_type === 'string' ? payload.subagent_type : undefined];
|
|
112
120
|
}
|
|
113
|
-
return undefined;
|
|
121
|
+
return [undefined];
|
|
114
122
|
}
|
|
115
123
|
function hasManagedProjectHookEntry(repoRoot, runnerPath, params) {
|
|
116
124
|
const eventName = eventNameFor(params);
|
|
117
|
-
const
|
|
125
|
+
const matchers = new Set(expectedMatchers(eventName, params.payload));
|
|
118
126
|
let parsed;
|
|
119
127
|
try {
|
|
120
128
|
parsed = JSON.parse(readFileSync(cursorRoutingHooksSettingsPath(repoRoot), 'utf8'));
|
|
@@ -139,7 +147,7 @@ function hasManagedProjectHookEntry(repoRoot, runnerPath, params) {
|
|
|
139
147
|
}
|
|
140
148
|
const definition = entry;
|
|
141
149
|
return (definition.failClosed === true &&
|
|
142
|
-
definition.matcher
|
|
150
|
+
matchers.has(definition.matcher) &&
|
|
143
151
|
typeof definition.command === 'string' &&
|
|
144
152
|
commandInvokesProjectHook(definition.command, runnerPath, params.kind, eventName));
|
|
145
153
|
});
|
|
@@ -155,6 +163,11 @@ function hasMatchingProjectShim(repoRoot, kind) {
|
|
|
155
163
|
return (source.includes("from '../belay/runtime/dispatcher.mjs'") &&
|
|
156
164
|
source.includes(`origin: ${JSON.stringify({ scope: 'project', repoRoot })}`));
|
|
157
165
|
}
|
|
166
|
+
function hasProjectOwnerInstallation(repoRoot) {
|
|
167
|
+
const hooksDir = cursorRoutingHooksDir(repoRoot);
|
|
168
|
+
return Boolean(selectedRunnerPath(hooksDir) &&
|
|
169
|
+
isRegularFile(path.join(cursorRoutingRuntimeDir(repoRoot), 'dispatcher.mjs')));
|
|
170
|
+
}
|
|
158
171
|
function hasCallableProjectOwner(repoRoot, params) {
|
|
159
172
|
const hooksDir = cursorRoutingHooksDir(repoRoot);
|
|
160
173
|
const runnerPath = selectedRunnerPath(hooksDir);
|
|
@@ -198,6 +211,10 @@ function selectedActionPath(payload) {
|
|
|
198
211
|
}
|
|
199
212
|
return undefined;
|
|
200
213
|
}
|
|
214
|
+
function hasRoutingRootMarker(dir) {
|
|
215
|
+
return (existsSync(path.join(dir, '.git')) ||
|
|
216
|
+
(existsSync(path.join(dir, '.cursor')) && existsSync(cursorRoutingConfigPath(dir))));
|
|
217
|
+
}
|
|
201
218
|
function readInstallScope(repoRoot) {
|
|
202
219
|
const configPath = cursorRoutingConfigPath(repoRoot);
|
|
203
220
|
let source;
|
|
@@ -213,7 +230,13 @@ function readInstallScope(repoRoot) {
|
|
|
213
230
|
typeof parsed === 'object' &&
|
|
214
231
|
!Array.isArray(parsed) &&
|
|
215
232
|
parsed.installScope === 'global') {
|
|
216
|
-
|
|
233
|
+
if (isTrustedCursorRoutingConfig(repoRoot, parsed)) {
|
|
234
|
+
return 'global';
|
|
235
|
+
}
|
|
236
|
+
// Untrusted global scope still routes through the global owner when no project
|
|
237
|
+
// installation exists. Otherwise a global-only workspace is blocked by the sentinel
|
|
238
|
+
// even though ADR-008 assigns ownership to the User/global installation.
|
|
239
|
+
return hasProjectOwnerInstallation(repoRoot) ? 'project' : 'global';
|
|
217
240
|
}
|
|
218
241
|
}
|
|
219
242
|
catch {
|
|
@@ -233,10 +256,17 @@ export function routeCursorHook(params) {
|
|
|
233
256
|
if (!canonicalCwd) {
|
|
234
257
|
return { decision: 'fail_closed', message: 'belay could not determine the workspace.' };
|
|
235
258
|
}
|
|
236
|
-
const
|
|
259
|
+
const discoveredRepoRoot = findCursorRoutingRepoRoot(canonicalCwd);
|
|
260
|
+
const repoRoot = canonicalExistingPath(discoveredRepoRoot);
|
|
237
261
|
if (!repoRoot) {
|
|
238
262
|
return { decision: 'neutral' };
|
|
239
263
|
}
|
|
264
|
+
// If repository discovery fell back to the action directory without finding a marker,
|
|
265
|
+
// keep the global source neutral instead of treating an unreadable/non-repo directory
|
|
266
|
+
// as a broken project owner.
|
|
267
|
+
if (repoRoot === canonicalCwd && !hasRoutingRootMarker(repoRoot)) {
|
|
268
|
+
return { decision: 'neutral' };
|
|
269
|
+
}
|
|
240
270
|
const installScope = readInstallScope(repoRoot);
|
|
241
271
|
if (installScope === 'missing') {
|
|
242
272
|
return { decision: 'neutral' };
|
|
@@ -256,7 +286,7 @@ export function routeCursorHook(params) {
|
|
|
256
286
|
? { decision: 'neutral' }
|
|
257
287
|
: {
|
|
258
288
|
decision: 'fail_closed',
|
|
259
|
-
message:
|
|
289
|
+
message: `${CURSOR_GLOBAL_SENTINEL_BLOCK_MESSAGE} Run belay doctor from this repository to diagnose hook routing.`,
|
|
260
290
|
};
|
|
261
291
|
}
|
|
262
292
|
const complete = callableProjectOwner && isRegularFile(path.join(cursorRoutingRuntimeDir(repoRoot), 'core.mjs'));
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function cursorHookRoutingIssues(repoRoot: string): string[];
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { CURSOR_GLOBAL_SENTINEL_BLOCK_MESSAGE, routeCursorHook, } from './hook-router.js';
|
|
3
|
+
import { cursorRoutingConfigPath } from './routing-layout.js';
|
|
4
|
+
const SENTINEL_KINDS = ['before-submit', 'shell-gate'];
|
|
5
|
+
function canonicalRepoRoot(repoRoot) {
|
|
6
|
+
try {
|
|
7
|
+
return realpathSync(repoRoot);
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return repoRoot;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function configuredInstallScope(repoRoot) {
|
|
14
|
+
let parsed;
|
|
15
|
+
try {
|
|
16
|
+
parsed = JSON.parse(readFileSync(cursorRoutingConfigPath(repoRoot), 'utf8'));
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
return parsed.installScope === 'global' ? 'global' : 'project';
|
|
25
|
+
}
|
|
26
|
+
function upgradeCommand(repoRoot, installScope) {
|
|
27
|
+
return `belay upgrade --scope ${installScope} --target ${JSON.stringify(repoRoot)}`;
|
|
28
|
+
}
|
|
29
|
+
export function cursorHookRoutingIssues(repoRoot) {
|
|
30
|
+
if (!existsSync(cursorRoutingConfigPath(repoRoot))) {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
const canonicalRoot = canonicalRepoRoot(repoRoot);
|
|
34
|
+
const payload = { cwd: canonicalRoot, workspace_roots: [canonicalRoot] };
|
|
35
|
+
const installScope = configuredInstallScope(repoRoot) ?? 'project';
|
|
36
|
+
const issues = [];
|
|
37
|
+
for (const kind of SENTINEL_KINDS) {
|
|
38
|
+
const route = routeCursorHook({
|
|
39
|
+
origin: { scope: 'global' },
|
|
40
|
+
kind,
|
|
41
|
+
payload,
|
|
42
|
+
});
|
|
43
|
+
if (route.decision !== 'fail_closed') {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (route.message.includes(CURSOR_GLOBAL_SENTINEL_BLOCK_MESSAGE)) {
|
|
47
|
+
issues.push(`Global Cursor sentinel would block ${kind} in this workspace. Run ${upgradeCommand(repoRoot, installScope)}; if the config was edited manually, also run belay config trust --target ${JSON.stringify(repoRoot)}.`);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
issues.push(`Global Cursor sentinel would block ${kind}: ${route.message}`);
|
|
51
|
+
}
|
|
52
|
+
return issues;
|
|
53
|
+
}
|
|
@@ -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.',
|
|
@@ -48,8 +48,6 @@ export declare function evaluateGatedAction(ctx: GateRuntimeContext, deps: GateR
|
|
|
48
48
|
toolName?: string;
|
|
49
49
|
sourceEvent?: string;
|
|
50
50
|
}): Promise<GateVerdict>;
|
|
51
|
-
/** R39: unmapped Codex tools ask via pending approval — not hard deny without approval path. */
|
|
52
|
-
export declare function gateUnmappedToolVerdict(ctx: GateRuntimeContext, deps: GateRuntimeDeps, toolName: string, payload: Record<string, unknown>): Promise<GateVerdict>;
|
|
53
51
|
export declare function processApprovalPrompt(ctx: GateRuntimeContext, deps: GateRuntimeDeps, prompt: string): Promise<ApprovalPromptResult>;
|
|
54
52
|
export declare function gateVerdictToCursorResponse(verdict: GateVerdict): GatePermissionResponse;
|
|
55
53
|
export declare function gateVerdictToClaudePreToolUseResponse(verdict: GateVerdict): Record<string, unknown>;
|