diff --git a/.vscodeignore b/.vscodeignore index fbc0fad..2eada3f 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -11,4 +11,6 @@ vsc-extension-quickstart.md **/.vscode-test.* backup .claude -claude-code-chat-permissions-mcp/** \ No newline at end of file +claude-code-chat-permissions-mcp/** +node_modules +mcp-permissions.js \ No newline at end of file diff --git a/src/extension.ts b/src/extension.ts index d1d1a21..aaf2731 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -124,9 +124,14 @@ class ClaudeChatProvider { private _backupRepoPath: string | undefined; private _commits: Array<{ id: string, sha: string, message: string, timestamp: string }> = []; private _conversationsPath: string | undefined; - private _permissionRequestsPath: string | undefined; - private _permissionWatcher: vscode.FileSystemWatcher | undefined; - private _pendingPermissionResolvers: Map void> | undefined; + // Pending permission requests from stdio control_request messages + private _pendingPermissionRequests: Map; + suggestions?: any[]; + toolUseId: string; + }> = new Map(); private _currentConversation: Array<{ timestamp: string, messageType: string, data: any }> = []; private _conversationStartTime: string | undefined; private _conversationIndex: Array<{ @@ -496,9 +501,12 @@ class ClaudeChatProvider { }); // Build command arguments with session management + // Use stream-json for both input and output to enable bidirectional communication + // This is required for stdio-based permission prompts const args = [ - '-p', - '--output-format', 'stream-json', '--verbose' + '--output-format', 'stream-json', + '--input-format', 'stream-json', + '--verbose' ]; // Get configuration @@ -506,16 +514,17 @@ class ClaudeChatProvider { const yoloMode = config.get('permissions.yoloMode', false); if (yoloMode) { - // Yolo mode: skip all permissions regardless of MCP config + // Yolo mode: skip all permissions args.push('--dangerously-skip-permissions'); } else { - // Add MCP configuration for permissions - const mcpConfigPath = this.getMCPConfigPath(); - if (mcpConfigPath) { - args.push('--mcp-config', this.convertToWSLPath(mcpConfigPath)); - args.push('--allowedTools', 'mcp__claude-code-chat-permissions__approval_prompt'); - args.push('--permission-prompt-tool', 'mcp__claude-code-chat-permissions__approval_prompt'); - } + // Use stdio-based permission prompts (no MCP server needed) + args.push('--permission-prompt-tool', 'stdio'); + } + + // Add MCP config if user has custom servers configured + const mcpConfigPath = this.getMCPConfigPath(); + if (mcpConfigPath) { + args.push('--mcp-config', this.convertToWSLPath(mcpConfigPath)); } // Add model selection if not using default @@ -585,10 +594,19 @@ class ClaudeChatProvider { // Store process reference for potential termination this._currentClaudeProcess = claudeProcess; - // Send the message to Claude's stdin (with mode prefixes if enabled) + // Send the message to Claude's stdin as JSON (stream-json input format) + // Don't end stdin yet - we need to keep it open for permission responses if (claudeProcess.stdin) { - claudeProcess.stdin.write(actualMessage + '\n'); - claudeProcess.stdin.end(); + const userMessage = { + type: 'user', + session_id: this._currentSessionId || '', + message: { + role: 'user', + content: [{ type: 'text', text: actualMessage }] + }, + parent_tool_use_id: null + }; + claudeProcess.stdin.write(JSON.stringify(userMessage) + '\n'); } let rawOutput = ''; @@ -606,6 +624,22 @@ class ClaudeChatProvider { if (line.trim()) { try { const jsonData = JSON.parse(line.trim()); + + // Handle control_request messages (permission requests via stdio) + if (jsonData.type === 'control_request') { + this._handleControlRequest(jsonData, claudeProcess).catch(err => { + console.error('Error handling control request:', err); + }); + continue; + } + + // Handle result message - end stdin when done + if (jsonData.type === 'result') { + if (claudeProcess.stdin && !claudeProcess.stdin.destroyed) { + claudeProcess.stdin.end(); + } + } + this._processJsonStreamData(jsonData); } catch (error) { console.log('Failed to parse JSON line:', line, error); @@ -632,6 +666,9 @@ class ClaudeChatProvider { // Clear process reference this._currentClaudeProcess = undefined; + // Cancel any pending permission requests (process is gone) + this._cancelPendingPermissionRequests(); + // Clear loading indicator and set processing to false this._postMessage({ type: 'clearLoading' @@ -665,6 +702,9 @@ class ClaudeChatProvider { // Clear process reference this._currentClaudeProcess = undefined; + // Cancel any pending permission requests (process is gone) + this._cancelPendingPermissionRequests(); + this._postMessage({ type: 'clearLoading' }); @@ -1270,10 +1310,9 @@ class ClaudeChatProvider { console.log(`Created MCP config directory at: ${mcpConfigDir}`); } - // Create or update mcp-servers.json with permissions server, preserving existing servers + // Create or update mcp-servers.json, preserving user's custom servers + // Note: Permissions are now handled via stdio, not MCP const mcpConfigPath = path.join(mcpConfigDir, 'mcp-servers.json'); - const mcpPermissionsPath = this.convertToWSLPath(path.join(this._extensionUri.fsPath, 'mcp-permissions.js')); - const permissionRequestsPath = this.convertToWSLPath(path.join(storagePath, 'permission-requests')); // Load existing config or create new one let mcpConfig: any = { mcpServers: {} }; @@ -1292,14 +1331,11 @@ class ClaudeChatProvider { mcpConfig.mcpServers = {}; } - // Add or update the permissions server entry - mcpConfig.mcpServers['claude-code-chat-permissions'] = { - command: 'node', - args: [mcpPermissionsPath], - env: { - CLAUDE_PERMISSIONS_PATH: permissionRequestsPath - } - }; + // Remove old permissions server if it exists (migrating from file-based to stdio) + if (mcpConfig.mcpServers['claude-code-chat-permissions']) { + delete mcpConfig.mcpServers['claude-code-chat-permissions']; + console.log('Removed legacy permissions MCP server (now using stdio)'); + } const configContent = new TextEncoder().encode(JSON.stringify(mcpConfig, null, 2)); await vscode.workspace.fs.writeFile(mcpConfigUri, configContent); @@ -1310,180 +1346,293 @@ class ClaudeChatProvider { } } - private async _initializePermissions(): Promise { + /** + * Check if a tool is pre-approved in local permissions + */ + private async _isToolPreApproved(toolName: string, input: Record): Promise { try { - - if (this._permissionWatcher) { - this._permissionWatcher.dispose(); - this._permissionWatcher = undefined; - } - const storagePath = this._context.storageUri?.fsPath; - if (!storagePath) { return; } + if (!storagePath) return false; - // Create permission requests directory - this._permissionRequestsPath = path.join(path.join(storagePath, 'permission-requests')); - try { - await vscode.workspace.fs.stat(vscode.Uri.file(this._permissionRequestsPath)); - } catch { - await vscode.workspace.fs.createDirectory(vscode.Uri.file(this._permissionRequestsPath)); - console.log(`Created permission requests directory at: ${this._permissionRequestsPath}`); - } - - console.log("DIRECTORY-----", this._permissionRequestsPath) - - // Set up file watcher for *.request files - this._permissionWatcher = vscode.workspace.createFileSystemWatcher( - new vscode.RelativePattern(this._permissionRequestsPath, '*.request') - ); - - this._permissionWatcher.onDidCreate(async (uri) => { - // Only handle file scheme URIs, ignore vscode-userdata scheme - if (uri.scheme === 'file') { - await this._handlePermissionRequest(uri); - } - }); - - this._disposables.push(this._permissionWatcher); - - } catch (error: any) { - console.error('Failed to initialize permissions:', error.message); - } - } - - private async _handlePermissionRequest(requestUri: vscode.Uri): Promise { - try { - // Read the request file - const content = await vscode.workspace.fs.readFile(requestUri); - const request = JSON.parse(new TextDecoder().decode(content)); - - // Show permission dialog - const approved = await this._showPermissionDialog(request); - - // Write response file - const responseFile = requestUri.fsPath.replace('.request', '.response'); - const response = { - id: request.id, - approved: approved, - timestamp: new Date().toISOString() - }; - - const responseContent = new TextEncoder().encode(JSON.stringify(response)); - await vscode.workspace.fs.writeFile(vscode.Uri.file(responseFile), responseContent); - - // Clean up request file - await vscode.workspace.fs.delete(requestUri); - - } catch (error: any) { - console.error('Failed to handle permission request:', error.message); - } - } - - private async _showPermissionDialog(request: any): Promise { - const toolName = request.tool || 'Unknown Tool'; - - // Generate pattern for Bash commands - let pattern = undefined; - if (toolName === 'Bash' && request.input?.command) { - pattern = this.getCommandPattern(request.input.command); - } - - // Send permission request to the UI - this._sendAndSaveMessage({ - type: 'permissionRequest', - data: { - id: request.id, - tool: toolName, - input: request.input, - pattern: pattern - } - }); - - // Wait for response from UI - return new Promise((resolve) => { - // Store the resolver so we can call it when we get the response - this._pendingPermissionResolvers = this._pendingPermissionResolvers || new Map(); - this._pendingPermissionResolvers.set(request.id, resolve); - }); - } - - private _handlePermissionResponse(id: string, approved: boolean, alwaysAllow?: boolean): void { - if (this._pendingPermissionResolvers && this._pendingPermissionResolvers.has(id)) { - const resolver = this._pendingPermissionResolvers.get(id); - if (resolver) { - resolver(approved); - this._pendingPermissionResolvers.delete(id); - - // Handle always allow setting - if (alwaysAllow && approved) { - void this._saveAlwaysAllowPermission(id); - } - } - } - } - - private async _saveAlwaysAllowPermission(requestId: string): Promise { - try { - // Read the original request to get tool name and input - const storagePath = this._context.storageUri?.fsPath; - if (!storagePath) return; - - const requestFileUri = vscode.Uri.file(path.join(storagePath, 'permission-requests', `${requestId}.request`)); - - let requestContent: Uint8Array; - try { - requestContent = await vscode.workspace.fs.readFile(requestFileUri); - } catch { - return; // Request file doesn't exist - } - - const request = JSON.parse(new TextDecoder().decode(requestContent)); - - // Load existing workspace permissions - const permissionsUri = vscode.Uri.file(path.join(storagePath, 'permission-requests', 'permissions.json')); + const permissionsUri = vscode.Uri.file(path.join(storagePath, 'permissions', 'permissions.json')); let permissions: any = { alwaysAllow: {} }; try { const content = await vscode.workspace.fs.readFile(permissionsUri); permissions = JSON.parse(new TextDecoder().decode(content)); } catch { - // File doesn't exist yet, use default permissions + return false; // No permissions file } - // Add the new permission - const toolName = request.tool; - if (toolName === 'Bash' && request.input?.command) { - // For Bash, store the command pattern + const toolPermission = permissions.alwaysAllow?.[toolName]; + + if (toolPermission === true) { + // Tool is fully approved (all commands/inputs) + return true; + } + + if (Array.isArray(toolPermission) && toolName === 'Bash' && input.command) { + // Check if the command matches any approved pattern + const command = (input.command as string).trim(); + for (const pattern of toolPermission) { + if (this._matchesPattern(command, pattern)) { + return true; + } + } + } + + return false; + } catch (error) { + console.error('Error checking pre-approved permissions:', error); + return false; + } + } + + /** + * Check if a command matches a permission pattern (supports * wildcard) + */ + private _matchesPattern(command: string, pattern: string): boolean { + if (pattern === command) return true; + + // Handle wildcard patterns like "npm install *" + if (pattern.endsWith(' *')) { + const prefix = pattern.slice(0, -1); // Remove the * + return command.startsWith(prefix); + } + + // Handle exact match patterns + return false; + } + + /** + * Handle control_request messages from Claude CLI via stdio + * This is the new permission flow that replaces the MCP file-based approach + */ + private async _handleControlRequest(controlRequest: any, claudeProcess: cp.ChildProcess): Promise { + const request = controlRequest.request; + const requestId = controlRequest.request_id; + + // Only handle can_use_tool requests (permission requests) + if (request?.subtype !== 'can_use_tool') { + console.log('Ignoring non-permission control request:', request?.subtype); + return; + } + + const toolName = request.tool_name || 'Unknown Tool'; + const input = request.input || {}; + const suggestions = request.permission_suggestions; + const toolUseId = request.tool_use_id; + + console.log(`Permission request for tool: ${toolName}, requestId: ${requestId}`); + + // Check if this tool is pre-approved + const isPreApproved = await this._isToolPreApproved(toolName, input); + + if (isPreApproved) { + console.log(`Tool ${toolName} is pre-approved, auto-allowing`); + // Auto-approve without showing UI + this._sendPermissionResponse(requestId, true, { + requestId, + toolName, + input, + suggestions, + toolUseId + }, false); + return; + } + + // Store the request data so we can respond later + this._pendingPermissionRequests.set(requestId, { + requestId, + toolName, + input, + suggestions, + toolUseId + }); + + // Generate pattern for Bash commands (for display purposes) + let pattern: string | undefined; + if (toolName === 'Bash' && input.command) { + pattern = this.getCommandPattern(input.command as string); + } + + // Send permission request to the UI with pending status + this._sendAndSaveMessage({ + type: 'permissionRequest', + data: { + id: requestId, + tool: toolName, + input: input, + pattern: pattern, + suggestions: suggestions, + decisionReason: request.decision_reason, + blockedPath: request.blocked_path, + status: 'pending' + } + }); + } + + /** + * Send permission response back to Claude CLI via stdin + */ + private _sendPermissionResponse( + requestId: string, + approved: boolean, + pendingRequest: { + requestId: string; + toolName: string; + input: Record; + suggestions?: any[]; + toolUseId: string; + }, + alwaysAllow?: boolean + ): void { + if (!this._currentClaudeProcess?.stdin || this._currentClaudeProcess.stdin.destroyed) { + console.error('Cannot send permission response: stdin not available'); + return; + } + + let response: any; + if (approved) { + response = { + type: 'control_response', + response: { + subtype: 'success', + request_id: requestId, + response: { + behavior: 'allow', + updatedInput: pendingRequest.input, + // Pass back suggestions if user chose "always allow" + updatedPermissions: alwaysAllow ? pendingRequest.suggestions : undefined, + toolUseID: pendingRequest.toolUseId + } + } + }; + } else { + response = { + type: 'control_response', + response: { + subtype: 'success', + request_id: requestId, + response: { + behavior: 'deny', + message: 'User denied permission', + interrupt: true, + toolUseID: pendingRequest.toolUseId + } + } + }; + } + + const responseJson = JSON.stringify(response) + '\n'; + console.log('Sending permission response:', responseJson); + console.log('Always allow:', alwaysAllow, 'Suggestions included:', !!pendingRequest.suggestions); + this._currentClaudeProcess.stdin.write(responseJson); + } + + private async _initializePermissions(): Promise { + // No longer needed - permissions are handled via stdio + // This method is kept for compatibility but does nothing + } + + /** + * Handle permission response from webview UI + * Sends control_response back to Claude CLI via stdin + */ + private _handlePermissionResponse(id: string, approved: boolean, alwaysAllow?: boolean): void { + const pendingRequest = this._pendingPermissionRequests.get(id); + if (!pendingRequest) { + console.error('No pending permission request found for id:', id); + return; + } + + // Remove from pending requests + this._pendingPermissionRequests.delete(id); + + // Send the response to Claude via stdin + this._sendPermissionResponse(id, approved, pendingRequest, alwaysAllow); + + // Update the permission request status in UI + this._postMessage({ + type: 'updatePermissionStatus', + data: { + id: id, + status: approved ? 'approved' : 'denied' + } + }); + + // Also save to local permissions.json for UI display purposes + if (alwaysAllow && approved) { + void this._saveLocalPermission(pendingRequest.toolName, pendingRequest.input); + } + } + + /** + * Cancel all pending permission requests (called when process ends) + */ + private _cancelPendingPermissionRequests(): void { + for (const [id, _request] of this._pendingPermissionRequests) { + this._postMessage({ + type: 'updatePermissionStatus', + data: { + id: id, + status: 'cancelled' + } + }); + } + this._pendingPermissionRequests.clear(); + } + + /** + * Save permission to local storage for UI display in settings + * Note: The actual "always allow" is handled by Claude via updatedPermissions + */ + private async _saveLocalPermission(toolName: string, input: Record): Promise { + try { + const storagePath = this._context.storageUri?.fsPath; + if (!storagePath) return; + + // Ensure permissions directory exists + const permissionsDir = path.join(storagePath, 'permissions'); + try { + await vscode.workspace.fs.stat(vscode.Uri.file(permissionsDir)); + } catch { + await vscode.workspace.fs.createDirectory(vscode.Uri.file(permissionsDir)); + } + + // Load existing permissions + const permissionsUri = vscode.Uri.file(path.join(permissionsDir, 'permissions.json')); + let permissions: any = { alwaysAllow: {} }; + + try { + const content = await vscode.workspace.fs.readFile(permissionsUri); + permissions = JSON.parse(new TextDecoder().decode(content)); + } catch { + // File doesn't exist yet + } + + // Add the permission + if (toolName === 'Bash' && input.command) { if (!permissions.alwaysAllow[toolName]) { permissions.alwaysAllow[toolName] = []; } if (Array.isArray(permissions.alwaysAllow[toolName])) { - const command = request.input.command.trim(); - const pattern = this.getCommandPattern(command); + const pattern = this.getCommandPattern(input.command as string); if (!permissions.alwaysAllow[toolName].includes(pattern)) { permissions.alwaysAllow[toolName].push(pattern); } } } else { - // For other tools, allow all instances permissions.alwaysAllow[toolName] = true; } - // Ensure permissions directory exists - const permissionsDir = vscode.Uri.file(path.dirname(permissionsUri.fsPath)); - try { - await vscode.workspace.fs.stat(permissionsDir); - } catch { - await vscode.workspace.fs.createDirectory(permissionsDir); - } - - // Save the permissions + // Save permissions const permissionsContent = new TextEncoder().encode(JSON.stringify(permissions, null, 2)); await vscode.workspace.fs.writeFile(permissionsUri, permissionsContent); - console.log(`Saved always-allow permission for ${toolName}`); + console.log(`Saved local permission for ${toolName}`); } catch (error) { - console.error('Error saving always-allow permission:', error); + console.error('Error saving local permission:', error); } } @@ -1592,7 +1741,7 @@ class ClaudeChatProvider { return; } - const permissionsUri = vscode.Uri.file(path.join(storagePath, 'permission-requests', 'permissions.json')); + const permissionsUri = vscode.Uri.file(path.join(storagePath, 'permissions', 'permissions.json')); let permissions: any = { alwaysAllow: {} }; try { @@ -1620,7 +1769,7 @@ class ClaudeChatProvider { const storagePath = this._context.storageUri?.fsPath; if (!storagePath) return; - const permissionsUri = vscode.Uri.file(path.join(storagePath, 'permission-requests', 'permissions.json')); + const permissionsUri = vscode.Uri.file(path.join(storagePath, 'permissions', 'permissions.json')); let permissions: any = { alwaysAllow: {} }; try { @@ -1666,7 +1815,7 @@ class ClaudeChatProvider { const storagePath = this._context.storageUri?.fsPath; if (!storagePath) return; - const permissionsUri = vscode.Uri.file(path.join(storagePath, 'permission-requests', 'permissions.json')); + const permissionsUri = vscode.Uri.file(path.join(storagePath, 'permissions', 'permissions.json')); let permissions: any = { alwaysAllow: {} }; try { @@ -2293,10 +2442,18 @@ class ClaudeChatProvider { } // For tool messages, include the message index so Open Diff buttons work - const messageData = (message.messageType === 'toolUse' || message.messageType === 'toolResult') + let messageData = (message.messageType === 'toolUse' || message.messageType === 'toolResult') ? { ...message.data, messageIndex: i } : message.data; + // For permission requests loaded from history, mark pending ones as expired + // ONLY if there's no active Claude process (i.e., VS Code was restarted) + if (message.messageType === 'permissionRequest' && + message.data?.status === 'pending' && + !this._currentClaudeProcess) { + messageData = { ...message.data, status: 'expired' }; + } + this._postMessage({ type: message.messageType, data: messageData @@ -2328,6 +2485,15 @@ class ClaudeChatProvider { data: { isProcessing: this._isProcessing, requestStartTime } }); } + + // Mark any pending permission requests as expired ONLY if there's no active Claude process + // (i.e., VS Code was restarted, not just the panel was closed/reopened) + if (!this._currentClaudeProcess) { + this._postMessage({ + type: 'expirePendingPermissions' + }); + } + // Send ready message after conversation is loaded this._sendReadyMessage(); }, 50); diff --git a/src/script.ts b/src/script.ts index d8dfd53..2d34c42 100644 --- a/src/script.ts +++ b/src/script.ts @@ -2265,6 +2265,12 @@ const getScript = (isTelemetryEnabled: boolean) => `