diff --git a/server/claude-sdk.js b/server/claude-sdk.js index 1abf70893..836669124 100644 --- a/server/claude-sdk.js +++ b/server/claude-sdk.js @@ -13,6 +13,9 @@ */ import { query } from '@anthropic-ai/claude-agent-sdk'; +// Used to mint unique approval request IDs when randomUUID is not available. +// This keeps parallel tool approvals from colliding; it does not add any crypto/security guarantees. +import crypto from 'crypto'; import { promises as fs } from 'fs'; import path from 'path'; import os from 'os'; @@ -20,6 +23,124 @@ import { CLAUDE_MODELS } from '../shared/modelConstants.js'; // Session tracking: Map of session IDs to active query instances const activeSessions = new Map(); +// In-memory registry of pending tool approvals keyed by requestId. +// This does not persist approvals or share across processes; it exists so the +// SDK can pause tool execution while the UI decides what to do. +const pendingToolApprovals = new Map(); + +// Default approval timeout kept under the SDK's 60s control timeout. +// This does not change SDK limits; it only defines how long we wait for the UI, +// introduced to avoid hanging the run when no decision arrives. +const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000; + +// Generate a stable request ID for UI approval flows. +// This does not encode tool details or get shown to users; it exists so the UI +// can respond to the correct pending request without collisions. +function createRequestId() { + // if clause is used because randomUUID is not available in older Node.js versions + if (typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + return crypto.randomBytes(16).toString('hex'); +} + +// Wait for a UI approval decision, honoring SDK cancellation. +// This does not auto-approve or auto-deny; it only resolves with UI input, +// and it cleans up the pending map to avoid leaks, introduced to prevent +// replying after the SDK cancels the control request. +function waitForToolApproval(requestId, options = {}) { + const { timeoutMs = TOOL_APPROVAL_TIMEOUT_MS, signal, onCancel } = options; + + return new Promise(resolve => { + let settled = false; + + const finalize = (decision) => { + if (settled) return; + settled = true; + cleanup(); + resolve(decision); + }; + + const cleanup = () => { + pendingToolApprovals.delete(requestId); + clearTimeout(timeout); + if (signal && abortHandler) { + signal.removeEventListener('abort', abortHandler); + } + }; + + // Timeout is local to this process; it does not override SDK timing. + // It exists to prevent the UI prompt from lingering indefinitely. + const timeout = setTimeout(() => { + onCancel?.('timeout'); + finalize(null); + }, timeoutMs); + + const abortHandler = () => { + // If the SDK cancels the control request, stop waiting to avoid + // replying after the process is no longer ready for writes. + onCancel?.('cancelled'); + finalize({ cancelled: true }); + }; + + if (signal) { + if (signal.aborted) { + onCancel?.('cancelled'); + finalize({ cancelled: true }); + return; + } + signal.addEventListener('abort', abortHandler, { once: true }); + } + + pendingToolApprovals.set(requestId, (decision) => { + finalize(decision); + }); + }); +} + +// Resolve a pending approval. This does not validate the decision payload; +// validation and tool matching remain in canUseTool, which keeps this as a +// lightweight WebSocket -> SDK relay. +function resolveToolApproval(requestId, decision) { + const resolver = pendingToolApprovals.get(requestId); + if (resolver) { + resolver(decision); + } +} + +// Match stored permission entries against a tool + input combo. +// This only supports exact tool names and the Bash(command:*) shorthand +// used by the UI; it intentionally does not implement full glob semantics, +// introduced to stay consistent with the UI's "Allow rule" format. +function matchesToolPermission(entry, toolName, input) { + if (!entry || !toolName) { + return false; + } + + if (entry === toolName) { + return true; + } + + const bashMatch = entry.match(/^Bash\((.+):\*\)$/); + if (toolName === 'Bash' && bashMatch) { + const allowedPrefix = bashMatch[1]; + let command = ''; + + if (typeof input === 'string') { + command = input.trim(); + } else if (input && typeof input === 'object' && typeof input.command === 'string') { + command = input.command.trim(); + } + + if (!command) { + return false; + } + + return command.startsWith(allowedPrefix); + } + + return false; +} /** * Maps CLI options to SDK-compatible options format @@ -52,29 +173,28 @@ function mapCliOptionsToSDK(options = {}) { if (settings.skipPermissions && permissionMode !== 'plan') { // When skipping permissions, use bypassPermissions mode sdkOptions.permissionMode = 'bypassPermissions'; - } else { - // Map allowed tools - let allowedTools = [...(settings.allowedTools || [])]; - - // Add plan mode default tools - if (permissionMode === 'plan') { - const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite', 'WebFetch', 'WebSearch']; - for (const tool of planModeTools) { - if (!allowedTools.includes(tool)) { - allowedTools.push(tool); - } + } + + // Map allowed tools (always set to avoid implicit "allow all" defaults). + // This does not grant permissions by itself; it just configures the SDK, + // introduced because leaving it undefined made the SDK treat it as "all tools allowed." + let allowedTools = [...(settings.allowedTools || [])]; + + // Add plan mode default tools + if (permissionMode === 'plan') { + const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite', 'WebFetch', 'WebSearch']; + for (const tool of planModeTools) { + if (!allowedTools.includes(tool)) { + allowedTools.push(tool); } } + } - if (allowedTools.length > 0) { - sdkOptions.allowedTools = allowedTools; - } + sdkOptions.allowedTools = allowedTools; - // Map disallowed tools - if (settings.disallowedTools && settings.disallowedTools.length > 0) { - sdkOptions.disallowedTools = settings.disallowedTools; - } - } + // Map disallowed tools (always set so the SDK doesn't treat "undefined" as permissive). + // This does not override allowlists; it only feeds the canUseTool gate. + sdkOptions.disallowedTools = settings.disallowedTools || []; // Map model (default to sonnet) // Valid models: sonnet, opus, haiku, opusplan, sonnet[1m] @@ -370,6 +490,76 @@ async function queryClaudeSDK(command, options = {}, ws) { tempImagePaths = imageResult.tempImagePaths; tempDir = imageResult.tempDir; + // Gate tool usage with explicit UI approval when not auto-approved. + // This does not render UI or persist permissions; it only bridges to the UI + // via WebSocket and waits for the response, introduced so tool calls pause + // instead of auto-running when the allowlist is empty. + sdkOptions.canUseTool = async (toolName, input, context) => { + if (sdkOptions.permissionMode === 'bypassPermissions') { + return { behavior: 'allow', updatedInput: input }; + } + + const isDisallowed = (sdkOptions.disallowedTools || []).some(entry => + matchesToolPermission(entry, toolName, input) + ); + if (isDisallowed) { + return { behavior: 'deny', message: 'Tool disallowed by settings' }; + } + + const isAllowed = (sdkOptions.allowedTools || []).some(entry => + matchesToolPermission(entry, toolName, input) + ); + if (isAllowed) { + return { behavior: 'allow', updatedInput: input }; + } + + const requestId = createRequestId(); + ws.send({ + type: 'claude-permission-request', + requestId, + toolName, + input, + sessionId: capturedSessionId || sessionId || null + }); + + // Wait for the UI; if the SDK cancels, notify the UI so it can dismiss the banner. + // This does not retry or resurface the prompt; it just reflects the cancellation. + const decision = await waitForToolApproval(requestId, { + signal: context?.signal, + onCancel: (reason) => { + ws.send({ + type: 'claude-permission-cancelled', + requestId, + reason, + sessionId: capturedSessionId || sessionId || null + }); + } + }); + if (!decision) { + return { behavior: 'deny', message: 'Permission request timed out' }; + } + + if (decision.cancelled) { + return { behavior: 'deny', message: 'Permission request cancelled' }; + } + + if (decision.allow) { + // rememberEntry only updates this run's in-memory allowlist to prevent + // repeated prompts in the same session; persistence is handled by the UI. + if (decision.rememberEntry && typeof decision.rememberEntry === 'string') { + if (!sdkOptions.allowedTools.includes(decision.rememberEntry)) { + sdkOptions.allowedTools.push(decision.rememberEntry); + } + if (Array.isArray(sdkOptions.disallowedTools)) { + sdkOptions.disallowedTools = sdkOptions.disallowedTools.filter(entry => entry !== decision.rememberEntry); + } + } + return { behavior: 'allow', updatedInput: decision.updatedInput ?? input }; + } + + return { behavior: 'deny', message: decision.message ?? 'User denied tool use' }; + }; + // Create SDK query instance const queryInstance = query({ prompt: finalCommand, @@ -526,5 +716,6 @@ export { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, - getActiveClaudeSDKSessions + getActiveClaudeSDKSessions, + resolveToolApproval }; diff --git a/server/index.js b/server/index.js index d6b46e51d..a638d0d61 100755 --- a/server/index.js +++ b/server/index.js @@ -58,7 +58,7 @@ import fetch from 'node-fetch'; import mime from 'mime-types'; import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } from './projects.js'; -import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions } from './claude-sdk.js'; +import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval } from './claude-sdk.js'; import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js'; import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js'; import gitRoutes from './routes/git.js'; @@ -804,6 +804,18 @@ function handleChatConnection(ws) { provider, success }); + } else if (data.type === 'claude-permission-response') { + // Relay UI approval decisions back into the SDK control flow. + // This does not persist permissions; it only resolves the in-flight request, + // introduced so the SDK can resume once the user clicks Allow/Deny. + if (data.requestId) { + resolveToolApproval(data.requestId, { + allow: Boolean(data.allow), + updatedInput: data.updatedInput, + message: data.message, + rememberEntry: data.rememberEntry + }); + } } else if (data.type === 'cursor-abort') { console.log('[DEBUG] Abort Cursor session:', data.sessionId); const success = abortCursorSession(data.sessionId); diff --git a/src/components/ChatInterface.jsx b/src/components/ChatInterface.jsx index d5abc0a66..a9d5110dc 100644 --- a/src/components/ChatInterface.jsx +++ b/src/components/ChatInterface.jsx @@ -37,6 +37,7 @@ import Fuse from 'fuse.js'; import CommandMenu from './CommandMenu'; import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants'; +import { safeJsonParse } from '../lib/utils.js'; // Helper function to decode HTML entities in text function decodeHtmlEntities(text) { @@ -236,6 +237,102 @@ const safeLocalStorage = { } }; +const CLAUDE_SETTINGS_KEY = 'claude-settings'; + + +function getClaudeSettings() { + const raw = safeLocalStorage.getItem(CLAUDE_SETTINGS_KEY); + if (!raw) { + return { + allowedTools: [], + disallowedTools: [], + skipPermissions: false, + projectSortOrder: 'name' + }; + } + + try { + const parsed = JSON.parse(raw); + return { + ...parsed, + allowedTools: Array.isArray(parsed.allowedTools) ? parsed.allowedTools : [], + disallowedTools: Array.isArray(parsed.disallowedTools) ? parsed.disallowedTools : [], + skipPermissions: Boolean(parsed.skipPermissions), + projectSortOrder: parsed.projectSortOrder || 'name' + }; + } catch { + return { + allowedTools: [], + disallowedTools: [], + skipPermissions: false, + projectSortOrder: 'name' + }; + } +} + +function buildClaudeToolPermissionEntry(toolName, toolInput) { + if (!toolName) return null; + if (toolName !== 'Bash') return toolName; + + const parsed = safeJsonParse(toolInput); + const command = typeof parsed?.command === 'string' ? parsed.command.trim() : ''; + if (!command) return toolName; + + const tokens = command.split(/\s+/); + if (tokens.length === 0) return toolName; + + // For Bash, allow the command family instead of every Bash invocation. + if (tokens[0] === 'git' && tokens[1]) { + return `Bash(${tokens[0]} ${tokens[1]}:*)`; + } + return `Bash(${tokens[0]}:*)`; +} + +// Normalize tool inputs for display in the permission banner. +// This does not sanitize/redact secrets; it is strictly formatting so users +// can see the raw input that triggered the permission prompt. +function formatToolInputForDisplay(input) { + if (input === undefined || input === null) return ''; + if (typeof input === 'string') return input; + try { + return JSON.stringify(input, null, 2); + } catch { + return String(input); + } +} + +function getClaudePermissionSuggestion(message, provider) { + if (provider !== 'claude') return null; + if (!message?.toolResult?.isError) return null; + + const toolName = message?.toolName; + const entry = buildClaudeToolPermissionEntry(toolName, message.toolInput); + + if (!entry) return null; + + const settings = getClaudeSettings(); + const isAllowed = settings.allowedTools.includes(entry); + return { toolName, entry, isAllowed }; +} + +function grantClaudeToolPermission(entry) { + if (!entry) return { success: false }; + + const settings = getClaudeSettings(); + const alreadyAllowed = settings.allowedTools.includes(entry); + const nextAllowed = alreadyAllowed ? settings.allowedTools : [...settings.allowedTools, entry]; + const nextDisallowed = settings.disallowedTools.filter(tool => tool !== entry); + const updatedSettings = { + ...settings, + allowedTools: nextAllowed, + disallowedTools: nextDisallowed, + lastUpdated: new Date().toISOString() + }; + + safeLocalStorage.setItem(CLAUDE_SETTINGS_KEY, JSON.stringify(updatedSettings)); + return { success: true, alreadyAllowed, updatedSettings }; +} + // Common markdown components to ensure consistent rendering (tables, inline code, links, etc.) const markdownComponents = { code: ({ node, inline, className, children, ...props }) => { @@ -356,7 +453,7 @@ const markdownComponents = { }; // Memoized message component to prevent unnecessary re-renders -const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, autoExpandTools, showRawParameters, showThinking, selectedProject }) => { +const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, onGrantToolPermission, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }) => { const isGrouped = prevMessage && prevMessage.type === message.type && ((prevMessage.type === 'assistant') || (prevMessage.type === 'user') || @@ -364,6 +461,13 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile (prevMessage.type === 'error')); const messageRef = React.useRef(null); const [isExpanded, setIsExpanded] = React.useState(false); + const permissionSuggestion = getClaudePermissionSuggestion(message, provider); + const [permissionGrantState, setPermissionGrantState] = React.useState('idle'); + + React.useEffect(() => { + setPermissionGrantState('idle'); + }, [permissionSuggestion?.entry, message.toolId]); + React.useEffect(() => { if (!autoExpandTools || !messageRef.current || !message.isToolUse) return; @@ -1358,6 +1462,59 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile ); })()} + {permissionSuggestion && ( +
+ {rawInput}
+
+