@guilz-dev/belay 0.9.4 → 0.10.1

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 (62) hide show
  1. package/README.md +3 -2
  2. package/dist/adapters/claude/runtime-entry.js +5 -18
  3. package/dist/adapters/codex/runtime-entry.js +10 -11
  4. package/dist/adapters/cursor/hook-dispatch-entry.d.ts +3 -0
  5. package/dist/adapters/cursor/hook-dispatch-entry.js +11 -0
  6. package/dist/adapters/cursor/hook-router.d.ts +1 -0
  7. package/dist/adapters/cursor/hook-router.js +39 -9
  8. package/dist/adapters/cursor/hook-routing-health.d.ts +1 -0
  9. package/dist/adapters/cursor/hook-routing-health.js +53 -0
  10. package/dist/adapters/cursor/hooks.d.ts +1 -1
  11. package/dist/adapters/cursor/hooks.js +16 -5
  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/runtime-entry.d.ts +2 -2
  15. package/dist/adapters/cursor/runtime-entry.js +35 -10
  16. package/dist/adapters/shared/gate-runtime.d.ts +0 -2
  17. package/dist/adapters/shared/gate-runtime.js +12 -52
  18. package/dist/bundle/claude-runtime.mjs +1083 -669
  19. package/dist/bundle/codex-runtime.mjs +1092 -698
  20. package/dist/bundle/cursor-dispatcher.mjs +119 -28
  21. package/dist/bundle/cursor-runtime.mjs +1132 -677
  22. package/dist/cli.js +53 -4
  23. package/dist/commands/approval-token.d.ts +10 -0
  24. package/dist/commands/approval-token.js +26 -0
  25. package/dist/commands/audit.d.ts +2 -1
  26. package/dist/commands/audit.js +2 -2
  27. package/dist/commands/config.d.ts +1 -1
  28. package/dist/commands/config.js +28 -2
  29. package/dist/commands/doctor.js +14 -54
  30. package/dist/commands/dogfood-check.d.ts +3 -0
  31. package/dist/commands/dogfood-check.js +116 -0
  32. package/dist/commands/dogfood.d.ts +1 -0
  33. package/dist/commands/dogfood.js +4 -3
  34. package/dist/commands/judge.js +2 -2
  35. package/dist/commands/recovery-checkpoints.js +2 -11
  36. package/dist/config-io.d.ts +1 -0
  37. package/dist/config-io.js +8 -1
  38. package/dist/core/capability/index.d.ts +1 -1
  39. package/dist/core/capability/index.js +1 -1
  40. package/dist/core/capability/policy-engine.d.ts +19 -0
  41. package/dist/core/capability/policy-engine.js +31 -0
  42. package/dist/core/classify-tool.js +372 -148
  43. package/dist/core/config.js +2 -2
  44. package/dist/core/dogfood-environment.d.ts +8 -0
  45. package/dist/core/dogfood-environment.js +55 -0
  46. package/dist/core/effect-ir/shell-lower/decoders/belay.js +12 -0
  47. package/dist/core/egress-approval.js +0 -18
  48. package/dist/core/gate-engine.js +2 -9
  49. package/dist/core/notify.d.ts +8 -2
  50. package/dist/core/notify.js +63 -7
  51. package/dist/core/recovery/operator-guidance.js +4 -4
  52. package/dist/core/repo-config-trust.d.ts +31 -0
  53. package/dist/core/repo-config-trust.js +152 -0
  54. package/dist/corpus/benign-probe-cores.d.ts +1 -1
  55. package/dist/corpus/benign-probe-cores.js +0 -1
  56. package/dist/defaults.js +0 -25
  57. package/dist/installer/scope-config.js +2 -2
  58. package/dist/installer.js +4 -4
  59. package/dist/types.d.ts +19 -0
  60. package/dist/version.d.ts +1 -1
  61. package/dist/version.js +1 -1
  62. package/package.json +5 -1
package/README.md CHANGED
@@ -342,7 +342,7 @@ Notable settings:
342
342
  uses `git_worktree`; dirty Git and non-Git directories use `file_checkpoint` when separately
