@guilz-dev/belay 0.9.1 → 0.9.3

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 (61) hide show
  1. package/README.md +1 -1
  2. package/dist/adapters/codex/runtime-entry.d.ts +3 -0
  3. package/dist/adapters/codex/runtime-entry.js +27 -4
  4. package/dist/adapters/cursor/cwd-resolution.d.ts +10 -0
  5. package/dist/adapters/cursor/cwd-resolution.js +58 -0
  6. package/dist/adapters/cursor/hooks.d.ts +5 -3
  7. package/dist/adapters/cursor/hooks.js +29 -20
  8. package/dist/adapters/cursor/runtime-entry.d.ts +1 -0
  9. package/dist/adapters/cursor/runtime-entry.js +107 -6
  10. package/dist/adapters/shared/gate-runtime.js +26 -3
  11. package/dist/adapters/shared/repo-root.js +20 -1
  12. package/dist/bundle/claude-runtime.mjs +1225 -287
  13. package/dist/bundle/codex-runtime.mjs +1247 -291
  14. package/dist/bundle/cursor-runtime.mjs +4320 -3154
  15. package/dist/cli.js +33 -3
  16. package/dist/commands/doctor.js +38 -9
  17. package/dist/commands/health-snapshot.d.ts +3 -0
  18. package/dist/commands/health-snapshot.js +56 -0
  19. package/dist/commands/report.js +14 -0
  20. package/dist/commands/status.js +15 -0
  21. package/dist/commands/where.d.ts +4 -0
  22. package/dist/commands/where.js +52 -0
  23. package/dist/core/approval-repo-lookup.d.ts +16 -0
  24. package/dist/core/approval-repo-lookup.js +48 -0
  25. package/dist/core/audit-io.d.ts +1 -1
  26. package/dist/core/audit-io.js +1 -1
  27. package/dist/core/audit-legacy-archive.d.ts +1 -0
  28. package/dist/core/audit-legacy-archive.js +5 -0
  29. package/dist/core/audit-query.d.ts +1 -0
  30. package/dist/core/audit-query.js +7 -0
  31. package/dist/core/audit-serialize.d.ts +3 -0
  32. package/dist/core/audit-serialize.js +39 -3
  33. package/dist/core/audit-summary.d.ts +9 -0
  34. package/dist/core/audit-summary.js +58 -1
  35. package/dist/core/audit-types.d.ts +4 -0
  36. package/dist/core/effect-ir/shell-lower.js +283 -27
  37. package/dist/core/replay-scrub.d.ts +1 -0
  38. package/dist/core/replay-scrub.js +22 -3
  39. package/dist/core/shell-tokenizer.d.ts +28 -0
  40. package/dist/core/shell-tokenizer.js +111 -29
  41. package/dist/core/verdict/docker-compose-run.d.ts +18 -0
  42. package/dist/core/verdict/docker-compose-run.js +136 -0
  43. package/dist/core/verdict/launcher-resolve.js +66 -25
  44. package/dist/core/verdict/makefile-expand.d.ts +4 -0
  45. package/dist/core/verdict/makefile-expand.js +151 -0
  46. package/dist/core/verdict/parser.d.ts +4 -0
  47. package/dist/core/verdict/parser.js +25 -30
  48. package/dist/core/verdict/recursive-invocation.d.ts +20 -0
  49. package/dist/core/verdict/recursive-invocation.js +224 -0
  50. package/dist/corpus/benign-probe-cores.d.ts +1 -1
  51. package/dist/corpus/benign-probe-cores.js +2 -0
  52. package/dist/defaults.js +16 -0
  53. package/dist/installer/scope-config.d.ts +2 -2
  54. package/dist/installer.d.ts +10 -1
  55. package/dist/installer.js +43 -2
  56. package/dist/types.d.ts +34 -1
  57. package/dist/version.d.ts +1 -1
  58. package/dist/version.js +1 -1
  59. package/package.json +5 -2
  60. package/skills/belay/SKILL.md +5 -0
  61. package/skills/belay/belay-report.md +4 -1
package/README.md CHANGED
@@ -44,7 +44,7 @@ equivalent lifecycle points:
44
44
  | Gate shell / tools / file mutations | `belay-tool-gate` | `beforeShellExecution`, `preToolUse` | `PreToolUse` | `PreToolUse` |
45
45
  | Gate subagent launches | `belay-tool-gate` | `subagentStart` | (via `PreToolUse`) | `SubagentStart` |
