@guilz-dev/belay 0.9.3 → 0.9.4
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 +35 -1
- 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 +7 -0
- package/dist/adapters/cursor/hook-dispatch-entry.js +126 -0
- package/dist/adapters/cursor/hook-router.d.ts +23 -0
- package/dist/adapters/cursor/hook-router.js +266 -0
- package/dist/adapters/cursor/hooks.d.ts +4 -0
- package/dist/adapters/cursor/hooks.js +99 -8
- 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 +39 -39
- package/dist/adapters/layouts/scope.d.ts +5 -0
- package/dist/adapters/layouts/scope.js +71 -0
- package/dist/adapters/shared/gate-runtime.js +1 -0
- package/dist/bundle/claude-runtime.mjs +1932 -1740
- package/dist/bundle/codex-runtime.mjs +1934 -1742
- package/dist/bundle/cursor-dispatcher.mjs +443 -0
- package/dist/bundle/cursor-runtime.mjs +2017 -1821
- package/dist/commands/doctor.js +191 -15
- package/dist/commands/health-snapshot.js +9 -4
- package/dist/commands/recovery-checkpoints.d.ts +3 -3
- package/dist/config-io.js +11 -2
- package/dist/core/audit-legacy-archive.js +43 -25
- package/dist/core/classify-tool.js +16 -8
- 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 +29 -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/glob.js +32 -16
- package/dist/core/integrity.d.ts +2 -2
- package/dist/core/integrity.js +52 -12
- package/dist/core/replay-scrub.d.ts +2 -0
- package/dist/core/replay-scrub.js +8 -0
- package/dist/core/shell-substitution.js +2 -2
- package/dist/core/shell-unparseable.js +3 -1
- package/dist/defaults.d.ts +7 -3
- package/dist/defaults.js +29 -22
- 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.js +44 -9
- 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 +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -213,7 +213,41 @@ npx @guilz-dev/belay upgrade # refresh hooks/runtime, migrate con
|
|
|
213
213
|
**Install scope.** `--scope project` (default) writes artifacts under
|
|
214
214
|
`.cursor/` (or `.claude/`, `.codex/`). `--scope global` installs hooks, runtime,
|
|
215
215
|
and skill under `~/.cursor/`, so the gate is user-wide while `belay.config.json`,
|
|
216
|
-
approvals, and audit stay repo-local.
|
|
216
|
+
approvals, and audit stay repo-local. Cursor scope changes stage the complete target owner before
|
|
217
|
+
publishing `installScope`, then remove only exactly recognized artifacts from the previous owner.
|
|
218
|
+
The config file is replaced atomically so a concurrent hook does not observe a truncate-and-rewrite
|
|
219
|
+
window.
|
|
220
|
+
|
|
221
|
+
**Cursor source precedence.** Cursor may launch User/global hooks and hooks from multiple open
|
|
222
|
+
Projects for the same event. Belay chooses one effective source from the payload-derived action
|
|
223
|
+
repository: its matching Project install wins when `installScope` is `project`; User/global wins
|
|
224
|
+
when it is `global`. Other sources return a neutral Cursor response without loading the policy core
|
|
225
|
+
or writing audit/control-plane state. In a multi-root workspace, Shell
|
|
226
|
+
`tool_input.working_directory`, then `cwd`, then `workspace_roots` selects the action repository;
|
|
227
|
+
canonical paths prevent symlink aliases from becoming two owners; Project shims persist the
|
|
228
|
+
canonical repository identity at install time. An omitted `installScope` uses its documented
|
|
229
|
+
`project` default. A truly uninitialized repository is neutral to the global source, while a
|
|
230
|
+
present but malformed, unreadable, or invalid config remains selected by the matching Project
|
|
231
|
+
source and reaches Belay's fail-closed config path. A selected but incomplete Project owner fails
|
|
232
|
+
closed for gates and prompts (audit hooks remain safe and diagnostic).
|
|
233
|
+
|
|
234
|
+
Every managed Cursor hook entry is installed with `failClosed: true`. Cursor can therefore stop an
|
|
235
|
+
actionable prompt, shell, tool, or subagent operation when its runner, shim, or dispatcher cannot
|
|
236
|
+
start, crashes, times out, or returns invalid JSON. Post-action audit events cannot undo an action
|
|
237
|
+
that already completed, and Cursor documents `sessionEnd` as fire-and-forget with its response
|
|
238
|
+
unused; `failClosed` on those entries is defense-in-depth and diagnostics, not rollback. With
|
|
239
|
+
hash-pinned integrity enabled, both Project and global settings, runners, shims, core, and Cursor
|
|
240
|
+
dispatcher are pinned and checked by `belay doctor`. The dispatcher itself contains only payload
|
|
241
|
+
routing and filesystem layout logic; policy and audit modules load only for the selected owner.
|
|
242
|
+
|
|
243
|
+
Run `belay upgrade --scope global` for a pre-router global Cursor install, then run `belay doctor`.
|
|
244
|
+
A Project upgrade also refreshes an exactly recognized managed global install; doctor reports old
|
|
245
|
+
global generations, origin mismatches, incomplete owners, and managed entries that have not gained
|
|
246
|
+
`failClosed: true`, while a healthy global source shadowed by Project precedence is only a note.
|
|
247
|
+
This mechanism resolves competing sources for the same canonical event; it does not combine
|
|
248
|
+
distinct events such as `beforeShellExecution` and `preToolUse: Shell`, and it does not merge
|
|
249
|
+
repeated deliveries to the effective owner. See
|
|
250
|
+
[ADR-008](./docs/adr/ADR-008-cursor-hook-source-precedence.md).
|
|
217
251
|
|
|
218
252
|
**Skill-only.** The skill is just a UX layer (slash commands + guidance) and does
|
|
219
253
|
**not** enable gating on its own. Install from [skills.sh](https://skills.sh/guilz-dev/belay)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export declare const CODEX_HOOKS_BEGIN = "# --- BELAY MANAGED HOOKS BEGIN (managed by belay; do not edit) ---";
|
|
2
2
|
export declare const CODEX_HOOKS_END = "# --- BELAY MANAGED HOOKS END ---";
|
|
3
|
+
export declare function codexHooksTomlIncludesCommand(content: string, command: string): boolean;
|
|
3
4
|
/**
|
|
4
5
|
* Render belay's Codex lifecycle hooks as a marker-delimited TOML block for `.codex/config.toml`.
|
|
5
6
|
* The block is replaced wholesale on re-init/upgrade (see mergeCodexHooksToml), so we avoid a
|
|
@@ -12,6 +12,9 @@ const CODEX_HOOK_SPECS = [
|
|
|
12
12
|
function tomlString(value) {
|
|
13
13
|
return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
|
|
14
14
|
}
|
|
15
|
+
export function codexHooksTomlIncludesCommand(content, command) {
|
|
16
|
+
return content.includes(`command = ${tomlString(command)}`);
|
|
17
|
+
}
|
|
15
18
|
function runnerCommand(platform, hooksDir, repoRoot, hookName, ...args) {
|
|
16
19
|
return buildRunnerInvocation(platform, hooksDir, repoRoot, hookName, ...args);
|
|
17
20
|
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export const CURSOR_DISPATCHER_GENERATION = 'cursor-owner-router-v1';
|
|
2
|
+
export const CURSOR_DISPATCHER_GENERATION_HEADER = `export const BELAY_CURSOR_DISPATCHER_GENERATION = ${JSON.stringify(CURSOR_DISPATCHER_GENERATION)};\n`;
|
|
3
|
+
export function hasCurrentCursorDispatcherGeneration(source) {
|
|
4
|
+
return source.startsWith(CURSOR_DISPATCHER_GENERATION_HEADER);
|
|
5
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type CursorHookKind, type CursorHookOrigin } from './hook-router.js';
|
|
2
|
+
export interface DispatchCursorHookParams {
|
|
3
|
+
origin: CursorHookOrigin;
|
|
4
|
+
kind: CursorHookKind;
|
|
5
|
+
eventName: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function dispatchCursorHook(params: DispatchCursorHookParams): Promise<void>;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import process from 'node:process';
|
|
2
|
+
import { routeCursorHook } from './hook-router.js';
|
|
3
|
+
async function readStdinPayload() {
|
|
4
|
+
const chunks = [];
|
|
5
|
+
for await (const chunk of process.stdin) {
|
|
6
|
+
chunks.push(typeof chunk === 'string' ? chunk : chunk.toString('utf8'));
|
|
7
|
+
}
|
|
8
|
+
const raw = chunks.join('').trim();
|
|
9
|
+
if (!raw) {
|
|
10
|
+
return { ok: false };
|
|
11
|
+
}
|
|
12
|
+
try {
|
|
13
|
+
const parsed = JSON.parse(raw);
|
|
14
|
+
return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
15
|
+
? { ok: true, payload: parsed }
|
|
16
|
+
: { ok: false };
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return { ok: false };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function neutralResponse(kind) {
|
|
23
|
+
if (kind === 'before-submit') {
|
|
24
|
+
return { continue: true };
|
|
25
|
+
}
|
|
26
|
+
if (kind === 'audit') {
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
return { permission: 'allow' };
|
|
30
|
+
}
|
|
31
|
+
function failClosedResponse(kind, message) {
|
|
32
|
+
if (kind === 'audit') {
|
|
33
|
+
console.error(`belay audit hook skipped: ${message}`);
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
if (kind === 'before-submit') {
|
|
37
|
+
return { continue: false, user_message: message };
|
|
38
|
+
}
|
|
39
|
+
return { permission: 'deny', user_message: message };
|
|
40
|
+
}
|
|
41
|
+
function isRecord(value) {
|
|
42
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
43
|
+
}
|
|
44
|
+
function isNonEmptyString(value) {
|
|
45
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
46
|
+
}
|
|
47
|
+
function isRecognizedPayload(params, payload) {
|
|
48
|
+
if (params.kind === 'before-submit') {
|
|
49
|
+
return params.eventName === 'beforeSubmitPrompt' && typeof payload.prompt === 'string';
|
|
50
|
+
}
|
|
51
|
+
if (params.kind === 'shell-gate') {
|
|
52
|
+
return params.eventName === 'beforeShellExecution' && typeof payload.command === 'string';
|
|
53
|
+
}
|
|
54
|
+
if (params.kind === 'tool-gate') {
|
|
55
|
+
if (params.eventName === 'preToolUse' || params.eventName === 'PreToolUse') {
|
|
56
|
+
return isNonEmptyString(payload.tool_name) && isRecord(payload.tool_input);
|
|
57
|
+
}
|
|
58
|
+
return (params.eventName === 'subagentStart' &&
|
|
59
|
+
isNonEmptyString(payload.subagent_type) &&
|
|
60
|
+
typeof payload.task === 'string');
|
|
61
|
+
}
|
|
62
|
+
if (params.eventName === 'postToolUse') {
|
|
63
|
+
return (isNonEmptyString(payload.tool_name) &&
|
|
64
|
+
isRecord(payload.tool_input) &&
|
|
65
|
+
typeof payload.tool_output === 'string');
|
|
66
|
+
}
|
|
67
|
+
if (params.eventName === 'postToolUseFailure') {
|
|
68
|
+
return (isNonEmptyString(payload.tool_name) &&
|
|
69
|
+
isRecord(payload.tool_input) &&
|
|
70
|
+
typeof payload.error_message === 'string' &&
|
|
71
|
+
typeof payload.failure_type === 'string');
|
|
72
|
+
}
|
|
73
|
+
if (params.eventName === 'stop') {
|
|
74
|
+
return typeof payload.status === 'string' && typeof payload.loop_count === 'number';
|
|
75
|
+
}
|
|
76
|
+
if (params.eventName === 'sessionEnd') {
|
|
77
|
+
return isNonEmptyString(payload.session_id) && typeof payload.reason === 'string';
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
async function executeCoreHandler(params, payload) {
|
|
82
|
+
const coreModulePath = './core.mjs';
|
|
83
|
+
const core = (await import(coreModulePath));
|
|
84
|
+
if (params.kind === 'before-submit') {
|
|
85
|
+
return core.handleBeforeSubmitPromptHook(payload);
|
|
86
|
+
}
|
|
87
|
+
if (params.kind === 'shell-gate') {
|
|
88
|
+
return core.handleShellGateHook(payload);
|
|
89
|
+
}
|
|
90
|
+
if (params.kind === 'tool-gate') {
|
|
91
|
+
return core.handleToolGateHook(params.eventName, payload);
|
|
92
|
+
}
|
|
93
|
+
return core.handleAuditHook(params.eventName, payload);
|
|
94
|
+
}
|
|
95
|
+
async function dispatchCursorHookResponse(params) {
|
|
96
|
+
const input = await readStdinPayload();
|
|
97
|
+
if (!input.ok) {
|
|
98
|
+
return failClosedResponse(params.kind, 'belay received malformed Cursor hook input.');
|
|
99
|
+
}
|
|
100
|
+
if (!isRecognizedPayload(params, input.payload)) {
|
|
101
|
+
return neutralResponse(params.kind);
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const route = routeCursorHook({
|
|
105
|
+
origin: params.origin,
|
|
106
|
+
kind: params.kind,
|
|
107
|
+
eventName: params.eventName,
|
|
108
|
+
payload: input.payload,
|
|
109
|
+
});
|
|
110
|
+
if (route.decision === 'neutral') {
|
|
111
|
+
return neutralResponse(params.kind);
|
|
112
|
+
}
|
|
113
|
+
if (route.decision === 'fail_closed') {
|
|
114
|
+
return failClosedResponse(params.kind, route.message);
|
|
115
|
+
}
|
|
116
|
+
return await executeCoreHandler(params, input.payload);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
120
|
+
return failClosedResponse(params.kind, `belay failed while dispatching this Cursor hook: ${detail}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
export async function dispatchCursorHook(params) {
|
|
124
|
+
const response = await dispatchCursorHookResponse(params);
|
|
125
|
+
process.stdout.write(`${JSON.stringify(response)}\n`);
|
|
126
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type CursorHookOrigin = {
|
|
2
|
+
scope: 'project';
|
|
3
|
+
repoRoot: string;
|
|
4
|
+
} | {
|
|
5
|
+
scope: 'global';
|
|
6
|
+
};
|
|
7
|
+
export type CursorHookKind = 'before-submit' | 'shell-gate' | 'tool-gate' | 'audit';
|
|
8
|
+
export type CursorHookRoute = {
|
|
9
|
+
decision: 'execute';
|
|
10
|
+
repoRoot: string;
|
|
11
|
+
} | {
|
|
12
|
+
decision: 'neutral';
|
|
13
|
+
} | {
|
|
14
|
+
decision: 'fail_closed';
|
|
15
|
+
message: string;
|
|
16
|
+
};
|
|
17
|
+
export interface RouteCursorHookParams {
|
|
18
|
+
origin: CursorHookOrigin;
|
|
19
|
+
kind: CursorHookKind;
|
|
20
|
+
eventName?: string;
|
|
21
|
+
payload: Record<string, unknown>;
|
|
22
|
+
}
|
|
23
|
+
export declare function routeCursorHook(params: RouteCursorHookParams): CursorHookRoute;
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { accessSync, constants, existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { resolveCursorActionCwdDetails } from './cwd-resolution.js';
|
|
4
|
+
import { cursorRoutingConfigPath, cursorRoutingHooksDir, cursorRoutingHooksSettingsPath, cursorRoutingRuntimeDir, findCursorRoutingRepoRoot, } from './routing-layout.js';
|
|
5
|
+
function canonicalExistingPath(value) {
|
|
6
|
+
if (!existsSync(value)) {
|
|
7
|
+
return undefined;
|
|
8
|
+
}
|
|
9
|
+
try {
|
|
10
|
+
return realpathSync(value);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function hookScriptFor(kind) {
|
|
17
|
+
if (kind === 'before-submit') {
|
|
18
|
+
return 'belay-before-submit.mjs';
|
|
19
|
+
}
|
|
20
|
+
if (kind === 'shell-gate') {
|
|
21
|
+
return 'belay-shell-gate.mjs';
|
|
22
|
+
}
|
|
23
|
+
if (kind === 'tool-gate') {
|
|
24
|
+
return 'belay-tool-gate.mjs';
|
|
25
|
+
}
|
|
26
|
+
return 'belay-audit.mjs';
|
|
27
|
+
}
|
|
28
|
+
function hookCommandFor(kind) {
|
|
29
|
+
return hookScriptFor(kind).replace(/\.mjs$/, '');
|
|
30
|
+
}
|
|
31
|
+
function eventNameFor(params) {
|
|
32
|
+
if (params.eventName) {
|
|
33
|
+
return params.eventName;
|
|
34
|
+
}
|
|
35
|
+
if (params.kind === 'before-submit') {
|
|
36
|
+
return 'beforeSubmitPrompt';
|
|
37
|
+
}
|
|
38
|
+
if (params.kind === 'shell-gate') {
|
|
39
|
+
return 'beforeShellExecution';
|
|
40
|
+
}
|
|
41
|
+
if (params.kind === 'tool-gate') {
|
|
42
|
+
return typeof params.payload.subagent_type === 'string' ? 'subagentStart' : 'preToolUse';
|
|
43
|
+
}
|
|
44
|
+
if (typeof params.payload.error_message === 'string') {
|
|
45
|
+
return 'postToolUseFailure';
|
|
46
|
+
}
|
|
47
|
+
if (typeof params.payload.status === 'string') {
|
|
48
|
+
return 'stop';
|
|
49
|
+
}
|
|
50
|
+
if (typeof params.payload.session_id === 'string') {
|
|
51
|
+
return 'sessionEnd';
|
|
52
|
+
}
|
|
53
|
+
return 'postToolUse';
|
|
54
|
+
}
|
|
55
|
+
function selectedRunnerPath(hooksDir) {
|
|
56
|
+
const runnerPath = path.join(hooksDir, process.platform === 'win32' ? 'belay-runner.ps1' : 'belay-runner');
|
|
57
|
+
const canonicalPath = canonicalExistingPath(runnerPath);
|
|
58
|
+
if (!canonicalPath) {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
if (!statSync(canonicalPath).isFile()) {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
accessSync(canonicalPath, process.platform === 'win32' ? constants.R_OK : constants.X_OK);
|
|
66
|
+
return canonicalPath;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function isRegularFile(filePath) {
|
|
73
|
+
const canonicalPath = canonicalExistingPath(filePath);
|
|
74
|
+
if (!canonicalPath) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
return statSync(canonicalPath).isFile();
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function expectedHookArgs(kind, eventName) {
|
|
85
|
+
return kind === 'tool-gate' || kind === 'audit' ? [eventName] : [];
|
|
86
|
+
}
|
|
87
|
+
function commandInvokesProjectHook(command, runnerPath, kind, eventName) {
|
|
88
|
+
const hookCommand = hookCommandFor(kind);
|
|
89
|
+
const args = expectedHookArgs(kind, eventName);
|
|
90
|
+
if (process.platform !== 'win32') {
|
|
91
|
+
const expected = [`'${runnerPath.replaceAll("'", "'\\''")}'`, hookCommand, ...args].join(' ');
|
|
92
|
+
return command === expected;
|
|
93
|
+
}
|
|
94
|
+
const encoded = command.match(/^"[^"\r\n]+" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ([A-Za-z0-9+/=]+)$/)?.[1];
|
|
95
|
+
if (!encoded) {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
const quotePowerShellLiteral = (value) => `'${value.replaceAll("'", "''")}'`;
|
|
99
|
+
const expected = [
|
|
100
|
+
'&',
|
|
101
|
+
quotePowerShellLiteral(runnerPath),
|
|
102
|
+
...[hookCommand, ...args].map(quotePowerShellLiteral),
|
|
103
|
+
].join(' ');
|
|
104
|
+
return Buffer.from(encoded, 'base64').toString('utf16le') === expected;
|
|
105
|
+
}
|
|
106
|
+
function expectedMatcher(eventName, payload) {
|
|
107
|
+
if (eventName === 'preToolUse') {
|
|
108
|
+
return typeof payload.tool_name === 'string' ? payload.tool_name : undefined;
|
|
109
|
+
}
|
|
110
|
+
if (eventName === 'subagentStart') {
|
|
111
|
+
return typeof payload.subagent_type === 'string' ? payload.subagent_type : undefined;
|
|
112
|
+
}
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
function hasManagedProjectHookEntry(repoRoot, runnerPath, params) {
|
|
116
|
+
const eventName = eventNameFor(params);
|
|
117
|
+
const matcher = expectedMatcher(eventName, params.payload);
|
|
118
|
+
let parsed;
|
|
119
|
+
try {
|
|
120
|
+
parsed = JSON.parse(readFileSync(cursorRoutingHooksSettingsPath(repoRoot), 'utf8'));
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
const hooks = parsed.hooks;
|
|
129
|
+
if (hooks === null || typeof hooks !== 'object' || Array.isArray(hooks)) {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
const entries = hooks[eventName];
|
|
133
|
+
if (!Array.isArray(entries)) {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
return entries.some((entry) => {
|
|
137
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
const definition = entry;
|
|
141
|
+
return (definition.failClosed === true &&
|
|
142
|
+
definition.matcher === matcher &&
|
|
143
|
+
typeof definition.command === 'string' &&
|
|
144
|
+
commandInvokesProjectHook(definition.command, runnerPath, params.kind, eventName));
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
function hasMatchingProjectShim(repoRoot, kind) {
|
|
148
|
+
let source;
|
|
149
|
+
try {
|
|
150
|
+
source = readFileSync(path.join(cursorRoutingHooksDir(repoRoot), hookScriptFor(kind)), 'utf8');
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
return (source.includes("from '../belay/runtime/dispatcher.mjs'") &&
|
|
156
|
+
source.includes(`origin: ${JSON.stringify({ scope: 'project', repoRoot })}`));
|
|
157
|
+
}
|
|
158
|
+
function hasCallableProjectOwner(repoRoot, params) {
|
|
159
|
+
const hooksDir = cursorRoutingHooksDir(repoRoot);
|
|
160
|
+
const runnerPath = selectedRunnerPath(hooksDir);
|
|
161
|
+
return Boolean(runnerPath &&
|
|
162
|
+
isRegularFile(path.join(cursorRoutingRuntimeDir(repoRoot), 'dispatcher.mjs')) &&
|
|
163
|
+
hasMatchingProjectShim(repoRoot, params.kind) &&
|
|
164
|
+
hasManagedProjectHookEntry(repoRoot, runnerPath, params));
|
|
165
|
+
}
|
|
166
|
+
function payloadForKind(payload, kind) {
|
|
167
|
+
if (kind === 'before-submit' || kind === 'shell-gate') {
|
|
168
|
+
return payload;
|
|
169
|
+
}
|
|
170
|
+
if (kind === 'tool-gate' && payload.tool_name === 'Shell') {
|
|
171
|
+
return payload;
|
|
172
|
+
}
|
|
173
|
+
const toolInput = payload.tool_input;
|
|
174
|
+
if (toolInput === null || typeof toolInput !== 'object' || Array.isArray(toolInput)) {
|
|
175
|
+
return payload;
|
|
176
|
+
}
|
|
177
|
+
const { working_directory: _workingDirectory, ...withoutWorkingDirectory } = toolInput;
|
|
178
|
+
return { ...payload, tool_input: withoutWorkingDirectory };
|
|
179
|
+
}
|
|
180
|
+
function selectedActionPath(payload) {
|
|
181
|
+
const toolInput = payload.tool_input;
|
|
182
|
+
if (toolInput !== null && typeof toolInput === 'object' && !Array.isArray(toolInput)) {
|
|
183
|
+
const workingDirectory = toolInput.working_directory;
|
|
184
|
+
if (typeof workingDirectory === 'string' && workingDirectory.trim()) {
|
|
185
|
+
return workingDirectory.trim();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (typeof payload.cwd === 'string' && payload.cwd.trim()) {
|
|
189
|
+
return payload.cwd.trim();
|
|
190
|
+
}
|
|
191
|
+
if (!Array.isArray(payload.workspace_roots)) {
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
for (const root of payload.workspace_roots) {
|
|
195
|
+
if (typeof root === 'string' && root.trim()) {
|
|
196
|
+
return root.trim();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
function readInstallScope(repoRoot) {
|
|
202
|
+
const configPath = cursorRoutingConfigPath(repoRoot);
|
|
203
|
+
let source;
|
|
204
|
+
try {
|
|
205
|
+
source = readFileSync(configPath, 'utf8');
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
return error.code === 'ENOENT' ? 'missing' : 'project';
|
|
209
|
+
}
|
|
210
|
+
try {
|
|
211
|
+
const parsed = JSON.parse(source);
|
|
212
|
+
if (parsed !== null &&
|
|
213
|
+
typeof parsed === 'object' &&
|
|
214
|
+
!Array.isArray(parsed) &&
|
|
215
|
+
parsed.installScope === 'global') {
|
|
216
|
+
return 'global';
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
// A present config that cannot be read or parsed must retain Project ownership so the
|
|
221
|
+
// selected Project hook reaches its fail-closed core path. Only a missing config is neutral.
|
|
222
|
+
}
|
|
223
|
+
return 'project';
|
|
224
|
+
}
|
|
225
|
+
export function routeCursorHook(params) {
|
|
226
|
+
const relevantPayload = payloadForKind(params.payload, params.kind);
|
|
227
|
+
const selectedPath = selectedActionPath(relevantPayload);
|
|
228
|
+
if (!selectedPath || !path.isAbsolute(selectedPath)) {
|
|
229
|
+
return { decision: 'fail_closed', message: 'belay could not determine the workspace.' };
|
|
230
|
+
}
|
|
231
|
+
const resolution = resolveCursorActionCwdDetails(relevantPayload, '/');
|
|
232
|
+
const canonicalCwd = resolution.fromPayload ? canonicalExistingPath(resolution.cwd) : undefined;
|
|
233
|
+
if (!canonicalCwd) {
|
|
234
|
+
return { decision: 'fail_closed', message: 'belay could not determine the workspace.' };
|
|
235
|
+
}
|
|
236
|
+
const repoRoot = canonicalExistingPath(findCursorRoutingRepoRoot(canonicalCwd));
|
|
237
|
+
if (!repoRoot) {
|
|
238
|
+
return { decision: 'neutral' };
|
|
239
|
+
}
|
|
240
|
+
const installScope = readInstallScope(repoRoot);
|
|
241
|
+
if (installScope === 'missing') {
|
|
242
|
+
return { decision: 'neutral' };
|
|
243
|
+
}
|
|
244
|
+
if (installScope === 'global') {
|
|
245
|
+
return params.origin.scope === 'global'
|
|
246
|
+
? { decision: 'execute', repoRoot }
|
|
247
|
+
: { decision: 'neutral' };
|
|
248
|
+
}
|
|
249
|
+
if (params.origin.scope === 'project' &&
|
|
250
|
+
canonicalExistingPath(params.origin.repoRoot) !== repoRoot) {
|
|
251
|
+
return { decision: 'neutral' };
|
|
252
|
+
}
|
|
253
|
+
const callableProjectOwner = hasCallableProjectOwner(repoRoot, params);
|
|
254
|
+
if (params.origin.scope === 'global') {
|
|
255
|
+
return callableProjectOwner
|
|
256
|
+
? { decision: 'neutral' }
|
|
257
|
+
: {
|
|
258
|
+
decision: 'fail_closed',
|
|
259
|
+
message: 'belay project hook owner is unavailable; the global sentinel blocked this action.',
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
const complete = callableProjectOwner && isRegularFile(path.join(cursorRoutingRuntimeDir(repoRoot), 'core.mjs'));
|
|
263
|
+
return complete
|
|
264
|
+
? { decision: 'execute', repoRoot }
|
|
265
|
+
: { decision: 'fail_closed', message: 'belay project hook installation is incomplete.' };
|
|
266
|
+
}
|
|
@@ -5,4 +5,8 @@ export declare function managedShellPreToolUseEntry(platform: NodeJS.Platform, h
|
|
|
5
5
|
export declare const legacyManagedShellPreToolUseEntry: typeof managedShellPreToolUseEntry;
|
|
6
6
|
export declare function stripCursorHooksFile(current: HooksFile, platform: NodeJS.Platform, hooksDir: string, repoRoot: string): HooksFile;
|
|
7
7
|
export declare function mergeCursorHooksFile(current: HooksFile, platform: NodeJS.Platform, hooksDir: string, repoRoot: string): HooksFile;
|
|
8
|
+
export declare function legacyManagedShellPreToolUseVariants(platform: NodeJS.Platform, hooksDir: string, repoRoot: string): HookEntry[];
|
|
9
|
+
export declare function hasLegacyCursorDoubleShellGates(hooks: HooksFile, platform: NodeJS.Platform, hooksDir: string, repoRoot: string): boolean;
|
|
10
|
+
/** @deprecated Use {@link hasLegacyCursorDoubleShellGates}. */
|
|
8
11
|
export declare function hasDuplicateCursorShellGates(hooks: HooksFile, platform: NodeJS.Platform, hooksDir: string, repoRoot: string): boolean;
|
|
12
|
+
export declare function hasManagedCursorHookEntries(hooks: HooksFile, platform: NodeJS.Platform, hooksDir: string, repoRoot: string): boolean;
|
|
@@ -1,10 +1,34 @@
|
|
|
1
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
1
2
|
import { getManagedHookEntries } from '../../defaults.js';
|
|
2
3
|
function entryMatches(existing, expected) {
|
|
3
4
|
return existing.command === expected.command && existing.matcher === expected.matcher;
|
|
4
5
|
}
|
|
5
|
-
function
|
|
6
|
+
function legacyManagedEntries(platform, hooksDir, repoRoot) {
|
|
7
|
+
const entries = [
|
|
8
|
+
...getManagedHookEntries(platform, hooksDir, repoRoot, 'legacy-relative'),
|
|
9
|
+
...getManagedHookEntries(platform, hooksDir, repoRoot, 'legacy-absolute'),
|
|
10
|
+
...getManagedHookEntries(platform, hooksDir, repoRoot, 'legacy-quoted-absolute'),
|
|
11
|
+
...getManagedHookEntries(platform, hooksDir, repoRoot, 'legacy-bare-powershell-absolute'),
|
|
12
|
+
];
|
|
13
|
+
if (existsSync(hooksDir)) {
|
|
14
|
+
const canonicalHooksDir = realpathSync(hooksDir);
|
|
15
|
+
if (canonicalHooksDir !== hooksDir) {
|
|
16
|
+
entries.push(...getManagedHookEntries(platform, canonicalHooksDir, repoRoot, 'legacy-absolute'));
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return entries;
|
|
20
|
+
}
|
|
21
|
+
function variantsForDefinition(legacyEntries, event, definition) {
|
|
22
|
+
return [
|
|
23
|
+
definition,
|
|
24
|
+
...legacyEntries
|
|
25
|
+
.filter((entry) => entry.event === event && entry.definition.matcher === definition.matcher)
|
|
26
|
+
.map((entry) => entry.definition),
|
|
27
|
+
];
|
|
28
|
+
}
|
|
29
|
+
function mergeHookEntry(current, expected, managedVariants, placement) {
|
|
6
30
|
const entries = Array.isArray(current) ? [...current] : [];
|
|
7
|
-
const filtered = entries.filter((entry) => !entryMatches(entry,
|
|
31
|
+
const filtered = entries.filter((entry) => !managedVariants.some((variant) => entryMatches(entry, variant)));
|
|
8
32
|
if (placement === 'prepend') {
|
|
9
33
|
return [expected, ...filtered];
|
|
10
34
|
}
|
|
@@ -20,6 +44,7 @@ export function managedShellPreToolUseEntry(platform, hooksDir, repoRoot) {
|
|
|
20
44
|
return {
|
|
21
45
|
command: referencePreToolUse.command,
|
|
22
46
|
matcher: 'Shell',
|
|
47
|
+
failClosed: true,
|
|
23
48
|
};
|
|
24
49
|
}
|
|
25
50
|
/** @deprecated Use {@link managedShellPreToolUseEntry}. */
|
|
@@ -30,20 +55,19 @@ export function stripCursorHooksFile(current, platform, hooksDir, repoRoot) {
|
|
|
30
55
|
hooks: { ...current.hooks },
|
|
31
56
|
};
|
|
32
57
|
const managedEntries = getManagedHookEntries(platform, hooksDir, repoRoot);
|
|
58
|
+
const legacyEntries = legacyManagedEntries(platform, hooksDir, repoRoot);
|
|
33
59
|
for (const { event, definition } of managedEntries) {
|
|
34
60
|
const entries = next.hooks[event];
|
|
35
61
|
if (!Array.isArray(entries)) {
|
|
36
62
|
continue;
|
|
37
63
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
matcher: definition.matcher,
|
|
41
|
-
}));
|
|
64
|
+
const variants = variantsForDefinition(legacyEntries, event, definition);
|
|
65
|
+
next.hooks[event] = entries.filter((entry) => !variants.some((variant) => entryMatches(entry, variant)));
|
|
42
66
|
if (next.hooks[event].length === 0) {
|
|
43
67
|
delete next.hooks[event];
|
|
44
68
|
}
|
|
45
69
|
}
|
|
46
|
-
return next;
|
|
70
|
+
return stripLegacyManagedShellPreToolUseGates(next, platform, hooksDir, repoRoot);
|
|
47
71
|
}
|
|
48
72
|
export function mergeCursorHooksFile(current, platform, hooksDir, repoRoot) {
|
|
49
73
|
const next = {
|
|
@@ -51,14 +75,74 @@ export function mergeCursorHooksFile(current, platform, hooksDir, repoRoot) {
|
|
|
51
75
|
hooks: { ...current.hooks },
|
|
52
76
|
};
|
|
53
77
|
const managedEntries = getManagedHookEntries(platform, hooksDir, repoRoot);
|
|
78
|
+
const legacyEntries = legacyManagedEntries(platform, hooksDir, repoRoot);
|
|
54
79
|
for (const { event, definition } of managedEntries) {
|
|
80
|
+
const variants = variantsForDefinition(legacyEntries, event, definition);
|
|
55
81
|
next.hooks[event] = mergeHookEntry(next.hooks[event], {
|
|
56
82
|
command: definition.command,
|
|
57
83
|
matcher: definition.matcher,
|
|
58
|
-
|
|
84
|
+
failClosed: definition.failClosed,
|
|
85
|
+
}, variants, definition.placement);
|
|
86
|
+
}
|
|
87
|
+
return stripLegacyManagedShellPreToolUseGates(next, platform, hooksDir, repoRoot);
|
|
88
|
+
}
|
|
89
|
+
export function legacyManagedShellPreToolUseVariants(platform, hooksDir, repoRoot) {
|
|
90
|
+
const shellEntry = managedShellPreToolUseEntry(platform, hooksDir, repoRoot);
|
|
91
|
+
const legacyEntries = legacyManagedEntries(platform, hooksDir, repoRoot);
|
|
92
|
+
const commands = new Set([shellEntry.command]);
|
|
93
|
+
for (const entry of legacyEntries) {
|
|
94
|
+
if (entry.event === 'preToolUse' && entry.definition.matcher === 'Write') {
|
|
95
|
+
commands.add(entry.definition.command);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return [...commands].map((command) => ({
|
|
99
|
+
command,
|
|
100
|
+
matcher: 'Shell',
|
|
101
|
+
failClosed: true,
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
104
|
+
function stripLegacyManagedShellPreToolUseGates(hooks, platform, hooksDir, repoRoot) {
|
|
105
|
+
const variants = legacyManagedShellPreToolUseVariants(platform, hooksDir, repoRoot);
|
|
106
|
+
const preToolUse = hooks.hooks.preToolUse;
|
|
107
|
+
if (!Array.isArray(preToolUse) || variants.length === 0) {
|
|
108
|
+
return hooks;
|
|
109
|
+
}
|
|
110
|
+
const filtered = preToolUse.filter((entry) => !variants.some((variant) => entryMatches(entry, variant)));
|
|
111
|
+
if (filtered.length === preToolUse.length) {
|
|
112
|
+
return hooks;
|
|
113
|
+
}
|
|
114
|
+
const next = {
|
|
115
|
+
version: hooks.version || 1,
|
|
116
|
+
hooks: { ...hooks.hooks },
|
|
117
|
+
};
|
|
118
|
+
if (filtered.length === 0) {
|
|
119
|
+
delete next.hooks.preToolUse;
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
next.hooks.preToolUse = filtered;
|
|
59
123
|
}
|
|
60
124
|
return next;
|
|
61
125
|
}
|
|
126
|
+
export function hasLegacyCursorDoubleShellGates(hooks, platform, hooksDir, repoRoot) {
|
|
127
|
+
const shellVariants = legacyManagedShellPreToolUseVariants(platform, hooksDir, repoRoot);
|
|
128
|
+
const preToolUse = hooks.hooks.preToolUse;
|
|
129
|
+
const beforeShell = hooks.hooks.beforeShellExecution;
|
|
130
|
+
if (!Array.isArray(preToolUse) || !Array.isArray(beforeShell)) {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
const hasLegacyShellPreToolUse = preToolUse.some((entry) => shellVariants.some((variant) => entryMatches(entry, variant)));
|
|
134
|
+
if (!hasLegacyShellPreToolUse) {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
const managedEntries = getManagedHookEntries(platform, hooksDir, repoRoot);
|
|
138
|
+
const shellManaged = managedEntries.find((entry) => entry.event === 'beforeShellExecution');
|
|
139
|
+
if (!shellManaged) {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
const shellVariantsForBefore = variantsForDefinition(legacyManagedEntries(platform, hooksDir, repoRoot), 'beforeShellExecution', shellManaged.definition);
|
|
143
|
+
return beforeShell.some((entry) => shellVariantsForBefore.some((variant) => entryMatches(entry, variant)));
|
|
144
|
+
}
|
|
145
|
+
/** @deprecated Use {@link hasLegacyCursorDoubleShellGates}. */
|
|
62
146
|
export function hasDuplicateCursorShellGates(hooks, platform, hooksDir, repoRoot) {
|
|
63
147
|
const shellEntry = managedShellPreToolUseEntry(platform, hooksDir, repoRoot);
|
|
64
148
|
const preToolUse = hooks.hooks.preToolUse;
|
|
@@ -68,3 +152,10 @@ export function hasDuplicateCursorShellGates(hooks, platform, hooksDir, repoRoot
|
|
|
68
152
|
const shellMatches = preToolUse.filter((entry) => entryMatches(entry, shellEntry));
|
|
69
153
|
return shellMatches.length > 1;
|
|
70
154
|
}
|
|
155
|
+
export function hasManagedCursorHookEntries(hooks, platform, hooksDir, repoRoot) {
|
|
156
|
+
const managedEntries = [
|
|
157
|
+
...getManagedHookEntries(platform, hooksDir, repoRoot),
|
|
158
|
+
...legacyManagedEntries(platform, hooksDir, repoRoot),
|
|
159
|
+
];
|
|
160
|
+
return managedEntries.some(({ event, definition }) => hooks.hooks[event]?.some((entry) => entryMatches(entry, definition)));
|
|
161
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare function cursorRoutingConfigPath(repoRoot: string): string;
|
|
2
|
+
export declare function cursorRoutingHooksDir(repoRoot: string): string;
|
|
3
|
+
export declare function cursorRoutingHooksSettingsPath(repoRoot: string): string;
|
|
4
|
+
export declare function cursorRoutingRuntimeDir(repoRoot: string): string;
|
|
5
|
+
export declare function findCursorRoutingRepoRoot(startPath: string): string;
|