@guilz-dev/belay 0.9.3 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/README.md +38 -3
  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 +10 -0
  7. package/dist/adapters/cursor/hook-dispatch-entry.js +137 -0
  8. package/dist/adapters/cursor/hook-router.d.ts +23 -0
  9. package/dist/adapters/cursor/hook-router.js +273 -0
  10. package/dist/adapters/cursor/hooks.d.ts +5 -1
  11. package/dist/adapters/cursor/hooks.js +112 -10
  12. package/dist/adapters/cursor/routing-config-trust.d.ts +1 -0
  13. package/dist/adapters/cursor/routing-config-trust.js +67 -0
  14. package/dist/adapters/cursor/routing-layout.d.ts +5 -0
  15. package/dist/adapters/cursor/routing-layout.js +29 -0
  16. package/dist/adapters/cursor/runtime-entry.d.ts +17 -0
  17. package/dist/adapters/cursor/runtime-entry.js +74 -49
  18. package/dist/adapters/layouts/scope.d.ts +5 -0
  19. package/dist/adapters/layouts/scope.js +71 -0
  20. package/dist/adapters/shared/gate-runtime.js +29 -17
  21. package/dist/bundle/claude-runtime.mjs +2428 -2072
  22. package/dist/bundle/codex-runtime.mjs +2440 -2084
  23. package/dist/bundle/cursor-dispatcher.mjs +517 -0
  24. package/dist/bundle/cursor-runtime.mjs +2521 -2133
  25. package/dist/cli.js +53 -4
  26. package/dist/commands/approval-token.d.ts +10 -0
  27. package/dist/commands/approval-token.js +26 -0
  28. package/dist/commands/audit.d.ts +2 -1
  29. package/dist/commands/audit.js +2 -2
  30. package/dist/commands/config.d.ts +1 -1
  31. package/dist/commands/config.js +28 -2
  32. package/dist/commands/doctor.js +149 -17
  33. package/dist/commands/dogfood-check.d.ts +3 -0
  34. package/dist/commands/dogfood-check.js +116 -0
  35. package/dist/commands/dogfood.d.ts +1 -0
  36. package/dist/commands/dogfood.js +4 -3
  37. package/dist/commands/health-snapshot.js +9 -4
  38. package/dist/commands/judge.js +2 -2
  39. package/dist/commands/recovery-checkpoints.d.ts +3 -3
  40. package/dist/commands/recovery-checkpoints.js +2 -11
  41. package/dist/config-io.d.ts +1 -0
  42. package/dist/config-io.js +19 -3
  43. package/dist/core/audit-legacy-archive.js +43 -25
  44. package/dist/core/classify-tool.js +16 -8
  45. package/dist/core/dogfood-environment.d.ts +8 -0
  46. package/dist/core/dogfood-environment.js +55 -0
  47. package/dist/core/effect-ir/argv-delegate.d.ts +9 -0
  48. package/dist/core/effect-ir/argv-delegate.js +42 -0
  49. package/dist/core/effect-ir/shell-lower/argv-delegate-gate.d.ts +3 -0
  50. package/dist/core/effect-ir/shell-lower/argv-delegate-gate.js +40 -0
  51. package/dist/core/effect-ir/shell-lower/augment.d.ts +4 -0
  52. package/dist/core/effect-ir/shell-lower/augment.js +97 -0
  53. package/dist/core/effect-ir/shell-lower/context.d.ts +10 -0
  54. package/dist/core/effect-ir/shell-lower/context.js +1 -0
  55. package/dist/core/effect-ir/shell-lower/decode-process.d.ts +9 -0
  56. package/dist/core/effect-ir/shell-lower/decode-process.js +201 -0
  57. package/dist/core/effect-ir/shell-lower/decoders/belay.d.ts +2 -0
  58. package/dist/core/effect-ir/shell-lower/decoders/belay.js +41 -0
  59. package/dist/core/effect-ir/shell-lower/decoders/builtins.d.ts +7 -0
  60. package/dist/core/effect-ir/shell-lower/decoders/builtins.js +194 -0
  61. package/dist/core/effect-ir/shell-lower/decoders/decode-package-exec.d.ts +4 -0
  62. package/dist/core/effect-ir/shell-lower/decoders/decode-package-exec.js +68 -0
  63. package/dist/core/effect-ir/shell-lower/decoders/docker.d.ts +3 -0
  64. package/dist/core/effect-ir/shell-lower/decoders/docker.js +43 -0
  65. package/dist/core/effect-ir/shell-lower/decoders/filesystem.d.ts +9 -0
  66. package/dist/core/effect-ir/shell-lower/decoders/filesystem.js +217 -0
  67. package/dist/core/effect-ir/shell-lower/decoders/prisma.d.ts +2 -0
  68. package/dist/core/effect-ir/shell-lower/decoders/prisma.js +54 -0
  69. package/dist/core/effect-ir/shell-lower/decoders/ruby.d.ts +11 -0
  70. package/dist/core/effect-ir/shell-lower/decoders/ruby.js +147 -0
  71. package/dist/core/effect-ir/shell-lower/decoders/toolchain.d.ts +7 -0
  72. package/dist/core/effect-ir/shell-lower/decoders/toolchain.js +155 -0
  73. package/dist/core/effect-ir/shell-lower/requirement.d.ts +12 -0
  74. package/dist/core/effect-ir/shell-lower/requirement.js +65 -0
  75. package/dist/core/effect-ir/shell-lower/segment.d.ts +11 -0
  76. package/dist/core/effect-ir/shell-lower/segment.js +82 -0
  77. package/dist/core/effect-ir/shell-lower/tokens.d.ts +26 -0
  78. package/dist/core/effect-ir/shell-lower/tokens.js +183 -0
  79. package/dist/core/effect-ir/shell-lower.d.ts +2 -7
  80. package/dist/core/effect-ir/shell-lower.js +60 -1502
  81. package/dist/core/egress-approval.js +0 -18
  82. package/dist/core/glob.js +32 -16
  83. package/dist/core/integrity.d.ts +2 -2
  84. package/dist/core/integrity.js +52 -12
  85. package/dist/core/notify.d.ts +8 -2
  86. package/dist/core/notify.js +63 -7
  87. package/dist/core/recovery/operator-guidance.js +4 -4
  88. package/dist/core/replay-scrub.d.ts +2 -0
  89. package/dist/core/replay-scrub.js +8 -0
  90. package/dist/core/repo-config-trust.d.ts +31 -0
  91. package/dist/core/repo-config-trust.js +152 -0
  92. package/dist/core/shell-substitution.js +2 -2
  93. package/dist/core/shell-unparseable.js +3 -1
  94. package/dist/corpus/benign-probe-cores.d.ts +1 -1
  95. package/dist/corpus/benign-probe-cores.js +0 -1
  96. package/dist/defaults.d.ts +7 -3
  97. package/dist/defaults.js +29 -47
  98. package/dist/installer/bootstrap.d.ts +1 -0
  99. package/dist/installer/bootstrap.js +2 -2
  100. package/dist/installer/runtime-artifacts.js +43 -13
  101. package/dist/installer/scope-config.js +2 -2
  102. package/dist/installer.js +48 -13
  103. package/dist/node-resolution.d.ts +1 -0
  104. package/dist/node-resolution.js +61 -0
  105. package/dist/templates.d.ts +7 -4
  106. package/dist/templates.js +42 -4
  107. package/dist/types.d.ts +20 -0
  108. package/dist/version.d.ts +1 -1
  109. package/dist/version.js +1 -1
  110. package/package.json +5 -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)