46
46
  | One-shot approvals | `belay-before-submit` | `beforeSubmitPrompt` | `UserPromptSubmit` | `UserPromptSubmit` |
47
- | Audit log | `belay-audit` | `postToolUse`, `stop`, `sessionEnd` | `PostToolUse` | `PostToolUse` |
47
+ | Audit log | `belay-audit` | `postToolUse`, `postToolUseFailure`, `stop`, `sessionEnd` | `PostToolUse` | `PostToolUse` |
48
48
 
49
49
  ## Why
50
50
 
@@ -1,3 +1,6 @@
1
+ export declare function resolveCodexActionCwd(payload: Record<string, unknown>, fallbackCwd?: string, options?: {
2
+ includeToolInputCwd?: boolean;
3
+ }): string;
1
4
  export declare function runBeforeSubmitPromptHook(): Promise<void>;
2
5
  export declare function runToolGateHook(eventName: string): Promise<void>;
3
6
  export declare function runShellGateHook(): Promise<void>;
@@ -1,3 +1,4 @@
1
+ import path from 'node:path';
1
2
  import process from 'node:process';
2
3
  import { codexLayout } from '../layouts/codex.js';
3
4
  import { appendObservedAudit, createDefaultGateRuntimeDeps, evaluateGatedAction, gateUnmappedToolVerdict, gateVerdictToCodexPreToolUseResponse, gateVerdictToCodexUserPromptResponse, processApprovalPrompt, resolveGateConfig, } from '../shared/gate-runtime.js';
