@guilz-dev/belay 0.10.0 → 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.
@@ -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
  }
@@ -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;
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { resolveCursorActionCwdDetails } from './cwd-resolution.js';
4
4
  import { isTrustedCursorRoutingConfig } from './routing-config-trust.js';
5
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.';
6
7
  function canonicalExistingPath(value) {
7
8
  if (!existsSync(value)) {
8
9
  return undefined;
@@ -162,6 +163,11 @@ function hasMatchingProjectShim(repoRoot, kind) {
162
163
  return (source.includes("from '../belay/runtime/dispatcher.mjs'") &&
163
164
  source.includes(`origin: ${JSON.stringify({ scope: 'project', repoRoot })}`));
164
165
  }
166
+ function hasProjectOwnerInstallation(repoRoot) {
167
+ const hooksDir = cursorRoutingHooksDir(repoRoot);
168
+ return Boolean(selectedRunnerPath(hooksDir) &&
169
+ isRegularFile(path.join(cursorRoutingRuntimeDir(repoRoot), 'dispatcher.mjs')));
170
+ }
165
171
  function hasCallableProjectOwner(repoRoot, params) {
166
172
  const hooksDir = cursorRoutingHooksDir(repoRoot);
167
173
  const runnerPath = selectedRunnerPath(hooksDir);
@@ -205,6 +211,10 @@ function selectedActionPath(payload) {
205
211
  }
206
212
  return undefined;
207
213
  }
214
+ function hasRoutingRootMarker(dir) {
215
+ return (existsSync(path.join(dir, '.git')) ||
216
+ (existsSync(path.join(dir, '.cursor')) && existsSync(cursorRoutingConfigPath(dir))));
217
+ }
208
218
  function readInstallScope(repoRoot) {
209
219
  const configPath = cursorRoutingConfigPath(repoRoot);
210
220
  let source;
@@ -220,7 +230,13 @@ function readInstallScope(repoRoot) {
220
230
  typeof parsed === 'object' &&
221
231
  !Array.isArray(parsed) &&
222
232
  parsed.installScope === 'global') {
223
- return isTrustedCursorRoutingConfig(repoRoot, parsed) ? 'global' : 'project';
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';
224
240
  }
225
241
  }
226
242
  catch {
@@ -240,10 +256,17 @@ export function routeCursorHook(params) {
240
256
  if (!canonicalCwd) {
241
257
  return { decision: 'fail_closed', message: 'belay could not determine the workspace.' };
242
258
  }
243
- const repoRoot = canonicalExistingPath(findCursorRoutingRepoRoot(canonicalCwd));
259
+ const discoveredRepoRoot = findCursorRoutingRepoRoot(canonicalCwd);
260
+ const repoRoot = canonicalExistingPath(discoveredRepoRoot);
244
261
  if (!repoRoot) {
245
262
  return { decision: 'neutral' };
246
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
+ }
247
270
  const installScope = readInstallScope(repoRoot);
248
271
  if (installScope === 'missing') {
249
272
  return { decision: 'neutral' };
@@ -263,7 +286,7 @@ export function routeCursorHook(params) {
263
286
  ? { decision: 'neutral' }
264
287
  : {
265
288
  decision: 'fail_closed',
266
- 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.`,
267
290
  };
268
291
  }
269
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
+ }
@@ -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>;
@@ -30,7 +30,7 @@ import { effectPlanAuditFields, hashEffectPlan } from '../../core/effect-ir/audi
30
30
  import { buildCapabilityEffectPlan } from '../../core/effect-ir/build.js';
31
31
  import { classifyResultToGateVerdict, unnormalizedGateVerdict, } from '../../core/gate-contract.js';
32
32
  import { classifyGatedActionAsync, extractAgentAssessment, GateNormalizationError, gateEnabledForAction, normalizeGatedAction, } from '../../core/gate-engine.js';
33
- import { approvalCommandMatch, approvedApprovalsFile, belayStateDir, canonicalStringify, classifierOptionsFromConfig, configuredControlPlaneDir, hashValue, pendingApprovalsFile, resolveControlPlaneDir, scrubOptionsFromConfig, scrubString, scrubValue, toolFingerprint, } from '../../core/index.js';
33
+ import { approvalCommandMatch, approvedApprovalsFile, belayStateDir, canonicalStringify, classifierOptionsFromConfig, configuredControlPlaneDir, hashValue, pendingApprovalsFile, resolveControlPlaneDir, scrubOptionsFromConfig, scrubString, scrubValue, } from '../../core/index.js';
34
34
  import { extractJudgeFallbackReason, formatJudgeInfrastructureDenyMessage, inferProviderIdFromFallbackReason, isJudgeInfrastructureFailure, } from '../../core/judge-fallback-hints.js';
35
35
  import { notifyDeny } from '../../core/notify.js';
36
36
  import { canonicalPath } from '../../core/path-utils.js';
@@ -684,23 +684,7 @@ export async function evaluateGatedAction(ctx, deps, params) {
684
684
  ...authorization,
685
685
  egressProxyActive,
686
686
  };
687
- let predicted = await classifyGatedActionAsync(action, ctx.config, enrichedClassifierOptions);
688
- if (action.kind === 'tool' &&
689
- predicted.reason === 'unclassified_tool' &&
690
- (ctx.config.policy.codexUnmappedTool ?? 'deny') === 'deny') {
691
- predicted = {
692
- ...predicted,
693
- verdict: 'deny_pending_approval',
694
- reason: 'unmapped_tool',
695
- assessment: {
696
- reversibility: 'irreversible',
697
- external: false,
698
- blastRadius: 'unknown tool action',
699
- confidence: 0.5,
700
- signals: ['unmapped_tool'],
701
- },
702
- };
703
- }
687
+ const predicted = await classifyGatedActionAsync(action, ctx.config, enrichedClassifierOptions);
704
688
  if (action.kind === 'shell' &&
705
689
  action.command &&
706
690
  isContainedUnknownExecutionEligible(ctx.config, action, predicted)) {
@@ -842,41 +826,6 @@ export async function evaluateGatedAction(ctx, deps, params) {
842
826
  scopeHintPayload: params.payload,
843
827
  });
844
828
  }
845
- /** R39: unmapped Codex tools ask via pending approval — not hard deny without approval path. */
846
- export async function gateUnmappedToolVerdict(ctx, deps, toolName, payload) {
847
- const scrubOpts = scrubOptionsFromConfig(ctx.config);
848
- const replayPayload = fingerprintReplayPayload('tool', payload, scrubOpts) ?? {};
849
- const result = {
850
- verdict: 'deny_pending_approval',
851
- reason: 'unmapped_tool',
852
- summary: toolName,
853
- fingerprint: toolFingerprint(toolName, replayPayload, ctx.repoRoot),
854
- assessment: {
855
- reversibility: 'irreversible',
856
- external: false,
857
- blastRadius: 'unknown Codex tool action',
858
- confidence: 0.5,
859
- signals: ['unmapped_tool'],
860
- },
861
- };
862
- return gateDecisionToVerdict(ctx, deps, 'tool', result, {
863
- approvalInput: {
864
- input: toolName,
865
- inputKind: 'tool',
866
- cwd: ctx.repoRoot,
867
- toolName,
868
- payload: replayPayload,
869
- },
870
- replayAction: {
871
- kind: 'tool',
872
- cwd: ctx.repoRoot,
873
- toolName,
874
- payload: replayPayload,
875
- fingerprint: result.fingerprint,
876
- repoRoot: ctx.repoRoot,
877
- },
878
- });
879
- }
880
829
  async function consumeCapabilityGrantIfUsed(ctx, deps, result) {
881
830
  if (result.reason !== 'capability_grant' &&
882
831
  result.authorizationDecision?.matchedRule !== 'grant.exact') {