@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
@@ -1,25 +1,61 @@
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
+ const legacyMatchedToolNames = ['Task', 'Write', 'StrReplace', 'Delete'];
20
+ return [
21
+ ...entries,
22
+ ...entries.flatMap(({ event, definition }) => event === 'preToolUse' && definition.matcher === undefined
23
+ ? legacyMatchedToolNames.map((matcher) => ({
24
+ event,
25
+ definition: { ...definition, matcher },
26
+ }))
27
+ : []),
28
+ ];
29
+ }
30
+ function variantsForDefinition(legacyEntries, event, definition) {
31
+ return [
32
+ definition,
33
+ ...legacyEntries
34
+ .filter((entry) => entry.event === event &&
35
+ (entry.definition.matcher === definition.matcher ||
36
+ (event === 'preToolUse' && definition.matcher === undefined)))
37
+ .map((entry) => entry.definition),
38
+ ];
39
+ }
40
+ function mergeHookEntry(current, expected, managedVariants, placement) {
6
41
  const entries = Array.isArray(current) ? [...current] : [];
7
- const filtered = entries.filter((entry) => !entryMatches(entry, expected));
42
+ const filtered = entries.filter((entry) => !managedVariants.some((variant) => entryMatches(entry, variant)));
8
43
  if (placement === 'prepend') {
9
44
  return [expected, ...filtered];
10
45
  }
11
46
  return [...filtered, expected];
12
47
  }
13
- /** Managed Shell preToolUse gate for Cursor Agent Shell tool invocations. */
48
+ /** Legacy managed Shell preToolUse gate matcher (upgrade/uninstall migration only). */
14
49
  export function managedShellPreToolUseEntry(platform, hooksDir, repoRoot) {
15
50
  const managed = getManagedHookEntries(platform, hooksDir, repoRoot);
16
- const referencePreToolUse = managed.find((entry) => entry.event === 'preToolUse' && entry.definition.matcher === 'Write')?.definition;
51
+ const referencePreToolUse = managed.find((entry) => entry.event === 'preToolUse' && entry.definition.matcher === undefined)?.definition;
17
52
  if (!referencePreToolUse) {
18
53
  throw new Error('managed hook definitions missing for Shell preToolUse gate');
19
54
  }
20
55
  return {
21
56
  command: referencePreToolUse.command,
22
57
  matcher: 'Shell',
58
+ failClosed: true,
23
59
  };
24
60
  }
25
61
  /** @deprecated Use {@link managedShellPreToolUseEntry}. */
@@ -30,20 +66,19 @@ export function stripCursorHooksFile(current, platform, hooksDir, repoRoot) {
30
66
  hooks: { ...current.hooks },
31
67
  };
32
68
  const managedEntries = getManagedHookEntries(platform, hooksDir, repoRoot);
69
+ const legacyEntries = legacyManagedEntries(platform, hooksDir, repoRoot);
33
70
  for (const { event, definition } of managedEntries) {
34
71
  const entries = next.hooks[event];
35
72
  if (!Array.isArray(entries)) {
36
73
  continue;
37
74
  }
38
- next.hooks[event] = entries.filter((entry) => !entryMatches(entry, {
39
- command: definition.command,
40
- matcher: definition.matcher,
41
- }));
75
+ const variants = variantsForDefinition(legacyEntries, event, definition);
76
+ next.hooks[event] = entries.filter((entry) => !variants.some((variant) => entryMatches(entry, variant)));
42
77
  if (next.hooks[event].length === 0) {
43
78
  delete next.hooks[event];
44
79
  }
45
80
  }
46
- return next;
81
+ return stripLegacyManagedShellPreToolUseGates(next, platform, hooksDir, repoRoot);
47
82
  }
