Skip to content

Commit 0b99347

Browse files
committed
Adding allow-list for auto-executable commands
1 parent 9c2c269 commit 0b99347

File tree

2 files changed

+95
-6
lines changed

2 files changed

+95
-6
lines changed

src/core/Cline.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,15 @@ type UserContent = Array<
5656
Anthropic.TextBlockParam | Anthropic.ImageBlockParam | Anthropic.ToolUseBlockParam | Anthropic.ToolResultBlockParam
5757
>
5858

59+
// Add near the top of the file, after imports:
60+
const ALLOWED_AUTO_EXECUTE_COMMANDS = [
61+
'npm',
62+
'npx',
63+
'tsc',
64+
'git log',
65+
'git diff'
66+
] as const
67+
5968
export class Cline {
6069
readonly taskId: string
6170
api: ApiHandler
@@ -124,6 +133,14 @@ export class Cline {
124133
}
125134
}
126135

136+
protected isAllowedCommand(command?: string): boolean {
137+
if (!command) return false;
138+
const trimmedCommand = command.trim().toLowerCase();
139+
return ALLOWED_AUTO_EXECUTE_COMMANDS.some(prefix =>
140+
trimmedCommand.startsWith(prefix.toLowerCase())
141+
);
142+
}
143+
127144
// Storing task to disk for history
128145

129146
private async ensureTaskDirectoryExists(): Promise<string> {
@@ -555,8 +572,8 @@ export class Cline {
555572
: [{ type: "text", text: lastMessage.content }]
556573
if (previousAssistantMessage && previousAssistantMessage.role === "assistant") {
557574
const assistantContent = Array.isArray(previousAssistantMessage.content)
558-
? previousAssistantMessage.content
559-
: [{ type: "text", text: previousAssistantMessage.content }]
575+
? previousAssistantMessage.content
576+
: [{ type: "text", text: previousAssistantMessage.content }]
560577

561578
const toolUseBlocks = assistantContent.filter(
562579
(block) => block.type === "tool_use"
@@ -839,7 +856,7 @@ export class Cline {
839856
// (have to do this for partial and complete since sending content in thinking tags to markdown renderer will automatically be removed)
840857
// Remove end substrings of <thinking or </thinking (below xml parsing is only for opening tags)
841858
// (this is done with the xml parsing below now, but keeping here for reference)
842-
// content = content.replace(/<\/?t(?:h(?:i(?:n(?:k(?:i(?:n(?:g)?)?)?)?)?)?)?$/, "")
859+
// content = content.replace(/<\/?t(?:h(?:i(?:n(?:k(?:i(?:n(?:g)?)?)?)?)?$/, "")
843860
// Remove all instances of <thinking> (with optional line break after) and </thinking> (with optional line break before)
844861
// - Needs to be separate since we dont want to remove the line break before the first tag
845862
// - Needs to happen before the xml parsing below
@@ -1503,7 +1520,7 @@ export class Cline {
15031520
const command: string | undefined = block.params.command
15041521
try {
15051522
if (block.partial) {
1506-
if (this.alwaysAllowExecute) {
1523+
if (this.alwaysAllowExecute && this.isAllowedCommand(command)) {
15071524
await this.say("command", command, undefined, block.partial)
15081525
} else {
15091526
await this.ask("command", removeClosingTag("command", command), block.partial).catch(
@@ -1520,7 +1537,9 @@ export class Cline {
15201537
break
15211538
}
15221539
this.consecutiveMistakeCount = 0
1523-
const didApprove = this.alwaysAllowExecute || (await askApproval("command", command))
1540+
1541+
const didApprove = (this.alwaysAllowExecute && this.isAllowedCommand(command)) ||
1542+
(await askApproval("command", command))
15241543
if (!didApprove) {
15251544
break
15261545
}

src/core/__tests__/Cline.test.ts

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -318,5 +318,75 @@ describe('Cline', () => {
318318
expect(writeDisabledCline.alwaysAllowWrite).toBe(false);
319319
// The write operation would require approval in actual implementation
320320
});
321-
});
321+
});
322+
323+
describe('isAllowedCommand', () => {
324+
let cline: any
325+
326+
beforeEach(() => {
327+
// Create a more complete mock provider
328+
const mockProvider = {
329+
context: {
330+
globalStorageUri: { fsPath: '/mock/path' }
331+
},
332+
postStateToWebview: jest.fn(),
333+
postMessageToWebview: jest.fn(),
334+
updateTaskHistory: jest.fn()
335+
}
336+
337+
// Mock the required dependencies
338+
const mockApiConfig = {
339+
getModel: () => ({
340+
id: 'claude-3-sonnet',
341+
info: { supportsComputerUse: true }
342+
})
343+
}
344+
345+
// Create test instance with mocked constructor params
346+
cline = new Cline(
347+
mockProvider as any,
348+
mockApiConfig as any,
349+
undefined, // customInstructions
350+
false, // alwaysAllowReadOnly
351+
false, // alwaysAllowWrite
352+
false, // alwaysAllowExecute
353+
'test task' // task
354+
)
355+
356+
// Mock internal methods that are called during initialization
357+
cline.initiateTaskLoop = jest.fn()
358+
cline.say = jest.fn()
359+
cline.addToClineMessages = jest.fn()
360+
cline.overwriteClineMessages = jest.fn()
361+
cline.addToApiConversationHistory = jest.fn()
362+
cline.overwriteApiConversationHistory = jest.fn()
363+
})
364+
365+
test('returns true for allowed commands', () => {
366+
expect(cline.isAllowedCommand('npm install')).toBe(true)
367+
expect(cline.isAllowedCommand('npx create-react-app')).toBe(true)
368+
expect(cline.isAllowedCommand('tsc --watch')).toBe(true)
369+
expect(cline.isAllowedCommand('git log --oneline')).toBe(true)
370+
expect(cline.isAllowedCommand('git diff main')).toBe(true)
371+
})
372+
373+
test('returns true regardless of case or whitespace', () => {
374+
expect(cline.isAllowedCommand('NPM install')).toBe(true)
375+
expect(cline.isAllowedCommand(' npm install')).toBe(true)
376+
expect(cline.isAllowedCommand('GIT DIFF')).toBe(true)
377+
})
378+
379+
test('returns false for non-allowed commands', () => {
380+
expect(cline.isAllowedCommand('rm -rf /')).toBe(false)
381+
expect(cline.isAllowedCommand('git push')).toBe(false)
382+
expect(cline.isAllowedCommand('git commit')).toBe(false)
383+
expect(cline.isAllowedCommand('curl http://example.com')).toBe(false)
384+
})
385+
386+
test('returns false for undefined or empty commands', () => {
387+
expect(cline.isAllowedCommand()).toBe(false)
388+
expect(cline.isAllowedCommand('')).toBe(false)
389+
expect(cline.isAllowedCommand(' ')).toBe(false)
390+
})
391+
})
322392
});

0 commit comments

Comments
 (0)