@@ -21,6 +22,22 @@ async function readStdinJson() {
21
22
  function jsonResponse(value) {
22
23
  process.stdout.write(`${JSON.stringify(value)}\n`);
23
24
  }
25
+ function nonEmptyPathString(value) {
26
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
27
+ }
28
+ export function resolveCodexActionCwd(payload, fallbackCwd = process.cwd(), options = {}) {
29
+ const toolInput = payload.tool_input !== null && typeof payload.tool_input === 'object'
30
+ ? payload.tool_input
31
+ : undefined;
32
+ const nestedCwd = options.includeToolInputCwd
33
+ ? (nonEmptyPathString(toolInput?.working_directory) ?? nonEmptyPathString(toolInput?.cwd))
34
+ : undefined;
35
+ const payloadCwd = nonEmptyPathString(payload.cwd);
36
+ const workspaceRoot = Array.isArray(payload.workspace_roots)
37
+ ? payload.workspace_roots.map(nonEmptyPathString).find(Boolean)
38
+ : undefined;
39
+ return path.resolve(nestedCwd ?? payloadCwd ?? workspaceRoot ?? fallbackCwd);
40
+ }
24
41
  async function loadRuntimeContext(cwd) {
25
42
  const repoRoot = findRepoRoot(cwd, codexLayout);
26
43
  const configPath = codexLayout.configPath(repoRoot);
@@ -137,7 +154,8 @@ export async function runBeforeSubmitPromptHook() {
137
154
  try {
138
155
  const payload = await readStdinJson();
139
156
  const prompt = String(payload.prompt ?? payload.user_message ?? '');
140
- const ctx = await loadRuntimeContext(process.cwd());
157
+ const cwd = resolveCodexActionCwd(payload);
158
+ const ctx = await loadRuntimeContext(cwd);
141
159
  const deps = createDefaultGateRuntimeDeps();
142
160
  const result = await processApprovalPrompt(ctx, deps, prompt);
143
161
  jsonResponse(gateVerdictToCodexUserPromptResponse(result));
@@ -155,9 +173,10 @@ export async function runBeforeSubmitPromptHook() {
155
173
  export async function runToolGateHook(eventName) {
156
174
  try {
157
175
  const payload = await readStdinJson();
158
- const cwd = process.cwd();
159
176
  const toolName = String(payload.tool_name ?? payload.toolName ?? '');
160
177
  const kind = resolveCodexGateKind(eventName, toolName);
178
+ const includeToolInputCwd = (eventName === 'PreToolUse' || eventName === 'preToolUse') && kind === 'shell';
179
+ const cwd = resolveCodexActionCwd(payload, process.cwd(), { includeToolInputCwd });
161
180
  const ctx = await loadRuntimeContext(cwd);
162
181
  const deps = createDefaultGateRuntimeDeps();
163
182
  if (!kind) {
@@ -201,7 +220,7 @@ export async function runShellGateHook() {
201
220
  try {
202
221
  const payload = await readStdinJson();
203
222
  const command = extractString(payload.tool_input, 'command') || String(payload.command ?? '');
204
- const cwd = process.cwd();
223
+ const cwd = resolveCodexActionCwd(payload, process.cwd(), { includeToolInputCwd: true });
205
224
  const ctx = await loadRuntimeContext(cwd);
206
225
  const deps = createDefaultGateRuntimeDeps();
207
226
  const verdict = await evaluateGatedAction(ctx, deps, {
@@ -227,7 +246,11 @@ export async function runShellGateHook() {
227
246
  export async function runAuditHook(eventName) {
228
247
  try {
229
248
  const payload = await readStdinJson();
230
- const ctx = await loadRuntimeContext(process.cwd());
249
+ const toolName = String(payload.tool_name ?? payload.toolName ?? '');
250
+ const kind = resolveCodexGateKind(eventName, toolName);
251
+ const includeToolInputCwd = (eventName === 'PreToolUse' || eventName === 'preToolUse') && kind === 'shell';
252
+ const cwd = resolveCodexActionCwd(payload, process.cwd(), { includeToolInputCwd });
253
+ const ctx = await loadRuntimeContext(cwd);
231
254
  const deps = createDefaultGateRuntimeDeps();
232
255
  await appendObservedAudit(ctx, deps, eventName, payload);
233
256
  jsonResponse({});
@@ -0,0 +1,10 @@
1
+ export type CursorActionCwdSource = 'tool_input.working_directory' | 'cwd' | 'workspace_roots' | 'fallback';
2
+ export interface CursorActionCwdResolution {
3
+ cwd: string;
4
+ source: CursorActionCwdSource;
5
+ fromPayload: boolean;
6
+ }
7
+ export declare function resolveCursorActionCwdDetails(payload: Record<string, unknown>, fallback?: string): CursorActionCwdResolution;
8
+ export declare function isGlobalCursorHookRuntime(): boolean;
9
+ export declare function isUnsafeGlobalHookFallback(resolution: CursorActionCwdResolution, globalRuntime?: boolean): boolean;
10
+ export declare const GLOBAL_HOOK_WORKSPACE_MISSING_MESSAGE: string;
@@ -0,0 +1,58 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ function nonEmptyPathString(value) {
5
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
6
+ }
7
+ export function resolveCursorActionCwdDetails(payload, fallback = process.cwd()) {
8
+ const toolInput = payload.tool_input;
9
+ const toolInputWorkingDirectory = toolInput !== null && typeof toolInput === 'object' && !Array.isArray(toolInput)
10
+ ? nonEmptyPathString(toolInput.working_directory)
11
+ : undefined;
12
+ const topLevelCwd = nonEmptyPathString(payload.cwd);
13
+ const workspaceRoot = Array.isArray(payload.workspace_roots)
14
+ ? payload.workspace_roots.map(nonEmptyPathString).find(Boolean)
15
+ : undefined;
16
+ if (toolInputWorkingDirectory) {
17
+ return {
18
+ cwd: path.resolve(toolInputWorkingDirectory),
19
+ source: 'tool_input.working_directory',
20
+ fromPayload: true,
21
+ };
22
+ }
23
+ if (topLevelCwd) {
24
+ return {
25
+ cwd: path.resolve(topLevelCwd),
26
+ source: 'cwd',
27
+ fromPayload: true,
28
+ };
29
+ }
30
+ if (workspaceRoot) {
31
+ return {
32
+ cwd: path.resolve(workspaceRoot),
33
+ source: 'workspace_roots',
34
+ fromPayload: true,
35
+ };
36
+ }
37
+ return {
38
+ cwd: path.resolve(fallback),
39
+ source: 'fallback',
40
+ fromPayload: false,
41
+ };
42
+ }
43
+ export function isGlobalCursorHookRuntime() {
44
+ try {
45
+ const runtimePath = path.resolve(fileURLToPath(import.meta.url));
46
+ const globalRuntimeDir = path.resolve(path.join(os.homedir(), '.cursor', 'belay', 'runtime'));
47
+ return (runtimePath === globalRuntimeDir || runtimePath.startsWith(`${globalRuntimeDir}${path.sep}`));
48
+ }
49
+ catch {
50
+ return false;
51
+ }
52
+ }
53
+ export function isUnsafeGlobalHookFallback(resolution, globalRuntime = isGlobalCursorHookRuntime()) {
54
+ return !resolution.fromPayload && globalRuntime;
55
+ }
56
+ export const GLOBAL_HOOK_WORKSPACE_MISSING_MESSAGE = 'belay could not determine the workspace for this action from the Cursor hook payload. ' +
57
+ 'Open the target project workspace and retry, or run belay doctor. ' +
58
+ 'To stop global hooks: belay uninstall --scope global';
@@ -1,6 +1,8 @@
1
1
  import type { HookEntry, HooksFile } from '../../types.js';
2
- /** Legacy Belay-managed Shell preToolUse gate duplicated beforeShellExecution. */
3
- export declare function legacyManagedShellPreToolUseEntry(platform: NodeJS.Platform, hooksDir: string, repoRoot: string): HookEntry;
4
- export declare function removeLegacyManagedShellPreToolUse(hooks: HooksFile, platform: NodeJS.Platform, hooksDir: string, repoRoot: string): HooksFile;
2
+ /** Managed Shell preToolUse gate for Cursor Agent Shell tool invocations. */
3
+ export declare function managedShellPreToolUseEntry(platform: NodeJS.Platform, hooksDir: string, repoRoot: string): HookEntry;
4
+ /** @deprecated Use {@link managedShellPreToolUseEntry}. */
5
+ export declare const legacyManagedShellPreToolUseEntry: typeof managedShellPreToolUseEntry;
6
+ export declare function stripCursorHooksFile(current: HooksFile, platform: NodeJS.Platform, hooksDir: string, repoRoot: string): HooksFile;
5
7
  export declare function mergeCursorHooksFile(current: HooksFile, platform: NodeJS.Platform, hooksDir: string, repoRoot: string): HooksFile;
6
8
  export declare function hasDuplicateCursorShellGates(hooks: HooksFile, platform: NodeJS.Platform, hooksDir: string, repoRoot: string): boolean;
@@ -10,37 +10,45 @@ function mergeHookEntry(current, expected, placement) {
10
10
  }
11
11
  return [...filtered, expected];
12
12
  }
13
- /** Legacy Belay-managed Shell preToolUse gate duplicated beforeShellExecution. */
14
- export function legacyManagedShellPreToolUseEntry(platform, hooksDir, repoRoot) {
13
+ /** Managed Shell preToolUse gate for Cursor Agent Shell tool invocations. */
14
+ export function managedShellPreToolUseEntry(platform, hooksDir, repoRoot) {
15
15
  const managed = getManagedHookEntries(platform, hooksDir, repoRoot);
16
16
  const referencePreToolUse = managed.find((entry) => entry.event === 'preToolUse' && entry.definition.matcher === 'Write')?.definition;
17
17
  if (!referencePreToolUse) {
18
- throw new Error('managed hook definitions missing for legacy Shell migration');
18
+ throw new Error('managed hook definitions missing for Shell preToolUse gate');
19
19
  }
20
20
  return {
21
21
  command: referencePreToolUse.command,
22
22
  matcher: 'Shell',
23
23
  };
24
24
  }
25
- export function removeLegacyManagedShellPreToolUse(hooks, platform, hooksDir, repoRoot) {
26
- const legacy = legacyManagedShellPreToolUseEntry(platform, hooksDir, repoRoot);
27
- const preToolUse = hooks.hooks.preToolUse;
28
- if (!Array.isArray(preToolUse)) {
29
- return hooks;
30
- }
31
- return {
32
- ...hooks,
33
- hooks: {
34
- ...hooks.hooks,
35
- preToolUse: preToolUse.filter((entry) => !entryMatches(entry, legacy)),
36
- },
25
+ /** @deprecated Use {@link managedShellPreToolUseEntry}. */
26
+ export const legacyManagedShellPreToolUseEntry = managedShellPreToolUseEntry;
27
+ export function stripCursorHooksFile(current, platform, hooksDir, repoRoot) {
28
+ const next = {
29
+ version: current.version || 1,
30
+ hooks: { ...current.hooks },
37
31
  };
32
+ const managedEntries = getManagedHookEntries(platform, hooksDir, repoRoot);
33
+ for (const { event, definition } of managedEntries) {
34
+ const entries = next.hooks[event];
35
+ if (!Array.isArray(entries)) {
36
+ continue;
37
+ }
38
+ next.hooks[event] = entries.filter((entry) => !entryMatches(entry, {
39
+ command: definition.command,
40
+ matcher: definition.matcher,
41
+ }));
42
+ if (next.hooks[event].length === 0) {
43
+ delete next.hooks[event];
44
+ }
45
+ }
46
+ return next;
38
47
  }
39
48
  export function mergeCursorHooksFile(current, platform, hooksDir, repoRoot) {
40
- const withoutLegacyShell = removeLegacyManagedShellPreToolUse(current, platform, hooksDir, repoRoot);
41
49
  const next = {
42
- version: withoutLegacyShell.version || 1,
43
- hooks: { ...withoutLegacyShell.hooks },
50
+ version: current.version || 1,
51
+ hooks: { ...current.hooks },
44
52
  };
45
53
  const managedEntries = getManagedHookEntries(platform, hooksDir, repoRoot);
46
54
  for (const { event, definition } of managedEntries) {
@@ -52,10 +60,11 @@ export function mergeCursorHooksFile(current, platform, hooksDir, repoRoot) {
52
60
  return next;
53
61
  }
54
62
  export function hasDuplicateCursorShellGates(hooks, platform, hooksDir, repoRoot) {
55
- const legacy = legacyManagedShellPreToolUseEntry(platform, hooksDir, repoRoot);
63
+ const shellEntry = managedShellPreToolUseEntry(platform, hooksDir, repoRoot);
56
64
  const preToolUse = hooks.hooks.preToolUse;
57
65
  if (!Array.isArray(preToolUse)) {
58
66
  return false;
59
67
  }
60
- return preToolUse.some((entry) => entryMatches(entry, legacy));
68
+ const shellMatches = preToolUse.filter((entry) => entryMatches(entry, shellEntry));
69
+ return shellMatches.length > 1;
61
70
  }
@@ -1,3 +1,4 @@
1
+ export declare function resolveCursorActionCwd(payload: Record<string, unknown>, fallback?: string): string;
1
2
  export declare function runBeforeSubmitPromptHook(): Promise<void>;
2
3
  export declare function runShellGateHook(): Promise<void>;
3
4
  export declare function runToolGateHook(eventName: string): Promise<void>;
@@ -1,7 +1,12 @@
1
+ import path from 'node:path';
1
2
  import process from 'node:process';
3
+ import { approvalCommandMatch } from '../../core/approval.js';
4
+ import { findApprovalRepoRoots, formatAmbiguousApprovalRepoMessage, } from '../../core/approval-repo-lookup.js';
5
+ import { DEFAULT_CONFIG_V4 } from '../../core/config.js';
2
6
  import { cursorLayout } from '../layouts/cursor.js';
3
7
  import { appendObservedAudit, createDefaultGateRuntimeDeps, evaluateGatedAction, gateVerdictToCursorResponse, processApprovalPrompt, resolveGateConfig, } from '../shared/gate-runtime.js';
4
8
  import { findRepoRoot } from '../shared/repo-root.js';
9
+ import { GLOBAL_HOOK_WORKSPACE_MISSING_MESSAGE, isUnsafeGlobalHookFallback, resolveCursorActionCwdDetails, } from './cwd-resolution.js';
5
10
  async function readStdinJson() {
6
11
  const chunks = [];
7
12
  for await (const chunk of process.stdin) {
@@ -21,6 +26,38 @@ async function readStdinJson() {
21
26
  function jsonResponse(value) {
22
27
  process.stdout.write(`${JSON.stringify(value)}\n`);
23
28
  }
29
+ export function resolveCursorActionCwd(payload, fallback = process.cwd()) {
30
+ return resolveCursorActionCwdDetails(payload, fallback).cwd;
31
+ }
32
+ function resolveCursorToolActionCwdDetails(payload, fallback, eventName, toolName) {
33
+ if ((eventName === 'preToolUse' || eventName === 'PreToolUse') && toolName === 'Shell') {
34
+ return resolveCursorActionCwdDetails(payload, fallback);
35
+ }
36
+ const toolInput = payload.tool_input;
37
+ if (toolInput === null || typeof toolInput !== 'object' || Array.isArray(toolInput)) {
38
+ return resolveCursorActionCwdDetails(payload, fallback);
39
+ }
40
+ const { working_directory: _workingDirectory, ...withoutWorkingDirectory } = toolInput;
41
+ return resolveCursorActionCwdDetails({ ...payload, tool_input: withoutWorkingDirectory }, fallback);
42
+ }
43
+ function collectCandidateRepoRoots(payload, resolution) {
44
+ const roots = [];
45
+ const add = (value) => {
46
+ const resolved = path.resolve(value);
47
+ if (!roots.includes(resolved)) {
48
+ roots.push(resolved);
49
+ }
50
+ };
51
+ if (Array.isArray(payload.workspace_roots)) {
52
+ for (const root of payload.workspace_roots) {
53
+ if (typeof root === 'string' && root.trim()) {
54
+ add(root);
55
+ }
56
+ }
57
+ }
58
+ add(resolution.cwd);
59
+ return roots;
60
+ }
24
61
  async function loadRuntimeContext(cwd) {
25
62
  const repoRoot = findRepoRoot(cwd, cursorLayout);
26
63
  const configPath = cursorLayout.configPath(repoRoot);
@@ -28,6 +65,39 @@ async function loadRuntimeContext(cwd) {
28
65
  const config = await resolveGateConfig({ layout: cursorLayout, repoRoot, configPath }, deps);
29
66
  return { layout: cursorLayout, repoRoot, config, configPath };
30
67
  }
68
+ async function processApprovalPromptWithRepoFallback(ctx, deps, prompt, payload, resolution) {
69
+ const tokenPrefix = ctx.config.tokenPrefix || DEFAULT_CONFIG_V4.tokenPrefix;
70
+ const approvalId = approvalCommandMatch(prompt, tokenPrefix);
71
+ const result = await processApprovalPrompt(ctx, deps, prompt);
72
+ if (result.continue || !approvalId) {
73
+ return result;
74
+ }
75
+ const notFound = result.user_message?.includes('not found') ||
76
+ result.user_message?.includes('Belay approval not found');
77
+ if (!notFound) {
78
+ return result;
79
+ }
80
+ const candidates = collectCandidateRepoRoots(payload, resolution).filter((root) => root !== ctx.repoRoot);
81
+ if (candidates.length === 0) {
82
+ return result;
83
+ }
84
+ const lookup = await findApprovalRepoRoots({
85
+ approvalId,
86
+ candidateRepoRoots: candidates,
87
+ adapter: 'cursor',
88
+ });
89
+ if (lookup.status === 'ambiguous') {
90
+ return {
91
+ continue: false,
92
+ user_message: formatAmbiguousApprovalRepoMessage(lookup.repoRoots),
93
+ };
94
+ }
95
+ if (lookup.status !== 'found') {
96
+ return result;
97
+ }
98
+ const retryCtx = await loadRuntimeContext(lookup.repoRoot);
99
+ return processApprovalPrompt(retryCtx, deps, prompt);
100
+ }
31
101
  function isSubagentEvent(payload, eventName) {
32
102
  return eventName === 'subagentStart' || payload.subagent_type !== undefined;
33
103
  }
@@ -38,9 +108,17 @@ export async function runBeforeSubmitPromptHook() {
38
108
  try {
39
109
  const payload = await readStdinJson();
40
110
  const prompt = String(payload.prompt ?? '');
41
- const ctx = await loadRuntimeContext(process.cwd());
111
+ const resolution = resolveCursorActionCwdDetails(payload);
112
+ if (isUnsafeGlobalHookFallback(resolution)) {
113
+ jsonResponse({
114
+ continue: false,
115
+ user_message: GLOBAL_HOOK_WORKSPACE_MISSING_MESSAGE,
116
+ });
117
+ return;
118
+ }
119
+ const ctx = await loadRuntimeContext(resolution.cwd);
42
120
  const deps = createDefaultGateRuntimeDeps();
43
- const result = await processApprovalPrompt(ctx, deps, prompt);
121
+ const result = await processApprovalPromptWithRepoFallback(ctx, deps, prompt, payload, resolution);
44
122
  jsonResponse({
45
123
  continue: result.continue,
46
124
  ...(result.user_message ? { user_message: result.user_message } : {}),
@@ -58,13 +136,22 @@ export async function runShellGateHook() {
58
136
  try {
59
137
  const payload = await readStdinJson();
60
138
  const command = String(payload.command ?? '').trim();
61
- const cwd = String(payload.cwd ?? process.cwd()).trim() || process.cwd();
139
+ const resolution = resolveCursorActionCwdDetails(payload);
140
+ if (isUnsafeGlobalHookFallback(resolution)) {
141
+ jsonResponse({
142
+ permission: 'deny',
143
+ user_message: GLOBAL_HOOK_WORKSPACE_MISSING_MESSAGE,
144
+ });
145
+ return;
146
+ }
147
+ const cwd = resolution.cwd;
62
148
  const ctx = await loadRuntimeContext(cwd);
63
149
  const deps = createDefaultGateRuntimeDeps();
64
150
  const verdict = await evaluateGatedAction(ctx, deps, {
65
151
  kind: 'shell',
66
152
  cwd,
67
153
  command,
154
+ payload,
68
155
  sourceEvent: 'beforeShellExecution',
69
156
  });
70
157
  jsonResponse(gateVerdictToCursorResponse(verdict));
@@ -79,10 +166,18 @@ export async function runShellGateHook() {
79
166
  export async function runToolGateHook(eventName) {
80
167
  try {
81
168
  const payload = await readStdinJson();
82
- const cwd = process.cwd();
169
+ const toolName = String(payload.tool_name ?? '');
170
+ const resolution = resolveCursorToolActionCwdDetails(payload, process.cwd(), eventName, toolName);
171
+ if (isUnsafeGlobalHookFallback(resolution)) {
172
+ jsonResponse({
173
+ permission: 'deny',
174
+ user_message: GLOBAL_HOOK_WORKSPACE_MISSING_MESSAGE,
175
+ });
176
+ return;
177
+ }
178
+ const cwd = resolution.cwd;
83
179
  const ctx = await loadRuntimeContext(cwd);
84
180
  const deps = createDefaultGateRuntimeDeps();
85
- const toolName = String(payload.tool_name ?? '');
86
181
  if (isSubagentEvent(payload, eventName)) {
87
182
  const verdict = await evaluateGatedAction(ctx, deps, {
88
183
  kind: 'subagent',
@@ -137,7 +232,13 @@ export async function runToolGateHook(eventName) {
137
232
  export async function runAuditHook(eventName) {
138
233
  try {
139
234
  const payload = await readStdinJson();
140
- const ctx = await loadRuntimeContext(process.cwd());
235
+ const toolName = String(payload.tool_name ?? '');
236
+ const resolution = resolveCursorToolActionCwdDetails(payload, process.cwd(), eventName, toolName);
237
+ if (isUnsafeGlobalHookFallback(resolution)) {
238
+ jsonResponse({});
239
+ return;
240
+ }
241
+ const ctx = await loadRuntimeContext(resolution.cwd);
141
242
  const deps = createDefaultGateRuntimeDeps();
142
243
  await appendObservedAudit(ctx, deps, eventName, payload);
143
244
  jsonResponse({});
@@ -7,7 +7,7 @@ import { buildReplayHint, buildRetryInstructionForConfig, canAutoReplay, getExec
7
7
  import { claimApprovedForReplay, gateApprovalStoreFromDeps, recordApproval, } from '../../core/approval-service.js';
8
8
  import { issueApprovalToken } from '../../core/approval-token.js';
9
9
  import { buildAuditActionSnapshot, buildAuditReplayContext, } from '../../core/audit-replay-context.js';
10
- import { approvalCorrelationId, serializeAuditRecordV3 } from '../../core/audit-serialize.js';
10
+ import { approvalCorrelationId, serializeAuditRecordV3, toolInvocationCorrelationId, } from '../../core/audit-serialize.js';
11
11
  import { boundedUtf8Tail } from '../../core/bounded-output.js';
12
12
  import { mutateApprovalStateWithRetry } from '../../core/capability/approval-state-mutation.js';
13
13
  import { APPROVAL_STATE_VERSION_V3 } from '../../core/capability/approval-v3.js';
@@ -36,7 +36,7 @@ import { extractJudgeFallbackReason, formatJudgeInfrastructureDenyMessage, infer
36
36
  import { notifyDeny } from '../../core/notify.js';
37
37
  import { canonicalPath } from '../../core/path-utils.js';
38
38
  import { recoveryFailClosedResult, recoveryFailReasonFromSkip, } from '../../core/recovery/fail-closed.js';
39
- import { fingerprintReplayPayload } from '../../core/replay-scrub.js';
39
+ import { fingerprintReplayPayload, redactToolInvocationId } from '../../core/replay-scrub.js';
40
40
  import { FILE_CHECKPOINT_ISOLATION_UNAVAILABLE, isTransactionalEligible, runTransactionalExecution, TRANSACTIONAL_ALREADY_APPLIED, TRANSACTIONAL_APPROVAL_BYPASS_REASONS, } from '../../core/transactional/index.js';
41
41
  import { buildAuditProvenanceFields } from '../../runtime-provenance.js';
42
42
  import { egressStatus } from '../../services/egress-service.js';
@@ -616,6 +616,9 @@ export async function evaluateGatedAction(ctx, deps, params) {
616
616
  event: resolveGateAuditEvent(sourceEvent, params.kind),
617
617
  sourceEvent,
618
618
  kind: params.kind,
619
+ ...(typeof params.payload?.tool_use_id === 'string'
620
+ ? { toolInvocationCorrelationId: toolInvocationCorrelationId(params.payload.tool_use_id) }
621
+ : {}),
619
622
  fingerprint: verdict.fingerprint,
620
623
  verdict: verdict.verdict,
621
624
  reason: verdict.reason,
@@ -779,6 +782,9 @@ export async function evaluateGatedAction(ctx, deps, params) {
779
782
  const scrubbedPayload = fingerprintReplayPayload(params.kind, params.payload, scrubOpts);
780
783
  return gateDecisionToVerdict(ctx, deps, params.kind, result, {
781
784
  sourceEvent: params.sourceEvent,
785
+ toolInvocationCorrelationId: typeof params.payload?.tool_use_id === 'string'
786
+ ? toolInvocationCorrelationId(params.payload.tool_use_id)
787
+ : undefined,
782
788
  predictedAssessment,
783
789
  observedAssessment,
784
790
  transactionalLayer,
@@ -904,6 +910,9 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
904
910
  event: auditEvent,
905
911
  sourceEvent,
906
912
  kind,
913
+ ...(auditExtras.toolInvocationCorrelationId
914
+ ? { toolInvocationCorrelationId: auditExtras.toolInvocationCorrelationId }
915
+ : {}),
907
916
  fingerprint: result.fingerprint,
908
917
  summary: result.normalizedCommand ?? result.summary ?? '',
909
918
  assessment: result.assessment,
@@ -1398,12 +1407,26 @@ export function gateVerdictToCodexUserPromptResponse(verdict) {
1398
1407
  };
1399
1408
  }
1400
1409
  export async function appendObservedAudit(ctx, deps, eventName, payload) {
1410
+ const rawToolUseId = typeof payload.tool_use_id === 'string' ? payload.tool_use_id : undefined;
1411
+ const summaryPayload = redactToolInvocationId(payload, rawToolUseId);
1401
1412
  await deps.appendAudit(ctx, {
1402
1413
  event: eventName,
1403
1414
  kind: 'audit',
1404
1415
  verdict: 'allow',
1405
1416
  reason: 'observed',
1406
- summary: canonicalStringify(payload),
1417
+ ...(rawToolUseId
1418
+ ? { toolInvocationCorrelationId: toolInvocationCorrelationId(rawToolUseId) }
1419
+ : {}),
1420
+ ...(typeof summaryPayload.tool_name === 'string' ? { toolName: summaryPayload.tool_name } : {}),
1421
+ ...(typeof summaryPayload.failure_type === 'string'
1422
+ ? { failureType: summaryPayload.failure_type }
1423
+ : {}),
1424
+ ...(typeof summaryPayload.error_message === 'string'
1425
+ ? { errorMessage: summaryPayload.error_message }
1426
+ : {}),
1427
+ ...(typeof payload.duration === 'number' ? { durationMs: payload.duration } : {}),
1428
+ ...(typeof payload.is_interrupt === 'boolean' ? { isInterrupt: payload.is_interrupt } : {}),
1429
+ summary: canonicalStringify(summaryPayload),
1407
1430
  });
1408
1431
  }
1409
1432
  export { GateNormalizationError };
@@ -1,10 +1,29 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import path from 'node:path';
3
+ function belayConfigPath(current, adapterName) {
4
+ if (adapterName === 'cursor') {
5
+ return path.join(current, '.cursor', 'belay.config.json');
6
+ }
7
+ if (adapterName === 'claude') {
8
+ return path.join(current, '.claude', 'belay.config.json');
9
+ }
10
+ return path.join(current, '.codex', 'belay.config.json');
11
+ }
12
+ function markerMatches(current, marker, layout) {
13
+ const markerPath = path.join(current, marker);
14
+ if (!existsSync(markerPath)) {
15
+ return false;
16
+ }
17
+ if (marker === '.cursor' || marker === '.claude' || marker === '.codex') {
18
+ return existsSync(belayConfigPath(current, layout.name));
19
+ }
20
+ return true;
21
+ }
3
22
  export function findRepoRoot(startPath, layout) {
4
23
  let current = path.resolve(startPath);
5
24
  while (true) {
6
25
  for (const marker of layout.repoRootMarkers) {
7
- if (existsSync(path.join(current, marker))) {
26
+ if (markerMatches(current, marker, layout)) {
8
27
  return current;
9
28
  }
10
29
  }