48
83
  export function mergeCursorHooksFile(current, platform, hooksDir, repoRoot) {
49
84
  const next = {
@@ -51,14 +86,74 @@ export function mergeCursorHooksFile(current, platform, hooksDir, repoRoot) {
51
86
  hooks: { ...current.hooks },
52
87
  };
53
88
  const managedEntries = getManagedHookEntries(platform, hooksDir, repoRoot);
89
+ const legacyEntries = legacyManagedEntries(platform, hooksDir, repoRoot);
54
90
  for (const { event, definition } of managedEntries) {
91
+ const variants = variantsForDefinition(legacyEntries, event, definition);
55
92
  next.hooks[event] = mergeHookEntry(next.hooks[event], {
56
93
  command: definition.command,
57
94
  matcher: definition.matcher,
58
- }, definition.placement);
95
+ failClosed: definition.failClosed,
96
+ }, variants, definition.placement);
97
+ }
98
+ return stripLegacyManagedShellPreToolUseGates(next, platform, hooksDir, repoRoot);
99
+ }
100
+ export function legacyManagedShellPreToolUseVariants(platform, hooksDir, repoRoot) {
101
+ const shellEntry = managedShellPreToolUseEntry(platform, hooksDir, repoRoot);
102
+ const legacyEntries = legacyManagedEntries(platform, hooksDir, repoRoot);
103
+ const commands = new Set([shellEntry.command]);
104
+ for (const entry of legacyEntries) {
105
+ if (entry.event === 'preToolUse') {
106
+ commands.add(entry.definition.command);
107
+ }
108
+ }
109
+ return [...commands].map((command) => ({
110
+ command,
111
+ matcher: 'Shell',
112
+ failClosed: true,
113
+ }));
114
+ }
115
+ function stripLegacyManagedShellPreToolUseGates(hooks, platform, hooksDir, repoRoot) {
116
+ const variants = legacyManagedShellPreToolUseVariants(platform, hooksDir, repoRoot);
117
+ const preToolUse = hooks.hooks.preToolUse;
118
+ if (!Array.isArray(preToolUse) || variants.length === 0) {
119
+ return hooks;
120
+ }
121
+ const filtered = preToolUse.filter((entry) => !variants.some((variant) => entryMatches(entry, variant)));
122
+ if (filtered.length === preToolUse.length) {
123
+ return hooks;
124
+ }
125
+ const next = {
126
+ version: hooks.version || 1,
127
+ hooks: { ...hooks.hooks },
128
+ };
129
+ if (filtered.length === 0) {
130
+ delete next.hooks.preToolUse;
131
+ }
132
+ else {
133
+ next.hooks.preToolUse = filtered;
59
134
  }
60
135
  return next;
61
136
  }