343
343
  enabled (`policy.transactional.fileCheckpoint.enabled` and, for non-Git roots,
344
344
  `allowNonGit: true`) with an attested workspace-isolating boundary. Restore is conflict-checked
345
- 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,
346
346
  databases, processes, and repo-external effects are outside this guarantee.
347
347
  - **Cloud judge** — configure with `belay config` (interactive) or `belay config set judge.providerId <id>`.
348
348
  Providers: `ollama`, `codex`, `claude`, `cursor`. **Provider** is the vendor/service
@@ -390,7 +390,8 @@ belay recover [advice] [--command "rm important.ts"] # advisory candidates only
390
390
  belay recover status # checkpoint backend, eligibility, and state counts
391
391
  belay recover list # proven repo-local recovery points
392
392
  belay recover show <checkpoint-id>
393
- 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
394
395
  belay explain -- <shell-command> # inspect a verdict
395
396
  belay explain --kind subagent -- "deploy to production"
396
397
  belay explain --kind tool --tool Write -- .env
@@ -209,25 +209,12 @@ export async function runToolGateHook(_eventName) {
209
209
  return;
210
210
  }
211
211
  if (!mappedKind) {
212
- const verdict = claudeFallbackToolVerdict({
213
- toolName,
214
- payload,
215
- repoRoot: ctx.repoRoot,
216
- reason: 'unmapped_tool',
217
- mode: ctx.config.mode,
218
- userMessage: 'belay does not recognize this tool action. Run belay doctor, then retry.',
219
- agentMessage: 'Belay denied this action because the tool could not be normalized.',
220
- });
221
- await deps.appendAudit(ctx, {
222
- event: 'preToolUse',
212
+ const verdict = await evaluateGatedAction(ctx, deps, {
223
213
  kind: 'tool',
224
- verdict: 'deny_pending_approval',
225
- reason: 'unmapped_tool',
226
- mode: ctx.config.mode,
227
- wouldBlock: true,
228
- permission: 'deny',
229
- summary: toolName,
230
- ...effectPlanAuditFields(verdict.effectPlan),
214
+ cwd,
215
+ payload,
216
+ toolName,
217
+ sourceEvent: 'PreToolUse',
231
218
  });
232
219
  jsonResponse(gateVerdictToClaudePreToolUseResponse(verdict));
233
220
  return;
@@ -1,7 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import process from 'node:process';
3
3
  import { codexLayout } from '../layouts/codex.js';
4
- import { appendObservedAudit, createDefaultGateRuntimeDeps, evaluateGatedAction, gateUnmappedToolVerdict, gateVerdictToCodexPreToolUseResponse, gateVerdictToCodexUserPromptResponse, processApprovalPrompt, resolveGateConfig, } from '../shared/gate-runtime.js';
4
+ import { appendObservedAudit, createDefaultGateRuntimeDeps, evaluateGatedAction, gateVerdictToCodexPreToolUseResponse, gateVerdictToCodexUserPromptResponse, processApprovalPrompt, resolveGateConfig, } from '../shared/gate-runtime.js';
5
5
  import { findRepoRoot } from '../shared/repo-root.js';
6
6
  async function readStdinJson() {
7
7
  const chunks = [];
@@ -169,7 +169,7 @@ export async function runBeforeSubmitPromptHook() {
169
169
  }
170
170
  // Codex routes all PreToolUse through this unified handler (matcher ".*"), since the exact
171
171
  // shell tool name is not yet confirmed. SubagentStart is also routed to this handler.
172
- // Unmapped tools/events ask with pending approval (R39) to avoid silent bypass without hard block.
172
+ // Unmapped tools use the shared effect-based classifier (no tool-name denylist).
173
173
  export async function runToolGateHook(eventName) {
174
174
  try {
175
175
  const payload = await readStdinJson();
@@ -180,15 +180,14 @@ export async function runToolGateHook(eventName) {
180
180
  const ctx = await loadRuntimeContext(cwd);
181
181
  const deps = createDefaultGateRuntimeDeps();
182
182
  if (!kind) {
183
- // Unmapped Codex tool. Policy-driven: default 'deny' asks with pending approval (R39).
184
- // 'allow' is the opt-out pass the tool but record it to audit for vocabulary learning.
185
- const policy = ctx.config.policy?.codexUnmappedTool ?? 'deny';
186
- if (policy === 'allow') {
187
- await appendObservedAudit(ctx, deps, eventName, payload);
188
- jsonResponse({});
189
- return;
190
- }
191
- const verdict = await gateUnmappedToolVerdict(ctx, deps, toolName, payload);
183
+ const normalizedPayload = normalizeCodexToolPayload('tool', payload);
184
+ const verdict = await evaluateGatedAction(ctx, deps, {
185
+ kind: 'tool',
186
+ cwd,
187
+ payload: normalizedPayload,
188
+ toolName,
189
+ sourceEvent: eventName,
190
+ });
192
191
  jsonResponse(gateVerdictToCodexPreToolUseResponse(verdict));
193
192
  return;
194
193
  }
@@ -4,4 +4,7 @@ export interface DispatchCursorHookParams {
4
4
  kind: CursorHookKind;
5
5
  eventName: string;
6
6
  }
7
+ type CursorResponse = Record<string, unknown>;
8
+ declare function dispatchCursorHookResponse(params: DispatchCursorHookParams): Promise<CursorResponse>;
7
9
  export declare function dispatchCursorHook(params: DispatchCursorHookParams): Promise<void>;
10
+ export { dispatchCursorHookResponse };
@@ -97,7 +97,17 @@ async function dispatchCursorHookResponse(params) {
97
97
  if (!input.ok) {
98
98
  return failClosedResponse(params.kind, 'belay received malformed Cursor hook input.');
99
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
+ }
100
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
+ }
101
111
  return neutralResponse(params.kind);
102
112
  }
103
113
  try {
@@ -124,3 +134,4 @@ export async function dispatchCursorHook(params) {
124
134
  const response = await dispatchCursorHookResponse(params);
125
135
  process.stdout.write(`${JSON.stringify(response)}\n`);
126
136
  }
137
+ export { dispatchCursorHookResponse };
@@ -14,6 +14,7 @@ export type CursorHookRoute = {
14
14
  decision: 'fail_closed';
15
15
  message: string;
16
16
  };
17
+ export declare const CURSOR_GLOBAL_SENTINEL_BLOCK_MESSAGE = "belay project hook owner is unavailable; the global sentinel blocked this action.";
17
18
  export interface RouteCursorHookParams {
18
19
  origin: CursorHookOrigin;
19
20
  kind: CursorHookKind;
@@ -1,7 +1,9 @@
1
1
  import { accessSync, constants, existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { resolveCursorActionCwdDetails } from './cwd-resolution.js';
4
+ import { isTrustedCursorRoutingConfig } from './routing-config-trust.js';
4
5
  import { cursorRoutingConfigPath, cursorRoutingHooksDir, cursorRoutingHooksSettingsPath, cursorRoutingRuntimeDir, findCursorRoutingRepoRoot, } from './routing-layout.js';
6
+ export const CURSOR_GLOBAL_SENTINEL_BLOCK_MESSAGE = 'belay project hook owner is unavailable; the global sentinel blocked this action.';
5
7
  function canonicalExistingPath(value) {
6
8
  if (!existsSync(value)) {
7
9
  return undefined;
@@ -103,18 +105,24 @@ function commandInvokesProjectHook(command, runnerPath, kind, eventName) {
103
105
  ].join(' ');
104
106
  return Buffer.from(encoded, 'base64').toString('utf16le') === expected;
105
107
  }
106
- function expectedMatcher(eventName, payload) {
108
+ function expectedMatchers(eventName, payload) {
107
109
  if (eventName === 'preToolUse') {
108
- return typeof payload.tool_name === 'string' ? payload.tool_name : undefined;
110
+ // Managed preToolUse is intentionally unfiltered. Keep the legacy matcher variant so older
111
+ // installed hooks still route through the same owner until upgrade.
112
+ const values = [undefined];
113
+ if (typeof payload.tool_name === 'string') {
114
+ values.push(payload.tool_name);
115
+ }
116
+ return values;
109
117
  }
110
118
  if (eventName === 'subagentStart') {
111
- return typeof payload.subagent_type === 'string' ? payload.subagent_type : undefined;
119
+ return [typeof payload.subagent_type === 'string' ? payload.subagent_type : undefined];
112
120
  }
113
- return undefined;
121
+ return [undefined];
114
122
  }
115
123
  function hasManagedProjectHookEntry(repoRoot, runnerPath, params) {
116
124
  const eventName = eventNameFor(params);
117
- const matcher = expectedMatcher(eventName, params.payload);
125
+ const matchers = new Set(expectedMatchers(eventName, params.payload));
118
126
  let parsed;
119
127
  try {
120
128
  parsed = JSON.parse(readFileSync(cursorRoutingHooksSettingsPath(repoRoot), 'utf8'));
@@ -139,7 +147,7 @@ function hasManagedProjectHookEntry(repoRoot, runnerPath, params) {
139
147
  }
140
148
  const definition = entry;
141
149
  return (definition.failClosed === true &&
142
- definition.matcher === matcher &&
150
+ matchers.has(definition.matcher) &&
143
151
  typeof definition.command === 'string' &&
144
152
  commandInvokesProjectHook(definition.command, runnerPath, params.kind, eventName));
145
153
  });
@@ -155,6 +163,11 @@ function hasMatchingProjectShim(repoRoot, kind) {
155
163
  return (source.includes("from '../belay/runtime/dispatcher.mjs'") &&
156
164
  source.includes(`origin: ${JSON.stringify({ scope: 'project', repoRoot })}`));
157
165
  }
166
+ function hasProjectOwnerInstallation(repoRoot) {
167
+ const hooksDir = cursorRoutingHooksDir(repoRoot);
168
+ return Boolean(selectedRunnerPath(hooksDir) &&
169
+ isRegularFile(path.join(cursorRoutingRuntimeDir(repoRoot), 'dispatcher.mjs')));
170
+ }
158
171
  function hasCallableProjectOwner(repoRoot, params) {
159
172
  const hooksDir = cursorRoutingHooksDir(repoRoot);
160
173
  const runnerPath = selectedRunnerPath(hooksDir);
@@ -198,6 +211,10 @@ function selectedActionPath(payload) {
198
211
  }
199
212
  return undefined;
200
213
  }
214
+ function hasRoutingRootMarker(dir) {
215
+ return (existsSync(path.join(dir, '.git')) ||
216
+ (existsSync(path.join(dir, '.cursor')) && existsSync(cursorRoutingConfigPath(dir))));
217
+ }
201
218
  function readInstallScope(repoRoot) {
202
219
  const configPath = cursorRoutingConfigPath(repoRoot);
203
220
  let source;
@@ -213,7 +230,13 @@ function readInstallScope(repoRoot) {
213
230
  typeof parsed === 'object' &&
214
231
  !Array.isArray(parsed) &&
215
232
  parsed.installScope === 'global') {
216
- return 'global';
233
+ if (isTrustedCursorRoutingConfig(repoRoot, parsed)) {
234
+ return 'global';
235
+ }
236
+ // Untrusted global scope still routes through the global owner when no project
237
+ // installation exists. Otherwise a global-only workspace is blocked by the sentinel
238
+ // even though ADR-008 assigns ownership to the User/global installation.
239
+ return hasProjectOwnerInstallation(repoRoot) ? 'project' : 'global';
217
240
  }
218
241
  }
219
242
  catch {
@@ -233,10 +256,17 @@ export function routeCursorHook(params) {
233
256
  if (!canonicalCwd) {
234
257
  return { decision: 'fail_closed', message: 'belay could not determine the workspace.' };
235
258
  }
236
- const repoRoot = canonicalExistingPath(findCursorRoutingRepoRoot(canonicalCwd));
259
+ const discoveredRepoRoot = findCursorRoutingRepoRoot(canonicalCwd);
260
+ const repoRoot = canonicalExistingPath(discoveredRepoRoot);
237
261
  if (!repoRoot) {
238
262
  return { decision: 'neutral' };
239
263
  }
264
+ // If repository discovery fell back to the action directory without finding a marker,
265
+ // keep the global source neutral instead of treating an unreadable/non-repo directory
266
+ // as a broken project owner.
267
+ if (repoRoot === canonicalCwd && !hasRoutingRootMarker(repoRoot)) {
268
+ return { decision: 'neutral' };
269
+ }
240
270
  const installScope = readInstallScope(repoRoot);
241
271
  if (installScope === 'missing') {
242
272
  return { decision: 'neutral' };
@@ -256,7 +286,7 @@ export function routeCursorHook(params) {
256
286
  ? { decision: 'neutral' }
257
287
  : {
258
288
  decision: 'fail_closed',
259
- message: 'belay project hook owner is unavailable; the global sentinel blocked this action.',
289
+ message: `${CURSOR_GLOBAL_SENTINEL_BLOCK_MESSAGE} Run belay doctor from this repository to diagnose hook routing.`,
260
290
  };
261
291
  }
262
292
  const complete = callableProjectOwner && isRegularFile(path.join(cursorRoutingRuntimeDir(repoRoot), 'core.mjs'));
@@ -0,0 +1 @@
1
+ export declare function cursorHookRoutingIssues(repoRoot: string): string[];
@@ -0,0 +1,53 @@
1
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
2
+ import { CURSOR_GLOBAL_SENTINEL_BLOCK_MESSAGE, routeCursorHook, } from './hook-router.js';
3
+ import { cursorRoutingConfigPath } from './routing-layout.js';
4
+ const SENTINEL_KINDS = ['before-submit', 'shell-gate'];
5
+ function canonicalRepoRoot(repoRoot) {
6
+ try {
7
+ return realpathSync(repoRoot);
8
+ }
9
+ catch {
10
+ return repoRoot;
11
+ }
12
+ }
13
+ function configuredInstallScope(repoRoot) {
14
+ let parsed;
15
+ try {
16
+ parsed = JSON.parse(readFileSync(cursorRoutingConfigPath(repoRoot), 'utf8'));
17
+ }
18
+ catch {
19
+ return undefined;
20
+ }
21
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
22
+ return undefined;
23
+ }
24
+ return parsed.installScope === 'global' ? 'global' : 'project';
25
+ }
26
+ function upgradeCommand(repoRoot, installScope) {
27
+ return `belay upgrade --scope ${installScope} --target ${JSON.stringify(repoRoot)}`;
28
+ }
29
+ export function cursorHookRoutingIssues(repoRoot) {
30
+ if (!existsSync(cursorRoutingConfigPath(repoRoot))) {
31
+ return [];
32
+ }
33
+ const canonicalRoot = canonicalRepoRoot(repoRoot);
34
+ const payload = { cwd: canonicalRoot, workspace_roots: [canonicalRoot] };
35
+ const installScope = configuredInstallScope(repoRoot) ?? 'project';
36
+ const issues = [];
37
+ for (const kind of SENTINEL_KINDS) {
38
+ const route = routeCursorHook({
39
+ origin: { scope: 'global' },
40
+ kind,
41
+ payload,
42
+ });
43
+ if (route.decision !== 'fail_closed') {
44
+ continue;
45
+ }
46
+ if (route.message.includes(CURSOR_GLOBAL_SENTINEL_BLOCK_MESSAGE)) {
47
+ issues.push(`Global Cursor sentinel would block ${kind} in this workspace. Run ${upgradeCommand(repoRoot, installScope)}; if the config was edited manually, also run belay config trust --target ${JSON.stringify(repoRoot)}.`);
48
+ continue;
49
+ }
50
+ issues.push(`Global Cursor sentinel would block ${kind}: ${route.message}`);
51
+ }
52
+ return issues;
53
+ }
@@ -1,5 +1,5 @@
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;
@@ -16,13 +16,24 @@ function legacyManagedEntries(platform, hooksDir, repoRoot) {
16
16
  entries.push(...getManagedHookEntries(platform, canonicalHooksDir, repoRoot, 'legacy-absolute'));
17
17
  }
18
18
  }
19
- return entries;
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
+ ];
20
29
  }
21
30
  function variantsForDefinition(legacyEntries, event, definition) {
22
31
  return [
23
32
  definition,
24
33
  ...legacyEntries
25
- .filter((entry) => entry.event === event && entry.definition.matcher === definition.matcher)
34
+ .filter((entry) => entry.event === event &&
35
+ (entry.definition.matcher === definition.matcher ||
36
+ (event === 'preToolUse' && definition.matcher === undefined)))
26
37
  .map((entry) => entry.definition),
27
38
  ];
28
39
  }
@@ -34,10 +45,10 @@ function mergeHookEntry(current, expected, managedVariants, placement) {
34
45
  }
35
46
  return [...filtered, expected];
36
47
  }
37
- /** Managed Shell preToolUse gate for Cursor Agent Shell tool invocations. */
48
+ /** Legacy managed Shell preToolUse gate matcher (upgrade/uninstall migration only). */
38
49
  export function managedShellPreToolUseEntry(platform, hooksDir, repoRoot) {
39
50
  const managed = getManagedHookEntries(platform, hooksDir, repoRoot);
40
- 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;
41
52
  if (!referencePreToolUse) {
42
53
  throw new Error('managed hook definitions missing for Shell preToolUse gate');
43
54
  }
@@ -91,7 +102,7 @@ export function legacyManagedShellPreToolUseVariants(platform, hooksDir, repoRoo
91
102
  const legacyEntries = legacyManagedEntries(platform, hooksDir, repoRoot);
92
103
  const commands = new Set([shellEntry.command]);
93
104
  for (const entry of legacyEntries) {
94
- if (entry.event === 'preToolUse' && entry.definition.matcher === 'Write') {
105
+ if (entry.event === 'preToolUse') {
95
106
  commands.add(entry.definition.command);
96
107
  }
97
108
  }
@@ -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
+ }
@@ -12,10 +12,10 @@ export declare function handleShellGateHook(payload: Record<string, unknown>): P
12
12
  export declare function runShellGateHook(): Promise<void>;
13
13
  export declare function handleToolGateHook(eventName: string, payload: Record<string, unknown>): Promise<import("../../core/gate-contract.js").GatePermissionResponse | {
14
14
  permission: string;
15
- user_message: string;
15
+ user_message?: undefined;
16
16
  } | {
17
17
  permission: string;
18
- user_message?: undefined;
18
+ user_message: string;
19
19
  }>;
20
20
  export declare function runToolGateHook(eventName: string): Promise<void>;
21
21
  export declare function handleAuditHook(eventName: string, payload: Record<string, unknown>): Promise<{}>;
@@ -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,6 +105,9 @@ function isSubagentEvent(payload, eventName) {
104
105
  function isFileMutationTool(toolName) {
105
106
  return toolName === 'Write' || toolName === 'StrReplace' || toolName === 'Delete';
106
107
  }
108
+ function isCursorPreToolUseEvent(eventName) {
109
+ return eventName === 'preToolUse' || eventName === 'PreToolUse';
110
+ }
107
111
  export async function handleBeforeSubmitPromptHook(payload) {
108
112
  try {
109
113
  const prompt = String(payload.prompt ?? '');
@@ -123,7 +127,13 @@ export async function handleBeforeSubmitPromptHook(payload) {
123
127
  ...(result.replay ? { replay: result.replay } : {}),
124
128
  };
125
129
  }
126
- catch {
130
+ catch (error) {
131
+ if (isRepoConfigTrustError(error)) {
132
+ return {
133
+ continue: false,
134
+ user_message: error.message,
135
+ };
136
+ }
127
137
  return {
128
138
  continue: false,
129
139
  user_message: 'belay failed while processing approval state. Run belay doctor, then retry.',
@@ -155,7 +165,13 @@ export async function handleShellGateHook(payload) {
155
165
  });
156
166
  return gateVerdictToCursorResponse(verdict);
157
167
  }
158
- catch {
168
+ catch (error) {
169
+ if (isRepoConfigTrustError(error)) {
170
+ return {
171
+ permission: 'deny',
172
+ user_message: error.message,
173
+ };
174
+ }
159
175
  return {
160
176
  permission: 'deny',
161
177
  user_message: 'belay failed while classifying this shell command. Run belay doctor, then retry.',
@@ -168,6 +184,9 @@ export async function runShellGateHook() {
168
184
  export async function handleToolGateHook(eventName, payload) {
169
185
  try {
170
186
  const toolName = String(payload.tool_name ?? '');
187
+ if (isCursorPreToolUseEvent(eventName) && toolName === 'Shell') {
188
+ return { permission: 'allow' };
189
+ }
171
190
  const resolution = resolveCursorToolActionCwdDetails(payload, process.cwd(), eventName, toolName);
172
191
  if (isUnsafeGlobalHookFallback(resolution)) {
173
192
  return {
@@ -187,9 +206,9 @@ export async function handleToolGateHook(eventName, payload) {
187
206
  });
188
207
  return gateVerdictToCursorResponse(verdict);
189
208
  }
190
- if (toolName === 'Shell') {
209
+ if (isFileMutationTool(toolName)) {
191
210
  const verdict = await evaluateGatedAction(ctx, deps, {
192
- kind: 'shell',
211
+ kind: 'tool',
193
212
  cwd,
194
213
  payload,
195
214
  toolName,
@@ -197,28 +216,34 @@ export async function handleToolGateHook(eventName, payload) {
197
216
  });
198
217
  return gateVerdictToCursorResponse(verdict);
199
218
  }
200
- if (isFileMutationTool(toolName)) {
219
+ if (payload.tool_name === 'Task') {
201
220
  const verdict = await evaluateGatedAction(ctx, deps, {
202
- kind: 'tool',
221
+ kind: 'subagent',
203
222
  cwd,
204
223
  payload,
205
- toolName,
206
224
  sourceEvent: eventName,
207
225
  });
208
226
  return gateVerdictToCursorResponse(verdict);
209
227
  }
210
- if (payload.tool_name === 'Task') {
228
+ if (isCursorPreToolUseEvent(eventName)) {
211
229
  const verdict = await evaluateGatedAction(ctx, deps, {
212
- kind: 'subagent',
230
+ kind: 'tool',
213
231
  cwd,
214
232
  payload,
233
+ toolName,
215
234
  sourceEvent: eventName,
216
235
  });
217
236
  return gateVerdictToCursorResponse(verdict);
218
237
  }
219
238
  return { permission: 'allow' };
220
239
  }
221
- catch {
240
+ catch (error) {
241
+ if (isRepoConfigTrustError(error)) {
242
+ return {
243
+ permission: 'deny',
244
+ user_message: error.message,
245
+ };
246
+ }
222
247
  return {
223
248
  permission: 'deny',
224
249
  user_message: 'belay failed while classifying this tool action. Run belay doctor, then retry.',
@@ -48,8 +48,6 @@ export declare function evaluateGatedAction(ctx: GateRuntimeContext, deps: GateR
48
48
  toolName?: string;
49
49
  sourceEvent?: string;
50
50
  }): Promise<GateVerdict>;
51
- /** R39: unmapped Codex tools ask via pending approval — not hard deny without approval path. */
52
- export declare function gateUnmappedToolVerdict(ctx: GateRuntimeContext, deps: GateRuntimeDeps, toolName: string, payload: Record<string, unknown>): Promise<GateVerdict>;
53
51
  export declare function processApprovalPrompt(ctx: GateRuntimeContext, deps: GateRuntimeDeps, prompt: string): Promise<ApprovalPromptResult>;
54
52
  export declare function gateVerdictToCursorResponse(verdict: GateVerdict): GatePermissionResponse;
55
53
  export declare function gateVerdictToClaudePreToolUseResponse(verdict: GateVerdict): Record<string, unknown>;