@@ -308,7 +342,7 @@ Notable settings:
308
342
  uses `git_worktree`; dirty Git and non-Git directories use `file_checkpoint` when separately
309
343
  enabled (`policy.transactional.fileCheckpoint.enabled` and, for non-Git roots,
310
344
  `allowNonGit: true`) with an attested workspace-isolating boundary. Restore is conflict-checked
311
- and always requires a signed out-of-band, exact one-shot approval. Network, remote Git,
345
+ and always requires a signed local-control-plane, exact one-shot approval. Network, remote Git,
312
346
  databases, processes, and repo-external effects are outside this guarantee.
313
347
  - **Cloud judge** — configure with `belay config` (interactive) or `belay config set judge.providerId <id>`.
314
348
  Providers: `ollama`, `codex`, `claude`, `cursor`. **Provider** is the vendor/service
@@ -356,7 +390,8 @@ belay recover [advice] [--command "rm important.ts"] # advisory candidates only
356
390
  belay recover status # checkpoint backend, eligibility, and state counts
357
391
  belay recover list # proven repo-local recovery points
358
392
  belay recover show <checkpoint-id>
359
- belay recover apply <checkpoint-id> # signed OOB exact one-shot approval required
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
360
395
  belay explain -- <shell-command> # inspect a verdict
361
396
  belay explain --kind subagent -- "deploy to production"
362
397
  belay explain --kind tool --tool Write -- .env
