mirror of
https://github.com/andrepimenta/claude-code-chat.git
synced 2025-12-13 13:49:47 +00:00
Migrate permission system from MCP file-based to stdio-based
Replace MCP permission server with stdio-based permission flow that communicates directly with Claude CLI via stdin/stdout. This simplifies the architecture and fixes permission expiration issues. Key changes: - Use --permission-prompt-tool stdio and --input-format stream-json - Handle control_request messages for permission prompts - Send control_response via stdin to approve/deny - Check local permissions for auto-approval of pre-approved tools - Only expire pending permissions when VS Code restarts, not panel close 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -11,4 +11,6 @@ vsc-extension-quickstart.md
|
|||||||
**/.vscode-test.*
|
**/.vscode-test.*
|
||||||
backup
|
backup
|
||||||
.claude
|
.claude
|
||||||
claude-code-chat-permissions-mcp/**
|
claude-code-chat-permissions-mcp/**
|
||||||
|
node_modules
|
||||||
|
mcp-permissions.js
|
||||||
522
src/extension.ts
522
src/extension.ts
@@ -124,9 +124,14 @@ class ClaudeChatProvider {
|
|||||||
private _backupRepoPath: string | undefined;
|
private _backupRepoPath: string | undefined;
|
||||||
private _commits: Array<{ id: string, sha: string, message: string, timestamp: string }> = [];
|
private _commits: Array<{ id: string, sha: string, message: string, timestamp: string }> = [];
|
||||||
private _conversationsPath: string | undefined;
|
private _conversationsPath: string | undefined;
|
||||||
private _permissionRequestsPath: string | undefined;
|
// Pending permission requests from stdio control_request messages
|
||||||
private _permissionWatcher: vscode.FileSystemWatcher | undefined;
|
private _pendingPermissionRequests: Map<string, {
|
||||||
private _pendingPermissionResolvers: Map<string, (approved: boolean) => void> | undefined;
|
requestId: string;
|
||||||
|
toolName: string;
|
||||||
|
input: Record<string, unknown>;
|
||||||
|
suggestions?: any[];
|
||||||
|
toolUseId: string;
|
||||||
|
}> = new Map();
|
||||||
private _currentConversation: Array<{ timestamp: string, messageType: string, data: any }> = [];
|
private _currentConversation: Array<{ timestamp: string, messageType: string, data: any }> = [];
|
||||||
private _conversationStartTime: string | undefined;
|
private _conversationStartTime: string | undefined;
|
||||||
private _conversationIndex: Array<{
|
private _conversationIndex: Array<{
|
||||||
@@ -496,9 +501,12 @@ class ClaudeChatProvider {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Build command arguments with session management
|
// 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 = [
|
const args = [
|
||||||
'-p',
|
'--output-format', 'stream-json',
|
||||||
'--output-format', 'stream-json', '--verbose'
|
'--input-format', 'stream-json',
|
||||||
|
'--verbose'
|
||||||
];
|
];
|
||||||
|
|
||||||
// Get configuration
|
// Get configuration
|
||||||
@@ -506,16 +514,17 @@ class ClaudeChatProvider {
|
|||||||
const yoloMode = config.get<boolean>('permissions.yoloMode', false);
|
const yoloMode = config.get<boolean>('permissions.yoloMode', false);
|
||||||
|
|
||||||
if (yoloMode) {
|
if (yoloMode) {
|
||||||
// Yolo mode: skip all permissions regardless of MCP config
|
// Yolo mode: skip all permissions
|
||||||
args.push('--dangerously-skip-permissions');
|
args.push('--dangerously-skip-permissions');
|
||||||
} else {
|
} else {
|
||||||
// Add MCP configuration for permissions
|
// Use stdio-based permission prompts (no MCP server needed)
|
||||||
const mcpConfigPath = this.getMCPConfigPath();
|
args.push('--permission-prompt-tool', 'stdio');
|
||||||
if (mcpConfigPath) {
|
}
|
||||||
args.push('--mcp-config', this.convertToWSLPath(mcpConfigPath));
|
|
||||||
args.push('--allowedTools', 'mcp__claude-code-chat-permissions__approval_prompt');
|
// Add MCP config if user has custom servers configured
|
||||||
args.push('--permission-prompt-tool', 'mcp__claude-code-chat-permissions__approval_prompt');
|
const mcpConfigPath = this.getMCPConfigPath();
|
||||||
}
|
if (mcpConfigPath) {
|
||||||
|
args.push('--mcp-config', this.convertToWSLPath(mcpConfigPath));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add model selection if not using default
|
// Add model selection if not using default
|
||||||
@@ -585,10 +594,19 @@ class ClaudeChatProvider {
|
|||||||
// Store process reference for potential termination
|
// Store process reference for potential termination
|
||||||
this._currentClaudeProcess = claudeProcess;
|
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) {
|
if (claudeProcess.stdin) {
|
||||||
claudeProcess.stdin.write(actualMessage + '\n');
|
const userMessage = {
|
||||||
claudeProcess.stdin.end();
|
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 = '';
|
let rawOutput = '';
|
||||||
@@ -606,6 +624,22 @@ class ClaudeChatProvider {
|
|||||||
if (line.trim()) {
|
if (line.trim()) {
|
||||||
try {
|
try {
|
||||||
const jsonData = JSON.parse(line.trim());
|
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);
|
this._processJsonStreamData(jsonData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('Failed to parse JSON line:', line, error);
|
console.log('Failed to parse JSON line:', line, error);
|
||||||
@@ -632,6 +666,9 @@ class ClaudeChatProvider {
|
|||||||
// Clear process reference
|
// Clear process reference
|
||||||
this._currentClaudeProcess = undefined;
|
this._currentClaudeProcess = undefined;
|
||||||
|
|
||||||
|
// Cancel any pending permission requests (process is gone)
|
||||||
|
this._cancelPendingPermissionRequests();
|
||||||
|
|
||||||
// Clear loading indicator and set processing to false
|
// Clear loading indicator and set processing to false
|
||||||
this._postMessage({
|
this._postMessage({
|
||||||
type: 'clearLoading'
|
type: 'clearLoading'
|
||||||
@@ -665,6 +702,9 @@ class ClaudeChatProvider {
|
|||||||
// Clear process reference
|
// Clear process reference
|
||||||
this._currentClaudeProcess = undefined;
|
this._currentClaudeProcess = undefined;
|
||||||
|
|
||||||
|
// Cancel any pending permission requests (process is gone)
|
||||||
|
this._cancelPendingPermissionRequests();
|
||||||
|
|
||||||
this._postMessage({
|
this._postMessage({
|
||||||
type: 'clearLoading'
|
type: 'clearLoading'
|
||||||
});
|
});
|
||||||
@@ -1270,10 +1310,9 @@ class ClaudeChatProvider {
|
|||||||
console.log(`Created MCP config directory at: ${mcpConfigDir}`);
|
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 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
|
// Load existing config or create new one
|
||||||
let mcpConfig: any = { mcpServers: {} };
|
let mcpConfig: any = { mcpServers: {} };
|
||||||
@@ -1292,14 +1331,11 @@ class ClaudeChatProvider {
|
|||||||
mcpConfig.mcpServers = {};
|
mcpConfig.mcpServers = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add or update the permissions server entry
|
// Remove old permissions server if it exists (migrating from file-based to stdio)
|
||||||
mcpConfig.mcpServers['claude-code-chat-permissions'] = {
|
if (mcpConfig.mcpServers['claude-code-chat-permissions']) {
|
||||||
command: 'node',
|
delete mcpConfig.mcpServers['claude-code-chat-permissions'];
|
||||||
args: [mcpPermissionsPath],
|
console.log('Removed legacy permissions MCP server (now using stdio)');
|
||||||
env: {
|
}
|
||||||
CLAUDE_PERMISSIONS_PATH: permissionRequestsPath
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const configContent = new TextEncoder().encode(JSON.stringify(mcpConfig, null, 2));
|
const configContent = new TextEncoder().encode(JSON.stringify(mcpConfig, null, 2));
|
||||||
await vscode.workspace.fs.writeFile(mcpConfigUri, configContent);
|
await vscode.workspace.fs.writeFile(mcpConfigUri, configContent);
|
||||||
@@ -1310,180 +1346,293 @@ class ClaudeChatProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _initializePermissions(): Promise<void> {
|
/**
|
||||||
|
* Check if a tool is pre-approved in local permissions
|
||||||
|
*/
|
||||||
|
private async _isToolPreApproved(toolName: string, input: Record<string, unknown>): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
if (this._permissionWatcher) {
|
|
||||||
this._permissionWatcher.dispose();
|
|
||||||
this._permissionWatcher = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const storagePath = this._context.storageUri?.fsPath;
|
const storagePath = this._context.storageUri?.fsPath;
|
||||||
if (!storagePath) { return; }
|
if (!storagePath) return false;
|
||||||
|
|
||||||
// Create permission requests directory
|
const permissionsUri = vscode.Uri.file(path.join(storagePath, 'permissions', 'permissions.json'));
|
||||||
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<void> {
|
|
||||||
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<boolean> {
|
|
||||||
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<void> {
|
|
||||||
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'));
|
|
||||||
let permissions: any = { alwaysAllow: {} };
|
let permissions: any = { alwaysAllow: {} };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = await vscode.workspace.fs.readFile(permissionsUri);
|
const content = await vscode.workspace.fs.readFile(permissionsUri);
|
||||||
permissions = JSON.parse(new TextDecoder().decode(content));
|
permissions = JSON.parse(new TextDecoder().decode(content));
|
||||||
} catch {
|
} catch {
|
||||||
// File doesn't exist yet, use default permissions
|
return false; // No permissions file
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the new permission
|
const toolPermission = permissions.alwaysAllow?.[toolName];
|
||||||
const toolName = request.tool;
|
|
||||||
if (toolName === 'Bash' && request.input?.command) {
|
if (toolPermission === true) {
|
||||||
// For Bash, store the command pattern
|
// 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<void> {
|
||||||
|
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<string, unknown>;
|
||||||
|
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<void> {
|
||||||
|
// 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<string, unknown>): Promise<void> {
|
||||||
|
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]) {
|
if (!permissions.alwaysAllow[toolName]) {
|
||||||
permissions.alwaysAllow[toolName] = [];
|
permissions.alwaysAllow[toolName] = [];
|
||||||
}
|
}
|
||||||
if (Array.isArray(permissions.alwaysAllow[toolName])) {
|
if (Array.isArray(permissions.alwaysAllow[toolName])) {
|
||||||
const command = request.input.command.trim();
|
const pattern = this.getCommandPattern(input.command as string);
|
||||||
const pattern = this.getCommandPattern(command);
|
|
||||||
if (!permissions.alwaysAllow[toolName].includes(pattern)) {
|
if (!permissions.alwaysAllow[toolName].includes(pattern)) {
|
||||||
permissions.alwaysAllow[toolName].push(pattern);
|
permissions.alwaysAllow[toolName].push(pattern);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// For other tools, allow all instances
|
|
||||||
permissions.alwaysAllow[toolName] = true;
|
permissions.alwaysAllow[toolName] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure permissions directory exists
|
// Save permissions
|
||||||
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
|
|
||||||
const permissionsContent = new TextEncoder().encode(JSON.stringify(permissions, null, 2));
|
const permissionsContent = new TextEncoder().encode(JSON.stringify(permissions, null, 2));
|
||||||
await vscode.workspace.fs.writeFile(permissionsUri, permissionsContent);
|
await vscode.workspace.fs.writeFile(permissionsUri, permissionsContent);
|
||||||
|
|
||||||
console.log(`Saved always-allow permission for ${toolName}`);
|
console.log(`Saved local permission for ${toolName}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving always-allow permission:', error);
|
console.error('Error saving local permission:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1592,7 +1741,7 @@ class ClaudeChatProvider {
|
|||||||
return;
|
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: {} };
|
let permissions: any = { alwaysAllow: {} };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1620,7 +1769,7 @@ class ClaudeChatProvider {
|
|||||||
const storagePath = this._context.storageUri?.fsPath;
|
const storagePath = this._context.storageUri?.fsPath;
|
||||||
if (!storagePath) return;
|
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: {} };
|
let permissions: any = { alwaysAllow: {} };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1666,7 +1815,7 @@ class ClaudeChatProvider {
|
|||||||
const storagePath = this._context.storageUri?.fsPath;
|
const storagePath = this._context.storageUri?.fsPath;
|
||||||
if (!storagePath) return;
|
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: {} };
|
let permissions: any = { alwaysAllow: {} };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -2293,10 +2442,18 @@ class ClaudeChatProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// For tool messages, include the message index so Open Diff buttons work
|
// 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, messageIndex: i }
|
||||||
: message.data;
|
: 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({
|
this._postMessage({
|
||||||
type: message.messageType,
|
type: message.messageType,
|
||||||
data: messageData
|
data: messageData
|
||||||
@@ -2328,6 +2485,15 @@ class ClaudeChatProvider {
|
|||||||
data: { isProcessing: this._isProcessing, requestStartTime }
|
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
|
// Send ready message after conversation is loaded
|
||||||
this._sendReadyMessage();
|
this._sendReadyMessage();
|
||||||
}, 50);
|
}, 50);
|
||||||
|
|||||||
148
src/script.ts
148
src/script.ts
@@ -2265,6 +2265,12 @@ const getScript = (isTelemetryEnabled: boolean) => `<script>
|
|||||||
case 'permissionRequest':
|
case 'permissionRequest':
|
||||||
addPermissionRequestMessage(message.data);
|
addPermissionRequestMessage(message.data);
|
||||||
break;
|
break;
|
||||||
|
case 'updatePermissionStatus':
|
||||||
|
updatePermissionStatus(message.data.id, message.data.status);
|
||||||
|
break;
|
||||||
|
case 'expirePendingPermissions':
|
||||||
|
expireAllPendingPermissions();
|
||||||
|
break;
|
||||||
case 'mcpServers':
|
case 'mcpServers':
|
||||||
displayMCPServers(message.data);
|
displayMCPServers(message.data);
|
||||||
break;
|
break;
|
||||||
@@ -2289,9 +2295,12 @@ const getScript = (isTelemetryEnabled: boolean) => `<script>
|
|||||||
|
|
||||||
const messageDiv = document.createElement('div');
|
const messageDiv = document.createElement('div');
|
||||||
messageDiv.className = 'message permission-request';
|
messageDiv.className = 'message permission-request';
|
||||||
|
messageDiv.id = \`permission-\${data.id}\`;
|
||||||
|
messageDiv.dataset.status = data.status || 'pending';
|
||||||
|
|
||||||
const toolName = data.tool || 'Unknown Tool';
|
const toolName = data.tool || 'Unknown Tool';
|
||||||
|
const status = data.status || 'pending';
|
||||||
|
|
||||||
// Create always allow button text with command styling for Bash
|
// Create always allow button text with command styling for Bash
|
||||||
let alwaysAllowText = \`Always allow \${toolName}\`;
|
let alwaysAllowText = \`Always allow \${toolName}\`;
|
||||||
let alwaysAllowTooltip = '';
|
let alwaysAllowTooltip = '';
|
||||||
@@ -2303,37 +2312,122 @@ const getScript = (isTelemetryEnabled: boolean) => `<script>
|
|||||||
alwaysAllowText = \`Always allow <code>\${truncatedPattern}</code>\`;
|
alwaysAllowText = \`Always allow <code>\${truncatedPattern}</code>\`;
|
||||||
alwaysAllowTooltip = displayPattern.length > 30 ? \`title="\${displayPattern}"\` : '';
|
alwaysAllowTooltip = displayPattern.length > 30 ? \`title="\${displayPattern}"\` : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
messageDiv.innerHTML = \`
|
// Show different content based on status
|
||||||
<div class="permission-header">
|
let contentHtml = '';
|
||||||
<span class="icon">🔐</span>
|
if (status === 'pending') {
|
||||||
<span>Permission Required</span>
|
contentHtml = \`
|
||||||
<div class="permission-menu">
|
<div class="permission-header">
|
||||||
<button class="permission-menu-btn" onclick="togglePermissionMenu('\${data.id}')" title="More options">⋮</button>
|
<span class="icon">🔐</span>
|
||||||
<div class="permission-menu-dropdown" id="permissionMenu-\${data.id}" style="display: none;">
|
<span>Permission Required</span>
|
||||||
<button class="permission-menu-item" onclick="enableYoloMode('\${data.id}')">
|
<div class="permission-menu">
|
||||||
<span class="menu-icon">⚡</span>
|
<button class="permission-menu-btn" onclick="togglePermissionMenu('\${data.id}')" title="More options">⋮</button>
|
||||||
<div class="menu-content">
|
<div class="permission-menu-dropdown" id="permissionMenu-\${data.id}" style="display: none;">
|
||||||
<span class="menu-title">Enable YOLO Mode</span>
|
<button class="permission-menu-item" onclick="enableYoloMode('\${data.id}')">
|
||||||
<span class="menu-subtitle">Auto-allow all permissions</span>
|
<span class="menu-icon">⚡</span>
|
||||||
</div>
|
<div class="menu-content">
|
||||||
</button>
|
<span class="menu-title">Enable YOLO Mode</span>
|
||||||
|
<span class="menu-subtitle">Auto-allow all permissions</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="permission-content">
|
||||||
<div class="permission-content">
|
<p>Allow <strong>\${toolName}</strong> to execute the tool call above?</p>
|
||||||
<p>Allow <strong>\${toolName}</strong> to execute the tool call above?</p>
|
<div class="permission-buttons">
|
||||||
<div class="permission-buttons">
|
<button class="btn deny" onclick="respondToPermission('\${data.id}', false)">Deny</button>
|
||||||
<button class="btn deny" onclick="respondToPermission('\${data.id}', false)">Deny</button>
|
<button class="btn always-allow" onclick="respondToPermission('\${data.id}', true, true)" \${alwaysAllowTooltip}>\${alwaysAllowText}</button>
|
||||||
<button class="btn always-allow" onclick="respondToPermission('\${data.id}', true, true)" \${alwaysAllowTooltip}>\${alwaysAllowText}</button>
|
<button class="btn allow" onclick="respondToPermission('\${data.id}', true)">Allow</button>
|
||||||
<button class="btn allow" onclick="respondToPermission('\${data.id}', true)">Allow</button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
\`;
|
||||||
\`;
|
} else if (status === 'approved') {
|
||||||
|
contentHtml = \`
|
||||||
|
<div class="permission-header">
|
||||||
|
<span class="icon">🔐</span>
|
||||||
|
<span>Permission Required</span>
|
||||||
|
</div>
|
||||||
|
<div class="permission-content">
|
||||||
|
<p>Allow <strong>\${toolName}</strong> to execute the tool call above?</p>
|
||||||
|
<div class="permission-decision allowed">✅ You allowed this</div>
|
||||||
|
</div>
|
||||||
|
\`;
|
||||||
|
messageDiv.classList.add('permission-decided', 'allowed');
|
||||||
|
} else if (status === 'denied') {
|
||||||
|
contentHtml = \`
|
||||||
|
<div class="permission-header">
|
||||||
|
<span class="icon">🔐</span>
|
||||||
|
<span>Permission Required</span>
|
||||||
|
</div>
|
||||||
|
<div class="permission-content">
|
||||||
|
<p>Allow <strong>\${toolName}</strong> to execute the tool call above?</p>
|
||||||
|
<div class="permission-decision denied">❌ You denied this</div>
|
||||||
|
</div>
|
||||||
|
\`;
|
||||||
|
messageDiv.classList.add('permission-decided', 'denied');
|
||||||
|
} else if (status === 'cancelled' || status === 'expired') {
|
||||||
|
contentHtml = \`
|
||||||
|
<div class="permission-header">
|
||||||
|
<span class="icon">🔐</span>
|
||||||
|
<span>Permission Required</span>
|
||||||
|
</div>
|
||||||
|
<div class="permission-content">
|
||||||
|
<p>Allow <strong>\${toolName}</strong> to execute the tool call above?</p>
|
||||||
|
<div class="permission-decision expired">⏱️ This request expired</div>
|
||||||
|
</div>
|
||||||
|
\`;
|
||||||
|
messageDiv.classList.add('permission-decided', 'expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
messageDiv.innerHTML = contentHtml;
|
||||||
messagesDiv.appendChild(messageDiv);
|
messagesDiv.appendChild(messageDiv);
|
||||||
scrollToBottomIfNeeded(messagesDiv, shouldScroll);
|
scrollToBottomIfNeeded(messagesDiv, shouldScroll);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updatePermissionStatus(id, status) {
|
||||||
|
const permissionMsg = document.getElementById(\`permission-\${id}\`);
|
||||||
|
if (!permissionMsg) return;
|
||||||
|
|
||||||
|
permissionMsg.dataset.status = status;
|
||||||
|
const permissionContent = permissionMsg.querySelector('.permission-content');
|
||||||
|
const buttons = permissionMsg.querySelector('.permission-buttons');
|
||||||
|
const menuDiv = permissionMsg.querySelector('.permission-menu');
|
||||||
|
|
||||||
|
// Hide buttons and menu if present
|
||||||
|
if (buttons) buttons.style.display = 'none';
|
||||||
|
if (menuDiv) menuDiv.style.display = 'none';
|
||||||
|
|
||||||
|
// Remove existing decision div if any
|
||||||
|
const existingDecision = permissionContent.querySelector('.permission-decision');
|
||||||
|
if (existingDecision) existingDecision.remove();
|
||||||
|
|
||||||
|
// Add new decision div
|
||||||
|
const decisionDiv = document.createElement('div');
|
||||||
|
if (status === 'approved') {
|
||||||
|
decisionDiv.className = 'permission-decision allowed';
|
||||||
|
decisionDiv.innerHTML = '✅ You allowed this';
|
||||||
|
permissionMsg.classList.add('permission-decided', 'allowed');
|
||||||
|
} else if (status === 'denied') {
|
||||||
|
decisionDiv.className = 'permission-decision denied';
|
||||||
|
decisionDiv.innerHTML = '❌ You denied this';
|
||||||
|
permissionMsg.classList.add('permission-decided', 'denied');
|
||||||
|
} else if (status === 'cancelled' || status === 'expired') {
|
||||||
|
decisionDiv.className = 'permission-decision expired';
|
||||||
|
decisionDiv.innerHTML = '⏱️ This request expired';
|
||||||
|
permissionMsg.classList.add('permission-decided', 'expired');
|
||||||
|
}
|
||||||
|
permissionContent.appendChild(decisionDiv);
|
||||||
|
}
|
||||||
|
|
||||||
|
function expireAllPendingPermissions() {
|
||||||
|
document.querySelectorAll('.permission-request').forEach(permissionMsg => {
|
||||||
|
if (permissionMsg.dataset.status === 'pending') {
|
||||||
|
const id = permissionMsg.id.replace('permission-', '');
|
||||||
|
updatePermissionStatus(id, 'expired');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function respondToPermission(id, approved, alwaysAllow = false) {
|
function respondToPermission(id, approved, alwaysAllow = false) {
|
||||||
// Send response back to extension
|
// Send response back to extension
|
||||||
|
|||||||
@@ -302,6 +302,12 @@ const styles = `
|
|||||||
border: 1px solid rgba(231, 76, 60, 0.3);
|
border: 1px solid rgba(231, 76, 60, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.permission-decision.expired {
|
||||||
|
background-color: rgba(128, 128, 128, 0.15);
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
.permission-decided {
|
.permission-decided {
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -321,6 +327,11 @@ const styles = `
|
|||||||
background-color: var(--vscode-inputValidation-errorBackground);
|
background-color: var(--vscode-inputValidation-errorBackground);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.permission-decided.expired {
|
||||||
|
border-color: var(--vscode-panel-border);
|
||||||
|
background-color: rgba(128, 128, 128, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
/* Permissions Management */
|
/* Permissions Management */
|
||||||
.permissions-list {
|
.permissions-list {
|
||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
|
|||||||
Reference in New Issue
Block a user