@guilz-dev/belay 0.9.4 → 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 (49) hide show
  1. package/README.md +3 -2
  2. package/dist/adapters/cursor/hook-dispatch-entry.d.ts +3 -0
  3. package/dist/adapters/cursor/hook-dispatch-entry.js +11 -0
  4. package/dist/adapters/cursor/hook-router.js +14 -7
  5. package/dist/adapters/cursor/hooks.d.ts +1 -1
  6. package/dist/adapters/cursor/hooks.js +16 -5
  7. package/dist/adapters/cursor/routing-config-trust.d.ts +1 -0
  8. package/dist/adapters/cursor/routing-config-trust.js +67 -0
  9. package/dist/adapters/cursor/runtime-entry.d.ts +2 -2
  10. package/dist/adapters/cursor/runtime-entry.js +35 -10
  11. package/dist/adapters/shared/gate-runtime.js +28 -17
  12. package/dist/bundle/claude-runtime.mjs +602 -438
  13. package/dist/bundle/codex-runtime.mjs +612 -448
  14. package/dist/bundle/cursor-dispatcher.mjs +100 -26
  15. package/dist/bundle/cursor-runtime.mjs +662 -470
  16. package/dist/cli.js +53 -4
  17. package/dist/commands/approval-token.d.ts +10 -0
  18. package/dist/commands/approval-token.js +26 -0
  19. package/dist/commands/audit.d.ts +2 -1
  20. package/dist/commands/audit.js +2 -2
  21. package/dist/commands/config.d.ts +1 -1
  22. package/dist/commands/config.js +28 -2
  23. package/dist/commands/doctor.js +10 -54
  24. package/dist/commands/dogfood-check.d.ts +3 -0
  25. package/dist/commands/dogfood-check.js +116 -0
  26. package/dist/commands/dogfood.d.ts +1 -0
  27. package/dist/commands/dogfood.js +4 -3
  28. package/dist/commands/judge.js +2 -2
  29. package/dist/commands/recovery-checkpoints.js +2 -11
  30. package/dist/config-io.d.ts +1 -0
  31. package/dist/config-io.js +8 -1
  32. package/dist/core/dogfood-environment.d.ts +8 -0
  33. package/dist/core/dogfood-environment.js +55 -0
  34. package/dist/core/effect-ir/shell-lower/decoders/belay.js +12 -0
  35. package/dist/core/egress-approval.js +0 -18
  36. package/dist/core/notify.d.ts +8 -2
  37. package/dist/core/notify.js +63 -7
  38. package/dist/core/recovery/operator-guidance.js +4 -4
  39. package/dist/core/repo-config-trust.d.ts +31 -0
  40. package/dist/core/repo-config-trust.js +152 -0
  41. package/dist/corpus/benign-probe-cores.d.ts +1 -1
  42. package/dist/corpus/benign-probe-cores.js +0 -1
  43. package/dist/defaults.js +0 -25
  44. package/dist/installer/scope-config.js +2 -2
  45. package/dist/installer.js +4 -4
  46. package/dist/types.d.ts +19 -0
  47. package/dist/version.d.ts +1 -1
  48. package/dist/version.js +1 -1
  49. 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