137
+ export function hasLegacyCursorDoubleShellGates(hooks, platform, hooksDir, repoRoot) {
138
+ const shellVariants = legacyManagedShellPreToolUseVariants(platform, hooksDir, repoRoot);
139
+ const preToolUse = hooks.hooks.preToolUse;
140
+ const beforeShell = hooks.hooks.beforeShellExecution;
141
+ if (!Array.isArray(preToolUse) || !Array.isArray(beforeShell)) {
142
+ return false;
143
+ }
144
+ const hasLegacyShellPreToolUse = preToolUse.some((entry) => shellVariants.some((variant) => entryMatches(entry, variant)));
145
+ if (!hasLegacyShellPreToolUse) {
146
+ return false;
147
+ }
148
+ const managedEntries = getManagedHookEntries(platform, hooksDir, repoRoot);
149
+ const shellManaged = managedEntries.find((entry) => entry.event === 'beforeShellExecution');
150
+ if (!shellManaged) {
151
+ return false;
152
+ }
153
+ const shellVariantsForBefore = variantsForDefinition(legacyManagedEntries(platform, hooksDir, repoRoot), 'beforeShellExecution', shellManaged.definition);
154
+ return beforeShell.some((entry) => shellVariantsForBefore.some((variant) => entryMatches(entry, variant)));
155
+ }
156
+ /** @deprecated Use {@link hasLegacyCursorDoubleShellGates}. */
62
157
  export function hasDuplicateCursorShellGates(hooks, platform, hooksDir, repoRoot) {
63
158
  const shellEntry = managedShellPreToolUseEntry(platform, hooksDir, repoRoot);
64
159
  const preToolUse = hooks.hooks.preToolUse;
@@ -68,3 +163,10 @@ export function hasDuplicateCursorShellGates(hooks, platform, hooksDir, repoRoot
68
163
  const shellMatches = preToolUse.filter((entry) => entryMatches(entry, shellEntry));
69
164
  return shellMatches.length > 1;
70
165
  }
166
+ export function hasManagedCursorHookEntries(hooks, platform, hooksDir, repoRoot) {
167
+ const managedEntries = [
168
+ ...getManagedHookEntries(platform, hooksDir, repoRoot),
169
+ ...legacyManagedEntries(platform, hooksDir, repoRoot),
170
+ ];
171
+ return managedEntries.some(({ event, definition }) => hooks.hooks[event]?.some((entry) => entryMatches(entry, definition)));
172
+ }
@@ -0,0 +1 @@
1
+ export declare function isTrustedCursorRoutingConfig(repoRoot: string, rawConfig: unknown): boolean;
@@ -0,0 +1,67 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync, realpathSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ function canonicalStringify(value) {
5
+ if (value === null || typeof value !== 'object') {
6
+ return JSON.stringify(value);
7
+ }
8
+ if (Array.isArray(value)) {
9
+ return `[${value.map((item) => canonicalStringify(item)).join(',')}]`;
10
+ }
11
+ const entries = Object.entries(value).sort(([left], [right]) => left.localeCompare(right));
12
+ return `{${entries.map(([key, child]) => `${JSON.stringify(key)}:${canonicalStringify(child)}`).join(',')}}`;
13
+ }
14
+ function hashValue(value) {
15
+ return createHash('sha256').update(value).digest('hex');
16
+ }
17
+ function defaultControlPlaneDir() {
18
+ if (process.platform === 'win32' && process.env.APPDATA?.trim()) {
19
+ return path.join(process.env.APPDATA.trim(), 'agent-belay');
20
+ }
21
+ const base = process.env.XDG_CONFIG_HOME?.trim() || path.join(process.env.HOME ?? '', '.config');
22
+ return path.join(base, 'agent-belay');
23
+ }
24
+ function canonicalRepoRoot(repoRoot) {
25
+ try {
26
+ return realpathSync(repoRoot);
27
+ }
28
+ catch {
29
+ return path.resolve(repoRoot);
30
+ }
31
+ }
32
+ export function isTrustedCursorRoutingConfig(repoRoot, rawConfig) {
33
+ const canonicalRoot = canonicalRepoRoot(repoRoot);
34
+ const identity = hashValue(`${canonicalRoot}\u0000cursor`);
35
+ const recordPath = path.join(defaultControlPlaneDir(), 'config-trust', `${identity}.json`);
36
+ let parsed;
37
+ try {
38
+ parsed = JSON.parse(readFileSync(recordPath, 'utf8'));
39
+ }
40
+ catch {
41
+ return false;
42
+ }
43
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
44
+ return false;
45
+ }
46
+ const record = parsed;
47
+ const expectedKeys = new Set([
48
+ 'schemaVersion',
49
+ 'repoRoot',
50
+ 'adapter',
51
+ 'repoConfigFingerprint',
52
+ 'trustedAt',
53
+ ]);
54
+ if (Object.keys(record).length !== expectedKeys.size ||
55
+ !Object.keys(record).every((key) => expectedKeys.has(key)) ||
56
+ record.schemaVersion !== 1 ||
57
+ record.adapter !== 'cursor' ||
58
+ typeof record.repoRoot !== 'string' ||
59
+ canonicalRepoRoot(record.repoRoot) !== canonicalRoot ||
60
+ typeof record.repoConfigFingerprint !== 'string' ||
61
+ !/^[a-f0-9]{64}$/.test(record.repoConfigFingerprint) ||
62
+ typeof record.trustedAt !== 'string' ||
63
+ !Number.isFinite(Date.parse(record.trustedAt))) {
64
+ return false;
65
+ }
66
+ return record.repoConfigFingerprint === hashValue(canonicalStringify(rawConfig));
67
+ }
@@ -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;
@@ -0,0 +1,29 @@
1
+ import { existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ export function cursorRoutingConfigPath(repoRoot) {
4
+ return path.join(repoRoot, '.cursor', 'belay.config.json');
5
+ }
6
+ export function cursorRoutingHooksDir(repoRoot) {
7
+ return path.join(repoRoot, '.cursor', 'hooks');
8
+ }
9
+ export function cursorRoutingHooksSettingsPath(repoRoot) {
10
+ return path.join(repoRoot, '.cursor', 'hooks.json');
11
+ }
12
+ export function cursorRoutingRuntimeDir(repoRoot) {
13
+ return path.join(repoRoot, '.cursor', 'belay', 'runtime');
14
+ }
15
+ export function findCursorRoutingRepoRoot(startPath) {
16
+ const resolvedStart = path.resolve(startPath);
17
+ let current = resolvedStart;
18
+ while (true) {
19
+ if (existsSync(path.join(current, '.git')) ||
20
+ (existsSync(path.join(current, '.cursor')) && existsSync(cursorRoutingConfigPath(current)))) {
21
+ return current;
22
+ }
23
+ const parent = path.dirname(current);
24
+ if (parent === current) {
25
+ return resolvedStart;
26
+ }
27
+ current = parent;
28
+ }
29
+ }
@@ -1,5 +1,22 @@
1
1
  export declare function resolveCursorActionCwd(payload: Record<string, unknown>, fallback?: string): string;
2
+ export declare function handleBeforeSubmitPromptHook(payload: Record<string, unknown>): Promise<{
3
+ replay?: import("../../core/approval-replay.js").ApprovalReplayHint | undefined;
4
+ user_message?: string | undefined;
5
+ continue: boolean;
6
+ }>;
2
7
  export declare function runBeforeSubmitPromptHook(): Promise<void>;
8
+ export declare function handleShellGateHook(payload: Record<string, unknown>): Promise<import("../../core/gate-contract.js").GatePermissionResponse | {
9
+ permission: string;
10
+ user_message: string;
11
+ }>;
3
12
  export declare function runShellGateHook(): Promise<void>;
13
+ export declare function handleToolGateHook(eventName: string, payload: Record<string, unknown>): Promise<import("../../core/gate-contract.js").GatePermissionResponse | {
14
+ permission: string;
15
+ user_message?: undefined;
16
+ } | {
17
+ permission: string;
18
+ user_message: string;
19
+ }>;
4
20
  export declare function runToolGateHook(eventName: string): Promise<void>;
21
+ export declare function handleAuditHook(eventName: string, payload: Record<string, unknown>): Promise<{}>;
5
22
  export declare function runAuditHook(eventName: string): Promise<void>;
@@ -3,6 +3,7 @@ import process from 'node:process';
3
3
  import { approvalCommandMatch } from '../../core/approval.js';
4
4
  import { findApprovalRepoRoots, formatAmbiguousApprovalRepoMessage, } from '../../core/approval-repo-lookup.js';
5
5
  import { DEFAULT_CONFIG_V4 } from '../../core/config.js';
6
+ import { isRepoConfigTrustError } from '../../core/repo-config-trust.js';
6
7
  import { cursorLayout } from '../layouts/cursor.js';
7
8
  import { appendObservedAudit, createDefaultGateRuntimeDeps, evaluateGatedAction, gateVerdictToCursorResponse, processApprovalPrompt, resolveGateConfig, } from '../shared/gate-runtime.js';
8
9
  import { findRepoRoot } from '../shared/repo-root.js';
@@ -104,45 +105,53 @@ function isSubagentEvent(payload, eventName) {
104
105
  function isFileMutationTool(toolName) {
105
106
  return toolName === 'Write' || toolName === 'StrReplace' || toolName === 'Delete';
106
107
  }
107
- export async function runBeforeSubmitPromptHook() {
108
+ function isCursorPreToolUseEvent(eventName) {
109
+ return eventName === 'preToolUse' || eventName === 'PreToolUse';
110
+ }
111
+ export async function handleBeforeSubmitPromptHook(payload) {
108
112
  try {
109
- const payload = await readStdinJson();
110
113
  const prompt = String(payload.prompt ?? '');
111
114
  const resolution = resolveCursorActionCwdDetails(payload);
112
115
  if (isUnsafeGlobalHookFallback(resolution)) {
113
- jsonResponse({
116
+ return {
114
117
  continue: false,
115
118
  user_message: GLOBAL_HOOK_WORKSPACE_MISSING_MESSAGE,
116
- });
117
- return;
119
+ };
118
120
  }
119
121
  const ctx = await loadRuntimeContext(resolution.cwd);
120
122
  const deps = createDefaultGateRuntimeDeps();
121
123
  const result = await processApprovalPromptWithRepoFallback(ctx, deps, prompt, payload, resolution);
122
- jsonResponse({
124
+ return {
123
125
  continue: result.continue,
124
126
  ...(result.user_message ? { user_message: result.user_message } : {}),
125
127
  ...(result.replay ? { replay: result.replay } : {}),
126
- });
128
+ };
127
129
  }
128
- catch {
129
- jsonResponse({
130
+ catch (error) {
131
+ if (isRepoConfigTrustError(error)) {
132
+ return {
133
+ continue: false,
134
+ user_message: error.message,
135
+ };
136
+ }
137
+ return {
130
138
  continue: false,
131
139
  user_message: 'belay failed while processing approval state. Run belay doctor, then retry.',
132
- });
140
+ };
133
141
  }
134
142
  }
135
- export async function runShellGateHook() {
143
+ export async function runBeforeSubmitPromptHook() {
144
+ jsonResponse(await handleBeforeSubmitPromptHook(await readStdinJson()));
145
+ }
146
+ export async function handleShellGateHook(payload) {
136
147
  try {
137
- const payload = await readStdinJson();
138
148
  const command = String(payload.command ?? '').trim();
139
149
  const resolution = resolveCursorActionCwdDetails(payload);
140
150
  if (isUnsafeGlobalHookFallback(resolution)) {
141
- jsonResponse({
151
+ return {
142
152
  permission: 'deny',
143
153
  user_message: GLOBAL_HOOK_WORKSPACE_MISSING_MESSAGE,
144
- });
145
- return;
154
+ };
146
155
  }
147
156
  const cwd = resolution.cwd;
148
157
  const ctx = await loadRuntimeContext(cwd);
@@ -154,26 +163,36 @@ export async function runShellGateHook() {
154
163
  payload,
155
164
  sourceEvent: 'beforeShellExecution',
156
165
  });
157
- jsonResponse(gateVerdictToCursorResponse(verdict));
166
+ return gateVerdictToCursorResponse(verdict);
158
167
  }
159
- catch {
160
- jsonResponse({
168
+ catch (error) {
169
+ if (isRepoConfigTrustError(error)) {
170
+ return {
171
+ permission: 'deny',
172
+ user_message: error.message,
173
+ };
174
+ }
175
+ return {
161
176
  permission: 'deny',
162
177
  user_message: 'belay failed while classifying this shell command. Run belay doctor, then retry.',
163
- });
178
+ };
164
179
  }
165
180
  }
166
- export async function runToolGateHook(eventName) {
181
+ export async function runShellGateHook() {
182
+ jsonResponse(await handleShellGateHook(await readStdinJson()));
183
+ }
184
+ export async function handleToolGateHook(eventName, payload) {
167
185
  try {
168
- const payload = await readStdinJson();
169
186
  const toolName = String(payload.tool_name ?? '');
187
+ if (isCursorPreToolUseEvent(eventName) && toolName === 'Shell') {
188
+ return { permission: 'allow' };
189
+ }
170
190
  const resolution = resolveCursorToolActionCwdDetails(payload, process.cwd(), eventName, toolName);
171
191
  if (isUnsafeGlobalHookFallback(resolution)) {
172
- jsonResponse({
192
+ return {
173
193
  permission: 'deny',
174
194
  user_message: GLOBAL_HOOK_WORKSPACE_MISSING_MESSAGE,
175
- });
176
- return;
195
+ };
177
196
  }
178
197
  const cwd = resolution.cwd;
179
198
  const ctx = await loadRuntimeContext(cwd);
@@ -185,66 +204,72 @@ export async function runToolGateHook(eventName) {
185
204
  payload,
186
205
  sourceEvent: eventName,
187
206
  });
188
- jsonResponse(gateVerdictToCursorResponse(verdict));
189
- return;
207
+ return gateVerdictToCursorResponse(verdict);
190
208
  }
191
- if (toolName === 'Shell') {
209
+ if (isFileMutationTool(toolName)) {
192
210
  const verdict = await evaluateGatedAction(ctx, deps, {
193
- kind: 'shell',
211
+ kind: 'tool',
194
212
  cwd,
195
213
  payload,
196
214
  toolName,
197
215
  sourceEvent: eventName,
198
216
  });
199
- jsonResponse(gateVerdictToCursorResponse(verdict));
200
- return;
217
+ return gateVerdictToCursorResponse(verdict);
201
218
  }
202
- if (isFileMutationTool(toolName)) {
219
+ if (payload.tool_name === 'Task') {
203
220
  const verdict = await evaluateGatedAction(ctx, deps, {
204
- kind: 'tool',
221
+ kind: 'subagent',
205
222
  cwd,
206
223
  payload,
207
- toolName,
208
224
  sourceEvent: eventName,
209
225
  });
210
- jsonResponse(gateVerdictToCursorResponse(verdict));
211
- return;
226
+ return gateVerdictToCursorResponse(verdict);
212
227
  }
213
- if (payload.tool_name === 'Task') {
228
+ if (isCursorPreToolUseEvent(eventName)) {
214
229
  const verdict = await evaluateGatedAction(ctx, deps, {
215
- kind: 'subagent',
230
+ kind: 'tool',
216
231
  cwd,
217
232
  payload,
233
+ toolName,
218
234
  sourceEvent: eventName,
219
235
  });
220
- jsonResponse(gateVerdictToCursorResponse(verdict));
221
- return;
236
+ return gateVerdictToCursorResponse(verdict);
222
237
  }
223
- jsonResponse({ permission: 'allow' });
238
+ return { permission: 'allow' };
224
239
  }
225
- catch {
226
- jsonResponse({
240
+ catch (error) {
241
+ if (isRepoConfigTrustError(error)) {
242
+ return {
243
+ permission: 'deny',
244
+ user_message: error.message,
245
+ };
246
+ }
247
+ return {
227
248
  permission: 'deny',
228
249
  user_message: 'belay failed while classifying this tool action. Run belay doctor, then retry.',
229
- });
250
+ };
230
251
  }
231
252
  }
232
- export async function runAuditHook(eventName) {
253
+ export async function runToolGateHook(eventName) {
254
+ jsonResponse(await handleToolGateHook(eventName, await readStdinJson()));
255
+ }
256
+ export async function handleAuditHook(eventName, payload) {
233
257
  try {
234
- const payload = await readStdinJson();
235
258
  const toolName = String(payload.tool_name ?? '');
236
259
  const resolution = resolveCursorToolActionCwdDetails(payload, process.cwd(), eventName, toolName);
237
260
  if (isUnsafeGlobalHookFallback(resolution)) {
238
- jsonResponse({});
239
- return;
261
+ return {};
240
262
  }
241
263
  const ctx = await loadRuntimeContext(resolution.cwd);
242
264
  const deps = createDefaultGateRuntimeDeps();
243
265
  await appendObservedAudit(ctx, deps, eventName, payload);
244
- jsonResponse({});
266
+ return {};
245
267
  }
246
268
  catch (error) {
247
269
  console.error('belay audit hook failed:', error instanceof Error ? error.message : String(error));
248
- jsonResponse({});
270
+ return {};
249
271
  }
250
272
  }
273
+ export async function runAuditHook(eventName) {
274
+ jsonResponse(await handleAuditHook(eventName, await readStdinJson()));
275
+ }
@@ -11,8 +11,13 @@ export interface ScopedPaths {
11
11
  skillsDir: string;
12
12
  commandsDir?: string;
13
13
  }
14
+ export declare function resolveTrustedWindowsPowerShellPath(systemRoot?: string | undefined): string;
14
15
  export declare function isPathInside(child: string, parent: string): boolean;
15
16
  export declare function buildRunnerInvocation(platform: NodeJS.Platform, hooksDir: string, repoRoot: string, hookScript: string, ...args: string[]): string;
17
+ export declare function buildAbsoluteRunnerInvocation(platform: NodeJS.Platform, hooksDir: string, hookScript: string, ...args: string[]): string;
18
+ export declare function buildLegacyBarePowerShellRunnerInvocation(platform: NodeJS.Platform, hooksDir: string, hookScript: string, ...args: string[]): string;
19
+ export declare function buildLegacyQuotedAbsoluteRunnerInvocation(platform: NodeJS.Platform, hooksDir: string, hookScript: string, ...args: string[]): string;
20
+ export declare function buildLegacyAbsoluteRunnerInvocation(platform: NodeJS.Platform, hooksDir: string, hookScript: string, ...args: string[]): string;
16
21
  export declare function resolveScopedPaths(layout: AdapterLayout, scope: InstallScope, repoRoot: string): ScopedPaths;
17
22
  export declare function resolveInstallScope(options: {
18
23
  scope?: InstallScope;
@@ -1,6 +1,50 @@
1
+ import { existsSync, realpathSync } from 'node:fs';
1
2
  import os from 'node:os';
2
3
  import path from 'node:path';
3
4
  import { isPathOutsideRoot } from '../../core/path-utils.js';
5
+ function canonicalizePotentialPath(inputPath) {
6
+ const unresolvedSegments = [];
7
+ let existingAncestor = path.resolve(inputPath);
8
+ while (!existsSync(existingAncestor)) {
9
+ const parent = path.dirname(existingAncestor);
10
+ if (parent === existingAncestor) {
11
+ break;
12
+ }
13
+ unresolvedSegments.unshift(path.basename(existingAncestor));
14
+ existingAncestor = parent;
15
+ }
16
+ return existsSync(existingAncestor)
17
+ ? path.join(realpathSync(existingAncestor), ...unresolvedSegments)
18
+ : path.resolve(inputPath);
19
+ }
20
+ function quotePowerShellLiteral(value) {
21
+ return `'${value.replaceAll("'", "''")}'`;
22
+ }
23
+ export function resolveTrustedWindowsPowerShellPath(systemRoot = process.env.SystemRoot || process.env.WINDIR) {
24
+ if (!systemRoot || !path.win32.isAbsolute(systemRoot)) {
25
+ throw new Error('A trusted Windows system root must be an absolute drive path.');
26
+ }
27
+ const hasControlCharacter = [...systemRoot].some((character) => character.charCodeAt(0) < 32);
28
+ if (systemRoot !== systemRoot.trim() || hasControlCharacter || /[%!?*"&|<>^]/.test(systemRoot)) {
29
+ throw new Error('The trusted Windows system root contains unsafe path or cmd.exe expansion characters.');
30
+ }
31
+ if (systemRoot.slice(2).includes(':')) {
32
+ throw new Error('The trusted Windows system root contains an invalid drive path.');
33
+ }
34
+ const normalizedRoot = path.win32.normalize(systemRoot);
35
+ if (!/^[A-Za-z]:\\$/.test(path.win32.parse(normalizedRoot).root)) {
36
+ throw new Error('A trusted Windows system root must be an absolute drive path.');
37
+ }
38
+ return path.win32.join(normalizedRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
39
+ }
40
+ function buildEncodedPowerShellRunnerInvocation(executable, runnerPath, hookScript, args) {
41
+ const encodedCommand = Buffer.from([
42
+ '&',
43
+ quotePowerShellLiteral(runnerPath),
44
+ ...[hookScript, ...args].map(quotePowerShellLiteral),
45
+ ].join(' '), 'utf16le').toString('base64');
46
+ return `${executable} -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encodedCommand}`;
47
+ }
4
48
  function agentHomeDir(adapter) {
5
49
  const home = os.homedir();
6
50
  if (adapter === 'cursor') {
@@ -38,6 +82,33 @@ export function buildRunnerInvocation(platform, hooksDir, repoRoot, hookScript,
38
82
  : runnerAbs;
39
83
  return [runnerRef, hookScript, ...args].join(' ');
40
84
  }
85
+ export function buildAbsoluteRunnerInvocation(platform, hooksDir, hookScript, ...args) {
86
+ const runnerFile = platform === 'win32' ? 'belay-runner.ps1' : 'belay-runner';
87
+ const canonicalHooksDir = canonicalizePotentialPath(hooksDir);
88
+ const runnerPath = path.join(canonicalHooksDir, runnerFile);
89
+ if (platform === 'win32') {
90
+ const powerShellPath = resolveTrustedWindowsPowerShellPath();
91
+ return buildEncodedPowerShellRunnerInvocation(`"${powerShellPath}"`, runnerPath, hookScript, args);
92
+ }
93
+ return [`'${runnerPath.replaceAll("'", "'\\''")}'`, hookScript, ...args].join(' ');
94
+ }
95
+ export function buildLegacyBarePowerShellRunnerInvocation(platform, hooksDir, hookScript, ...args) {
96
+ if (platform !== 'win32') {
97
+ return buildAbsoluteRunnerInvocation(platform, hooksDir, hookScript, ...args);
98
+ }
99
+ const runnerPath = path.join(canonicalizePotentialPath(hooksDir), 'belay-runner.ps1');
100
+ return buildEncodedPowerShellRunnerInvocation('powershell.exe', runnerPath, hookScript, args);
101
+ }
102
+ export function buildLegacyQuotedAbsoluteRunnerInvocation(platform, hooksDir, hookScript, ...args) {
103
+ const runnerFile = platform === 'win32' ? 'belay-runner.cmd' : 'belay-runner';
104
+ const runnerPath = path.join(canonicalizePotentialPath(hooksDir), runnerFile);
105
+ const quotedRunnerPath = platform === 'win32' ? `"${runnerPath}"` : `'${runnerPath.replaceAll("'", "'\\''")}'`;
106
+ return [quotedRunnerPath, hookScript, ...args].join(' ');
107
+ }
108
+ export function buildLegacyAbsoluteRunnerInvocation(platform, hooksDir, hookScript, ...args) {
109
+ const runnerFile = platform === 'win32' ? 'belay-runner.cmd' : 'belay-runner';
110
+ return [path.resolve(hooksDir, runnerFile), hookScript, ...args].join(' ');
111
+ }
41
112
  export function resolveScopedPaths(layout, scope, repoRoot) {
42
113
  const resolvedRepo = path.resolve(repoRoot);
43
114
  const adapter = layout.name;