diff --git a/src/core/Cline.ts b/src/core/Cline.ts index 64ad0ec..d6f1ac3 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -56,6 +56,15 @@ type UserContent = Array< Anthropic.TextBlockParam | Anthropic.ImageBlockParam | Anthropic.ToolUseBlockParam | Anthropic.ToolResultBlockParam > +// Add near the top of the file, after imports: +const ALLOWED_AUTO_EXECUTE_COMMANDS = [ + 'npm', + 'npx', + 'tsc', + 'git log', + 'git diff' +] as const + export class Cline { readonly taskId: string api: ApiHandler @@ -124,6 +133,14 @@ export class Cline { } } + protected isAllowedCommand(command?: string): boolean { + if (!command) return false; + const trimmedCommand = command.trim().toLowerCase(); + return ALLOWED_AUTO_EXECUTE_COMMANDS.some(prefix => + trimmedCommand.startsWith(prefix.toLowerCase()) + ); + } + // Storing task to disk for history private async ensureTaskDirectoryExists(): Promise { @@ -555,8 +572,8 @@ export class Cline { : [{ type: "text", text: lastMessage.content }] if (previousAssistantMessage && previousAssistantMessage.role === "assistant") { const assistantContent = Array.isArray(previousAssistantMessage.content) - ? previousAssistantMessage.content - : [{ type: "text", text: previousAssistantMessage.content }] + ? previousAssistantMessage.content + : [{ type: "text", text: previousAssistantMessage.content }] const toolUseBlocks = assistantContent.filter( (block) => block.type === "tool_use" @@ -839,7 +856,7 @@ export class Cline { // (have to do this for partial and complete since sending content in thinking tags to markdown renderer will automatically be removed) // Remove end substrings of (with optional line break after) and (with optional line break before) // - Needs to be separate since we dont want to remove the line break before the first tag // - Needs to happen before the xml parsing below @@ -1503,7 +1520,7 @@ export class Cline { const command: string | undefined = block.params.command try { if (block.partial) { - if (this.alwaysAllowExecute) { + if (this.alwaysAllowExecute && this.isAllowedCommand(command)) { await this.say("command", command, undefined, block.partial) } else { await this.ask("command", removeClosingTag("command", command), block.partial).catch( @@ -1520,7 +1537,9 @@ export class Cline { break } this.consecutiveMistakeCount = 0 - const didApprove = this.alwaysAllowExecute || (await askApproval("command", command)) + + const didApprove = (this.alwaysAllowExecute && this.isAllowedCommand(command)) || + (await askApproval("command", command)) if (!didApprove) { break } diff --git a/src/core/__tests__/Cline.test.ts b/src/core/__tests__/Cline.test.ts index 58ee2ff..fd2b3e9 100644 --- a/src/core/__tests__/Cline.test.ts +++ b/src/core/__tests__/Cline.test.ts @@ -318,5 +318,75 @@ describe('Cline', () => { expect(writeDisabledCline.alwaysAllowWrite).toBe(false); // The write operation would require approval in actual implementation }); - }); + }); + + describe('isAllowedCommand', () => { + let cline: any + + beforeEach(() => { + // Create a more complete mock provider + const mockProvider = { + context: { + globalStorageUri: { fsPath: '/mock/path' } + }, + postStateToWebview: jest.fn(), + postMessageToWebview: jest.fn(), + updateTaskHistory: jest.fn() + } + + // Mock the required dependencies + const mockApiConfig = { + getModel: () => ({ + id: 'claude-3-sonnet', + info: { supportsComputerUse: true } + }) + } + + // Create test instance with mocked constructor params + cline = new Cline( + mockProvider as any, + mockApiConfig as any, + undefined, // customInstructions + false, // alwaysAllowReadOnly + false, // alwaysAllowWrite + false, // alwaysAllowExecute + 'test task' // task + ) + + // Mock internal methods that are called during initialization + cline.initiateTaskLoop = jest.fn() + cline.say = jest.fn() + cline.addToClineMessages = jest.fn() + cline.overwriteClineMessages = jest.fn() + cline.addToApiConversationHistory = jest.fn() + cline.overwriteApiConversationHistory = jest.fn() + }) + + test('returns true for allowed commands', () => { + expect(cline.isAllowedCommand('npm install')).toBe(true) + expect(cline.isAllowedCommand('npx create-react-app')).toBe(true) + expect(cline.isAllowedCommand('tsc --watch')).toBe(true) + expect(cline.isAllowedCommand('git log --oneline')).toBe(true) + expect(cline.isAllowedCommand('git diff main')).toBe(true) + }) + + test('returns true regardless of case or whitespace', () => { + expect(cline.isAllowedCommand('NPM install')).toBe(true) + expect(cline.isAllowedCommand(' npm install')).toBe(true) + expect(cline.isAllowedCommand('GIT DIFF')).toBe(true) + }) + + test('returns false for non-allowed commands', () => { + expect(cline.isAllowedCommand('rm -rf /')).toBe(false) + expect(cline.isAllowedCommand('git push')).toBe(false) + expect(cline.isAllowedCommand('git commit')).toBe(false) + expect(cline.isAllowedCommand('curl http://example.com')).toBe(false) + }) + + test('returns false for undefined or empty commands', () => { + expect(cline.isAllowedCommand()).toBe(false) + expect(cline.isAllowedCommand('')).toBe(false) + expect(cline.isAllowedCommand(' ')).toBe(false) + }) + }) });