@@ -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 };
@@ -1,6 +1,7 @@
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';
5
6
  function canonicalExistingPath(value) {
6
7
  if (!existsSync(value)) {
@@ -103,18 +104,24 @@ function commandInvokesProjectHook(command, runnerPath, kind, eventName) {
103
104
  ].join(' ');
104
105
  return Buffer.from(encoded, 'base64').toString('utf16le') === expected;
105
106
  }
106
- function expectedMatcher(eventName, payload) {
107
+ function expectedMatchers(eventName, payload) {
107
108
  if (eventName === 'preToolUse') {
108
- return typeof payload.tool_name === 'string' ? payload.tool_name : undefined;
109
+ // Managed preToolUse is intentionally unfiltered. Keep the legacy matcher variant so older
110
+ // installed hooks still route through the same owner until upgrade.
111
+ const values = [undefined];
112
+ if (typeof payload.tool_name === 'string') {
113
+ values.push(payload.tool_name);
114
+ }
115
+ return values;
109
116
  }
110
117
  if (eventName === 'subagentStart') {
111
- return typeof payload.subagent_type === 'string' ? payload.subagent_type : undefined;
118
+ return [typeof payload.subagent_type === 'string' ? payload.subagent_type : undefined];
112
119
  }
113
- return undefined;
120
+ return [undefined];
114
121
  }
115
122
  function hasManagedProjectHookEntry(repoRoot, runnerPath, params) {
116
123
  const eventName = eventNameFor(params);
117
- const matcher = expectedMatcher(eventName, params.payload);
124
+ const matchers = new Set(expectedMatchers(eventName, params.payload));
118
125
  let parsed;
119
126
  try {
120
127
  parsed = JSON.parse(readFileSync(cursorRoutingHooksSettingsPath(repoRoot), 'utf8'));
@@ -139,7 +146,7 @@ function hasManagedProjectHookEntry(repoRoot, runnerPath, params) {
139
146
  }
140
147
  const definition = entry;
141
148
  return (definition.failClosed === true &&
142
- definition.matcher === matcher &&
149
+ matchers.has(definition.matcher) &&
143
150
  typeof definition.command === 'string' &&
144
151
  commandInvokesProjectHook(definition.command, runnerPath, params.kind, eventName));
145
152
  });
@@ -213,7 +220,7 @@ function readInstallScope(repoRoot) {
213
220
  typeof parsed === 'object' &&
214
221
  !Array.isArray(parsed) &&
215
222
  parsed.installScope === 'global') {
216
- return 'global';
223
+ return isTrustedCursorRoutingConfig(repoRoot, parsed) ? 'global' : 'project';
217
224
  }
218
225
  }
219
226
  catch {
@@ -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.',
@@ -5,7 +5,6 @@ import path from 'node:path';
5
5
  import { compactApprovals, createApprovalRecordWithEnvelope } from '../../core/approval.js';
6
6
  import { buildReplayHint, buildRetryInstructionForConfig, canAutoReplay, getExecutionLeaseMs, validateReplayEnvelope, } from '../../core/approval-replay.js';
7
7
  import { claimApprovedForReplay, gateApprovalStoreFromDeps, recordApproval, } from '../../core/approval-service.js';
8
- import { issueApprovalToken } from '../../core/approval-token.js';
9
8
  import { buildAuditActionSnapshot, buildAuditReplayContext, } from '../../core/audit-replay-context.js';
10
9
  import { approvalCorrelationId, serializeAuditRecordV3, toolInvocationCorrelationId, } from '../../core/audit-serialize.js';
11
10
  import { boundedUtf8Tail } from '../../core/bounded-output.js';
@@ -37,6 +36,7 @@ import { notifyDeny } from '../../core/notify.js';
37
36
  import { canonicalPath } from '../../core/path-utils.js';
38
37
  import { recoveryFailClosedResult, recoveryFailReasonFromSkip, } from '../../core/recovery/fail-closed.js';
39
38
  import { fingerprintReplayPayload, redactToolInvocationId } from '../../core/replay-scrub.js';
39
+ import { assertRepoConfigTrusted } from '../../core/repo-config-trust.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';
@@ -91,7 +91,15 @@ async function loadJsonFile(filePath, fallback) {
91
91
  export function createDefaultGateRuntimeDeps() {
92
92
  return {
93
93
  async readConfig(configPath) {
94
- return loadJsonFile(configPath, {});
94
+ try {
95
+ return JSON.parse(await readFile(configPath, 'utf8'));
96
+ }
97
+ catch (error) {
98
+ if (error.code === 'ENOENT') {
99
+ return {};
100
+ }
101
+ throw error;
102
+ }
95
103
  },
96
104
  async appendAudit(ctx, event) {
97
105
  const auditPath = path.join(ctx.repoRoot, ctx.config.audit.logPath);
@@ -149,6 +157,7 @@ export function createDefaultGateRuntimeDeps() {
149
157
  }
150
158
  export async function resolveGateConfig(ctx, deps) {
151
159
  const loaded = await deps.readConfig(ctx.configPath);
160
+ await assertRepoConfigTrusted(ctx.repoRoot, ctx.layout.name, loaded);
152
161
  let teamConfig = null;
153
162
  const teamPath = teamConfigPath();
154
163
  if (existsSync(teamPath)) {
@@ -675,7 +684,23 @@ export async function evaluateGatedAction(ctx, deps, params) {
675
684
  ...authorization,
676
685
  egressProxyActive,
677
686
  };
678
- const predicted = await classifyGatedActionAsync(action, ctx.config, enrichedClassifierOptions);
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
+ }
679
704
  if (action.kind === 'shell' &&
680
705
  action.command &&
681
706
  isContainedUnknownExecutionEligible(ctx.config, action, predicted)) {
@@ -937,19 +962,6 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
937
962
  if (created) {
938
963
  await recordGateApprovalAsk(stateDir, result.reason, false);
939
964
  }
940
- let approvalToken;
941
- try {
942
- approvalToken = await issueApprovalToken({
943
- approvalId: approval.approvalId,
944
- fingerprint: approval.fingerprint,
945
- repoRoot: approval.repoRoot,
946
- issuedAt: approval.createdAt,
947
- expiresAt: approval.expiresAt,
948
- }, configuredControlPlaneDir(ctx.config));
949
- }
950
- catch {
951
- approvalToken = undefined;
952
- }
953
965
  const denialReason = failure?.reason ?? result.reason;
954
966
  if (ctx.config.notifications.webhookUrl || ctx.config.notifications.commandHook) {
955
967
  await notifyDeny(ctx.config.notifications, {
@@ -958,7 +970,6 @@ async function gateDecisionToVerdict(ctx, deps, kind, result, auditExtras = {})
958
970
  summary: result.normalizedCommand ?? result.summary ?? '',
959
971
  repoRoot: ctx.repoRoot,
960
972
  fingerprint: result.fingerprint,
961
- approvalToken,
962
973
  });
963
974
  }
964
975
  const failureAuditFields = typeof failure?.auditFields === 'function'