|
13 | 13 | */ |
14 | 14 |
|
15 | 15 | import { query } from '@anthropic-ai/claude-agent-sdk'; |
| 16 | +// Used to mint unique approval request IDs when randomUUID is not available. |
| 17 | +// This keeps parallel tool approvals from colliding; it does not add any crypto/security guarantees. |
| 18 | +import crypto from 'crypto'; |
16 | 19 | import { promises as fs } from 'fs'; |
17 | 20 | import path from 'path'; |
18 | 21 | import os from 'os'; |
19 | 22 | import { CLAUDE_MODELS } from '../shared/modelConstants.js'; |
20 | 23 |
|
21 | 24 | // Session tracking: Map of session IDs to active query instances |
22 | 25 | const activeSessions = new Map(); |
| 26 | +// In-memory registry of pending tool approvals keyed by requestId. |
| 27 | +// This does not persist approvals or share across processes; it exists so the |
| 28 | +// SDK can pause tool execution while the UI decides what to do. |
| 29 | +const pendingToolApprovals = new Map(); |
| 30 | + |
| 31 | +// Default approval timeout kept under the SDK's 60s control timeout. |
| 32 | +// This does not change SDK limits; it only defines how long we wait for the UI, |
| 33 | +// introduced to avoid hanging the run when no decision arrives. |
| 34 | +const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000; |
| 35 | + |
| 36 | +// Generate a stable request ID for UI approval flows. |
| 37 | +// This does not encode tool details or get shown to users; it exists so the UI |
| 38 | +// can respond to the correct pending request without collisions. |
| 39 | +function createRequestId() { |
| 40 | + // if clause is used because randomUUID is not available in older Node.js versions |
| 41 | + if (typeof crypto.randomUUID === 'function') { |
| 42 | + return crypto.randomUUID(); |
| 43 | + } |
| 44 | + return crypto.randomBytes(16).toString('hex'); |
| 45 | +} |
| 46 | + |
| 47 | +// Wait for a UI approval decision, honoring SDK cancellation. |
| 48 | +// This does not auto-approve or auto-deny; it only resolves with UI input, |
| 49 | +// and it cleans up the pending map to avoid leaks, introduced to prevent |
| 50 | +// replying after the SDK cancels the control request. |
| 51 | +function waitForToolApproval(requestId, options = {}) { |
| 52 | + const { timeoutMs = TOOL_APPROVAL_TIMEOUT_MS, signal, onCancel } = options; |
| 53 | + |
| 54 | + return new Promise(resolve => { |
| 55 | + let settled = false; |
| 56 | + |
| 57 | + const finalize = (decision) => { |
| 58 | + if (settled) return; |
| 59 | + settled = true; |
| 60 | + cleanup(); |
| 61 | + resolve(decision); |
| 62 | + }; |
| 63 | + |
| 64 | + const cleanup = () => { |
| 65 | + pendingToolApprovals.delete(requestId); |
| 66 | + clearTimeout(timeout); |
| 67 | + if (signal && abortHandler) { |
| 68 | + signal.removeEventListener('abort', abortHandler); |
| 69 | + } |
| 70 | + }; |
| 71 | + |
| 72 | + // Timeout is local to this process; it does not override SDK timing. |
| 73 | + // It exists to prevent the UI prompt from lingering indefinitely. |
| 74 | + const timeout = setTimeout(() => { |
| 75 | + onCancel?.('timeout'); |
| 76 | + finalize(null); |
| 77 | + }, timeoutMs); |
| 78 | + |
| 79 | + const abortHandler = () => { |
| 80 | + // If the SDK cancels the control request, stop waiting to avoid |
| 81 | + // replying after the process is no longer ready for writes. |
| 82 | + onCancel?.('cancelled'); |
| 83 | + finalize({ cancelled: true }); |
| 84 | + }; |
| 85 | + |
| 86 | + if (signal) { |
| 87 | + if (signal.aborted) { |
| 88 | + onCancel?.('cancelled'); |
| 89 | + finalize({ cancelled: true }); |
| 90 | + return; |
| 91 | + } |
| 92 | + signal.addEventListener('abort', abortHandler, { once: true }); |
| 93 | + } |
| 94 | + |
| 95 | + pendingToolApprovals.set(requestId, (decision) => { |
| 96 | + finalize(decision); |
| 97 | + }); |
| 98 | + }); |
| 99 | +} |
| 100 | + |
| 101 | +// Resolve a pending approval. This does not validate the decision payload; |
| 102 | +// validation and tool matching remain in canUseTool, which keeps this as a |
| 103 | +// lightweight WebSocket -> SDK relay. |
| 104 | +function resolveToolApproval(requestId, decision) { |
| 105 | + const resolver = pendingToolApprovals.get(requestId); |
| 106 | + if (resolver) { |
| 107 | + resolver(decision); |
| 108 | + } |
| 109 | +} |
| 110 | + |
| 111 | +// Match stored permission entries against a tool + input combo. |
| 112 | +// This only supports exact tool names and the Bash(command:*) shorthand |
| 113 | +// used by the UI; it intentionally does not implement full glob semantics, |
| 114 | +// introduced to stay consistent with the UI's "Allow rule" format. |
| 115 | +function matchesToolPermission(entry, toolName, input) { |
| 116 | + if (!entry || !toolName) { |
| 117 | + return false; |
| 118 | + } |
| 119 | + |
| 120 | + if (entry === toolName) { |
| 121 | + return true; |
| 122 | + } |
| 123 | + |
| 124 | + const bashMatch = entry.match(/^Bash\((.+):\*\)$/); |
| 125 | + if (toolName === 'Bash' && bashMatch) { |
| 126 | + const allowedPrefix = bashMatch[1]; |
| 127 | + let command = ''; |
| 128 | + |
| 129 | + if (typeof input === 'string') { |
| 130 | + command = input.trim(); |
| 131 | + } else if (input && typeof input === 'object' && typeof input.command === 'string') { |
| 132 | + command = input.command.trim(); |
| 133 | + } |
| 134 | + |
| 135 | + if (!command) { |
| 136 | + return false; |
| 137 | + } |
| 138 | + |
| 139 | + return command.startsWith(allowedPrefix); |
| 140 | + } |
| 141 | + |
| 142 | + return false; |
| 143 | +} |
23 | 144 |
|
24 | 145 | /** |
25 | 146 | * Maps CLI options to SDK-compatible options format |
@@ -52,29 +173,28 @@ function mapCliOptionsToSDK(options = {}) { |
52 | 173 | if (settings.skipPermissions && permissionMode !== 'plan') { |
53 | 174 | // When skipping permissions, use bypassPermissions mode |
54 | 175 | sdkOptions.permissionMode = 'bypassPermissions'; |
55 | | - } else { |
56 | | - // Map allowed tools |
57 | | - let allowedTools = [...(settings.allowedTools || [])]; |
58 | | - |
59 | | - // Add plan mode default tools |
60 | | - if (permissionMode === 'plan') { |
61 | | - const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite', 'WebFetch', 'WebSearch']; |
62 | | - for (const tool of planModeTools) { |
63 | | - if (!allowedTools.includes(tool)) { |
64 | | - allowedTools.push(tool); |
65 | | - } |
| 176 | + } |
| 177 | + |
| 178 | + // Map allowed tools (always set to avoid implicit "allow all" defaults). |
| 179 | + // This does not grant permissions by itself; it just configures the SDK, |
| 180 | + // introduced because leaving it undefined made the SDK treat it as "all tools allowed." |
| 181 | + let allowedTools = [...(settings.allowedTools || [])]; |
| 182 | + |
| 183 | + // Add plan mode default tools |
| 184 | + if (permissionMode === 'plan') { |
| 185 | + const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite', 'WebFetch', 'WebSearch']; |
| 186 | + for (const tool of planModeTools) { |
| 187 | + if (!allowedTools.includes(tool)) { |
| 188 | + allowedTools.push(tool); |
66 | 189 | } |
67 | 190 | } |
| 191 | + } |
68 | 192 |
|
69 | | - if (allowedTools.length > 0) { |
70 | | - sdkOptions.allowedTools = allowedTools; |
71 | | - } |
| 193 | + sdkOptions.allowedTools = allowedTools; |
72 | 194 |
|
73 | | - // Map disallowed tools |
74 | | - if (settings.disallowedTools && settings.disallowedTools.length > 0) { |
75 | | - sdkOptions.disallowedTools = settings.disallowedTools; |
76 | | - } |
77 | | - } |
| 195 | + // Map disallowed tools (always set so the SDK doesn't treat "undefined" as permissive). |
| 196 | + // This does not override allowlists; it only feeds the canUseTool gate. |
| 197 | + sdkOptions.disallowedTools = settings.disallowedTools || []; |
78 | 198 |
|
79 | 199 | // Map model (default to sonnet) |
80 | 200 | // Valid models: sonnet, opus, haiku, opusplan, sonnet[1m] |
@@ -370,6 +490,76 @@ async function queryClaudeSDK(command, options = {}, ws) { |
370 | 490 | tempImagePaths = imageResult.tempImagePaths; |
371 | 491 | tempDir = imageResult.tempDir; |
372 | 492 |
|
| 493 | + // Gate tool usage with explicit UI approval when not auto-approved. |
| 494 | + // This does not render UI or persist permissions; it only bridges to the UI |
| 495 | + // via WebSocket and waits for the response, introduced so tool calls pause |
| 496 | + // instead of auto-running when the allowlist is empty. |
| 497 | + sdkOptions.canUseTool = async (toolName, input, context) => { |
| 498 | + if (sdkOptions.permissionMode === 'bypassPermissions') { |
| 499 | + return { behavior: 'allow', updatedInput: input }; |
| 500 | + } |
| 501 | + |
| 502 | + const isDisallowed = (sdkOptions.disallowedTools || []).some(entry => |
| 503 | + matchesToolPermission(entry, toolName, input) |
| 504 | + ); |
| 505 | + if (isDisallowed) { |
| 506 | + return { behavior: 'deny', message: 'Tool disallowed by settings' }; |
| 507 | + } |
| 508 | + |
| 509 | + const isAllowed = (sdkOptions.allowedTools || []).some(entry => |
| 510 | + matchesToolPermission(entry, toolName, input) |
| 511 | + ); |
| 512 | + if (isAllowed) { |
| 513 | + return { behavior: 'allow', updatedInput: input }; |
| 514 | + } |
| 515 | + |
| 516 | + const requestId = createRequestId(); |
| 517 | + ws.send({ |
| 518 | + type: 'claude-permission-request', |
| 519 | + requestId, |
| 520 | + toolName, |
| 521 | + input, |
| 522 | + sessionId: capturedSessionId || sessionId || null |
| 523 | + }); |
| 524 | + |
| 525 | + // Wait for the UI; if the SDK cancels, notify the UI so it can dismiss the banner. |
| 526 | + // This does not retry or resurface the prompt; it just reflects the cancellation. |
| 527 | + const decision = await waitForToolApproval(requestId, { |
| 528 | + signal: context?.signal, |
| 529 | + onCancel: (reason) => { |
| 530 | + ws.send({ |
| 531 | + type: 'claude-permission-cancelled', |
| 532 | + requestId, |
| 533 | + reason, |
| 534 | + sessionId: capturedSessionId || sessionId || null |
| 535 | + }); |
| 536 | + } |
| 537 | + }); |
| 538 | + if (!decision) { |
| 539 | + return { behavior: 'deny', message: 'Permission request timed out' }; |
| 540 | + } |
| 541 | + |
| 542 | + if (decision.cancelled) { |
| 543 | + return { behavior: 'deny', message: 'Permission request cancelled' }; |
| 544 | + } |
| 545 | + |
| 546 | + if (decision.allow) { |
| 547 | + // rememberEntry only updates this run's in-memory allowlist to prevent |
| 548 | + // repeated prompts in the same session; persistence is handled by the UI. |
| 549 | + if (decision.rememberEntry && typeof decision.rememberEntry === 'string') { |
| 550 | + if (!sdkOptions.allowedTools.includes(decision.rememberEntry)) { |
| 551 | + sdkOptions.allowedTools.push(decision.rememberEntry); |
| 552 | + } |
| 553 | + if (Array.isArray(sdkOptions.disallowedTools)) { |
| 554 | + sdkOptions.disallowedTools = sdkOptions.disallowedTools.filter(entry => entry !== decision.rememberEntry); |
| 555 | + } |
| 556 | + } |
| 557 | + return { behavior: 'allow', updatedInput: decision.updatedInput ?? input }; |
| 558 | + } |
| 559 | + |
| 560 | + return { behavior: 'deny', message: decision.message ?? 'User denied tool use' }; |
| 561 | + }; |
| 562 | + |
373 | 563 | // Create SDK query instance |
374 | 564 | const queryInstance = query({ |
375 | 565 | prompt: finalCommand, |
@@ -526,5 +716,6 @@ export { |
526 | 716 | queryClaudeSDK, |
527 | 717 | abortClaudeSDKSession, |
528 | 718 | isClaudeSDKSessionActive, |
529 | | - getActiveClaudeSDKSessions |
| 719 | + getActiveClaudeSDKSessions, |
| 720 | + resolveToolApproval |
530 | 721 | }; |
0 commit comments