@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.
Files changed (83) hide show
  1. package/README.md +35 -1
  2. package/dist/adapters/codex/hooks.d.ts +1 -0
  3. package/dist/adapters/codex/hooks.js +3 -0
  4. package/dist/adapters/cursor/dispatcher-generation.d.ts +3 -0
  5. package/dist/adapters/cursor/dispatcher-generation.js +5 -0
  6. package/dist/adapters/cursor/hook-dispatch-entry.d.ts +7 -0
  7. package/dist/adapters/cursor/hook-dispatch-entry.js +126 -0
  8. package/dist/adapters/cursor/hook-router.d.ts +23 -0
  9. package/dist/adapters/cursor/hook-router.js +266 -0
  10. package/dist/adapters/cursor/hooks.d.ts +4 -0
  11. package/dist/adapters/cursor/hooks.js +99 -8
  12. package/dist/adapters/cursor/routing-layout.d.ts +5 -0
  13. package/dist/adapters/cursor/routing-layout.js +29 -0
  14. package/dist/adapters/cursor/runtime-entry.d.ts +17 -0
  15. package/dist/adapters/cursor/runtime-entry.js +39 -39
  16. package/dist/adapters/layouts/scope.d.ts +5 -0
  17. package/dist/adapters/layouts/scope.js +71 -0
  18. package/dist/adapters/shared/gate-runtime.js +1 -0
  19. package/dist/bundle/claude-runtime.mjs +1932 -1740
  20. package/dist/bundle/codex-runtime.mjs +1934 -1742
  21. package/dist/bundle/cursor-dispatcher.mjs +443 -0
  22. package/dist/bundle/cursor-runtime.mjs +2017 -1821
  23. package/dist/commands/doctor.js +191 -15
  24. package/dist/commands/health-snapshot.js +9 -4
  25. package/dist/commands/recovery-checkpoints.d.ts +3 -3
  26. package/dist/config-io.js +11 -2
  27. package/dist/core/audit-legacy-archive.js +43 -25
  28. package/dist/core/classify-tool.js +16 -8
  29. package/dist/core/effect-ir/argv-delegate.d.ts +9 -0
  30. package/dist/core/effect-ir/argv-delegate.js +42 -0
  31. package/dist/core/effect-ir/shell-lower/argv-delegate-gate.d.ts +3 -0
  32. package/dist/core/effect-ir/shell-lower/argv-delegate-gate.js +40 -0
  33. package/dist/core/effect-ir/shell-lower/augment.d.ts +4 -0
  34. package/dist/core/effect-ir/shell-lower/augment.js +97 -0
  35. package/dist/core/effect-ir/shell-lower/context.d.ts +10 -0
  36. package/dist/core/effect-ir/shell-lower/context.js +1 -0
  37. package/dist/core/effect-ir/shell-lower/decode-process.d.ts +9 -0
  38. package/dist/core/effect-ir/shell-lower/decode-process.js +201 -0
  39. package/dist/core/effect-ir/shell-lower/decoders/belay.d.ts +2 -0
  40. package/dist/core/effect-ir/shell-lower/decoders/belay.js +29 -0
  41. package/dist/core/effect-ir/shell-lower/decoders/builtins.d.ts +7 -0
  42. package/dist/core/effect-ir/shell-lower/decoders/builtins.js +194 -0
  43. package/dist/core/effect-ir/shell-lower/decoders/decode-package-exec.d.ts +4 -0
  44. package/dist/core/effect-ir/shell-lower/decoders/decode-package-exec.js +68 -0
  45. package/dist/core/effect-ir/shell-lower/decoders/docker.d.ts +3 -0
  46. package/dist/core/effect-ir/shell-lower/decoders/docker.js +43 -0
  47. package/dist/core/effect-ir/shell-lower/decoders/filesystem.d.ts +9 -0
  48. package/dist/core/effect-ir/shell-lower/decoders/filesystem.js +217 -0
  49. package/dist/core/effect-ir/shell-lower/decoders/prisma.d.ts +2 -0
  50. package/dist/core/effect-ir/shell-lower/decoders/prisma.js +54 -0
  51. package/dist/core/effect-ir/shell-lower/decoders/ruby.d.ts +11 -0
  52. package/dist/core/effect-ir/shell-lower/decoders/ruby.js +147 -0
  53. package/dist/core/effect-ir/shell-lower/decoders/toolchain.d.ts +7 -0
  54. package/dist/core/effect-ir/shell-lower/decoders/toolchain.js +155 -0
  55. package/dist/core/effect-ir/shell-lower/requirement.d.ts +12 -0
  56. package/dist/core/effect-ir/shell-lower/requirement.js +65 -0
  57. package/dist/core/effect-ir/shell-lower/segment.d.ts +11 -0
  58. package/dist/core/effect-ir/shell-lower/segment.js +82 -0
  59. package/dist/core/effect-ir/shell-lower/tokens.d.ts +26 -0
  60. package/dist/core/effect-ir/shell-lower/tokens.js +183 -0
  61. package/dist/core/effect-ir/shell-lower.d.ts +2 -7
  62. package/dist/core/effect-ir/shell-lower.js +60 -1502
  63. package/dist/core/glob.js +32 -16
  64. package/dist/core/integrity.d.ts +2 -2
  65. package/dist/core/integrity.js +52 -12
  66. package/dist/core/replay-scrub.d.ts +2 -0
  67. package/dist/core/replay-scrub.js +8 -0
  68. package/dist/core/shell-substitution.js +2 -2
  69. package/dist/core/shell-unparseable.js +3 -1
  70. package/dist/defaults.d.ts +7 -3
  71. package/dist/defaults.js +29 -22
  72. package/dist/installer/bootstrap.d.ts +1 -0
  73. package/dist/installer/bootstrap.js +2 -2
  74. package/dist/installer/runtime-artifacts.js +43 -13
  75. package/dist/installer.js +44 -9
  76. package/dist/node-resolution.d.ts +1 -0
  77. package/dist/node-resolution.js +61 -0
  78. package/dist/templates.d.ts +7 -4
  79. package/dist/templates.js +42 -4
  80. package/dist/types.d.ts +1 -0
  81. package/dist/version.d.ts +1 -1
  82. package/dist/version.js +1 -1
  83. 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,3 @@
1
+ export declare const CURSOR_DISPATCHER_GENERATION = "cursor-owner-router-v1";
2
+ export declare const CURSOR_DISPATCHER_GENERATION_HEADER: string;
3
+ export declare function hasCurrentCursorDispatcherGeneration(source: string): boolean;
@@ -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 mergeHookEntry(current, expected, placement) {
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, expected));
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
- next.hooks[event] = entries.filter((entry) => !entryMatches(entry, {
39
- command: definition.command,
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
- }, definition.placement);
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;