@@ -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,10 @@
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
+ type CursorResponse = Record<string, unknown>;
8
+ declare function dispatchCursorHookResponse(params: DispatchCursorHookParams): Promise<CursorResponse>;
9
+ export declare function dispatchCursorHook(params: DispatchCursorHookParams): Promise<void>;
10
+ export { dispatchCursorHookResponse };
@@ -0,0 +1,137 @@
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 (params.kind === 'tool-gate' &&
101
+ (params.eventName === 'preToolUse' || params.eventName === 'PreToolUse') &&
102
+ input.payload.tool_name === 'Shell') {
103
+ return neutralResponse(params.kind);
104
+ }
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
+ }
111
+ return neutralResponse(params.kind);
112
+ }
113
+ try {
114
+ const route = routeCursorHook({
115
+ origin: params.origin,
116
+ kind: params.kind,
117
+ eventName: params.eventName,
118
+ payload: input.payload,
119
+ });
120
+ if (route.decision === 'neutral') {
121
+ return neutralResponse(params.kind);
122
+ }
123
+ if (route.decision === 'fail_closed') {
124
+ return failClosedResponse(params.kind, route.message);
125
+ }
126
+ return await executeCoreHandler(params, input.payload);
127
+ }
128
+ catch (error) {
129
+ const detail = error instanceof Error ? error.message : String(error);
130
+ return failClosedResponse(params.kind, `belay failed while dispatching this Cursor hook: ${detail}`);
131
+ }
132
+ }
133
+ export async function dispatchCursorHook(params) {
134
+ const response = await dispatchCursorHookResponse(params);
135
+ process.stdout.write(`${JSON.stringify(response)}\n`);
136
+ }
137
+ export { dispatchCursorHookResponse };
@@ -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,273 @@
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 { isTrustedCursorRoutingConfig } from './routing-config-trust.js';
5
+ import { cursorRoutingConfigPath, cursorRoutingHooksDir, cursorRoutingHooksSettingsPath, cursorRoutingRuntimeDir, findCursorRoutingRepoRoot, } from './routing-layout.js';
6
+ function canonicalExistingPath(value) {
7
+ if (!existsSync(value)) {
8
+ return undefined;
9
+ }
10
+ try {
11
+ return realpathSync(value);
12
+ }
13
+ catch {
14
+ return undefined;
15
+ }
16
+ }
17
+ function hookScriptFor(kind) {
18
+ if (kind === 'before-submit') {
19
+ return 'belay-before-submit.mjs';
20
+ }
21
+ if (kind === 'shell-gate') {
22
+ return 'belay-shell-gate.mjs';
23
+ }
24
+ if (kind === 'tool-gate') {
25
+ return 'belay-tool-gate.mjs';
26
+ }
27
+ return 'belay-audit.mjs';
28
+ }
29
+ function hookCommandFor(kind) {
30
+ return hookScriptFor(kind).replace(/\.mjs$/, '');
31
+ }
32
+ function eventNameFor(params) {
33
+ if (params.eventName) {
34
+ return params.eventName;
35
+ }
36
+ if (params.kind === 'before-submit') {
37
+ return 'beforeSubmitPrompt';
38
+ }
39
+ if (params.kind === 'shell-gate') {
40
+ return 'beforeShellExecution';
41
+ }
42
+ if (params.kind === 'tool-gate') {
43
+ return typeof params.payload.subagent_type === 'string' ? 'subagentStart' : 'preToolUse';
44
+ }
45
+ if (typeof params.payload.error_message === 'string') {
46
+ return 'postToolUseFailure';
47
+ }
48
+ if (typeof params.payload.status === 'string') {
49
+ return 'stop';
50
+ }
51
+ if (typeof params.payload.session_id === 'string') {
52
+ return 'sessionEnd';
53
+ }
54
+ return 'postToolUse';
55
+ }
56
+ function selectedRunnerPath(hooksDir) {
57
+ const runnerPath = path.join(hooksDir, process.platform === 'win32' ? 'belay-runner.ps1' : 'belay-runner');
58
+ const canonicalPath = canonicalExistingPath(runnerPath);
59
+ if (!canonicalPath) {
60
+ return undefined;
61
+ }
62
+ try {
63
+ if (!statSync(canonicalPath).isFile()) {
64
+ return undefined;
65
+ }
66
+ accessSync(canonicalPath, process.platform === 'win32' ? constants.R_OK : constants.X_OK);
67
+ return canonicalPath;
68
+ }
69
+ catch {
70
+ return undefined;
71
+ }
72
+ }
73
+ function isRegularFile(filePath) {
74
+ const canonicalPath = canonicalExistingPath(filePath);
75
+ if (!canonicalPath) {
76
+ return false;
77
+ }
78
+ try {
79
+ return statSync(canonicalPath).isFile();
80
+ }
81
+ catch {
82
+ return false;
83
+ }
84
+ }
85
+ function expectedHookArgs(kind, eventName) {
86
+ return kind === 'tool-gate' || kind === 'audit' ? [eventName] : [];
87
+ }
88
+ function commandInvokesProjectHook(command, runnerPath, kind, eventName) {
89
+ const hookCommand = hookCommandFor(kind);
90
+ const args = expectedHookArgs(kind, eventName);
91
+ if (process.platform !== 'win32') {
92
+ const expected = [`'${runnerPath.replaceAll("'", "'\\''")}'`, hookCommand, ...args].join(' ');
93
+ return command === expected;
94
+ }
95
+ const encoded = command.match(/^"[^"\r\n]+" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ([A-Za-z0-9+/=]+)$/)?.[1];
96
+ if (!encoded) {
97
+ return false;
98
+ }
99
+ const quotePowerShellLiteral = (value) => `'${value.replaceAll("'", "''")}'`;
100
+ const expected = [
101
+ '&',
102
+ quotePowerShellLiteral(runnerPath),
103
+ ...[hookCommand, ...args].map(quotePowerShellLiteral),
104
+ ].join(' ');
105
+ return Buffer.from(encoded, 'base64').toString('utf16le') === expected;
106
+ }
107
+ function expectedMatchers(eventName, payload) {
108
+ if (eventName === 'preToolUse') {
109
+ // Managed preToolUse is intentionally unfiltered. Keep the legacy matcher variant so older
110
+ // installed hooks still route through the same owner until upgrade.
111
+ const values = [undefined];
112
+ if (typeof payload.tool_name === 'string') {
113
+ values.push(payload.tool_name);
114
+ }
115
+ return values;
116
+ }
117
+ if (eventName === 'subagentStart') {
118
+ return [typeof payload.subagent_type === 'string' ? payload.subagent_type : undefined];
119
+ }
120
+ return [undefined];
121
+ }
122
+ function hasManagedProjectHookEntry(repoRoot, runnerPath, params) {
123
+ const eventName = eventNameFor(params);
124
+ const matchers = new Set(expectedMatchers(eventName, params.payload));
125
+ let parsed;
126
+ try {
127
+ parsed = JSON.parse(readFileSync(cursorRoutingHooksSettingsPath(repoRoot), 'utf8'));
128
+ }
129
+ catch {
130
+ return false;
131
+ }
132
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
133
+ return false;
134
+ }
135
+ const hooks = parsed.hooks;
136
+ if (hooks === null || typeof hooks !== 'object' || Array.isArray(hooks)) {
137
+ return false;
138
+ }
139
+ const entries = hooks[eventName];
140
+ if (!Array.isArray(entries)) {
141
+ return false;
142
+ }
143
+ return entries.some((entry) => {
144
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
145
+ return false;
146
+ }
147
+ const definition = entry;
148
+ return (definition.failClosed === true &&
149
+ matchers.has(definition.matcher) &&
150
+ typeof definition.command === 'string' &&
151
+ commandInvokesProjectHook(definition.command, runnerPath, params.kind, eventName));
152
+ });
153
+ }
154
+ function hasMatchingProjectShim(repoRoot, kind) {
155
+ let source;
156
+ try {
157
+ source = readFileSync(path.join(cursorRoutingHooksDir(repoRoot), hookScriptFor(kind)), 'utf8');
158
+ }
159
+ catch {
160
+ return false;
161
+ }
162
+ return (source.includes("from '../belay/runtime/dispatcher.mjs'") &&
163
+ source.includes(`origin: ${JSON.stringify({ scope: 'project', repoRoot })}`));
164
+ }
165
+ function hasCallableProjectOwner(repoRoot, params) {
166
+ const hooksDir = cursorRoutingHooksDir(repoRoot);
167
+ const runnerPath = selectedRunnerPath(hooksDir);
168
+ return Boolean(runnerPath &&
169
+ isRegularFile(path.join(cursorRoutingRuntimeDir(repoRoot), 'dispatcher.mjs')) &&
170
+ hasMatchingProjectShim(repoRoot, params.kind) &&
171
+ hasManagedProjectHookEntry(repoRoot, runnerPath, params));
172
+ }
173
+ function payloadForKind(payload, kind) {
174
+ if (kind === 'before-submit' || kind === 'shell-gate') {
175
+ return payload;
176
+ }
177
+ if (kind === 'tool-gate' && payload.tool_name === 'Shell') {
178
+ return payload;
179
+ }
180
+ const toolInput = payload.tool_input;
181
+ if (toolInput === null || typeof toolInput !== 'object' || Array.isArray(toolInput)) {
182
+ return payload;
183
+ }
184
+ const { working_directory: _workingDirectory, ...withoutWorkingDirectory } = toolInput;
185
+ return { ...payload, tool_input: withoutWorkingDirectory };
186
+ }
187
+ function selectedActionPath(payload) {
188
+ const toolInput = payload.tool_input;
189
+ if (toolInput !== null && typeof toolInput === 'object' && !Array.isArray(toolInput)) {
190
+ const workingDirectory = toolInput.working_directory;
191
+ if (typeof workingDirectory === 'string' && workingDirectory.trim()) {
192
+ return workingDirectory.trim();
193
+ }
194
+ }
195
+ if (typeof payload.cwd === 'string' && payload.cwd.trim()) {
196
+ return payload.cwd.trim();
197
+ }
198
+ if (!Array.isArray(payload.workspace_roots)) {
199
+ return undefined;
200
+ }
201
+ for (const root of payload.workspace_roots) {
202
+ if (typeof root === 'string' && root.trim()) {
203
+ return root.trim();
204
+ }
205
+ }
206
+ return undefined;
207
+ }
208
+ function readInstallScope(repoRoot) {
209
+ const configPath = cursorRoutingConfigPath(repoRoot);
210
+ let source;
211
+ try {
212
+ source = readFileSync(configPath, 'utf8');
213
+ }
214
+ catch (error) {
215
+ return error.code === 'ENOENT' ? 'missing' : 'project';
216
+ }
217
+ try {
218
+ const parsed = JSON.parse(source);
219
+ if (parsed !== null &&
220
+ typeof parsed === 'object' &&
221
+ !Array.isArray(parsed) &&
222
+ parsed.installScope === 'global') {
223
+ return isTrustedCursorRoutingConfig(repoRoot, parsed) ? 'global' : 'project';
224
+ }
225
+ }
226
+ catch {
227
+ // A present config that cannot be read or parsed must retain Project ownership so the
228
+ // selected Project hook reaches its fail-closed core path. Only a missing config is neutral.
229
+ }
230
+ return 'project';
231
+ }
232
+ export function routeCursorHook(params) {
233
+ const relevantPayload = payloadForKind(params.payload, params.kind);
234
+ const selectedPath = selectedActionPath(relevantPayload);
235
+ if (!selectedPath || !path.isAbsolute(selectedPath)) {
236
+ return { decision: 'fail_closed', message: 'belay could not determine the workspace.' };
237
+ }
238
+ const resolution = resolveCursorActionCwdDetails(relevantPayload, '/');
239
+ const canonicalCwd = resolution.fromPayload ? canonicalExistingPath(resolution.cwd) : undefined;
240
+ if (!canonicalCwd) {
241
+ return { decision: 'fail_closed', message: 'belay could not determine the workspace.' };
242
+ }
243
+ const repoRoot = canonicalExistingPath(findCursorRoutingRepoRoot(canonicalCwd));
244
+ if (!repoRoot) {
245
+ return { decision: 'neutral' };
246
+ }
247
+ const installScope = readInstallScope(repoRoot);
248
+ if (installScope === 'missing') {
249
+ return { decision: 'neutral' };
250
+ }
251
+ if (installScope === 'global') {
252
+ return params.origin.scope === 'global'
253
+ ? { decision: 'execute', repoRoot }
254
+ : { decision: 'neutral' };
255
+ }
256
+ if (params.origin.scope === 'project' &&
257
+ canonicalExistingPath(params.origin.repoRoot) !== repoRoot) {
258
+ return { decision: 'neutral' };
259
+ }
260
+ const callableProjectOwner = hasCallableProjectOwner(repoRoot, params);
261
+ if (params.origin.scope === 'global') {
262
+ return callableProjectOwner
263
+ ? { decision: 'neutral' }
264
+ : {
265
+ decision: 'fail_closed',
266
+ message: 'belay project hook owner is unavailable; the global sentinel blocked this action.',
267
+ };
268
+ }
269
+ const complete = callableProjectOwner && isRegularFile(path.join(cursorRoutingRuntimeDir(repoRoot), 'core.mjs'));
270
+ return complete
271
+ ? { decision: 'execute', repoRoot }
272
+ : { decision: 'fail_closed', message: 'belay project hook installation is incomplete.' };
273
+ }
@@ -1,8 +1,12 @@
1
1
  import type { HookEntry, HooksFile } from '../../types.js';
2
- /** Managed Shell preToolUse gate for Cursor Agent Shell tool invocations. */
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;
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;