Simplify auto-approving code and make it work better with browser actions (#21)

This commit is contained in:
Matt Rubens
2024-11-28 13:44:02 -05:00
committed by GitHub
parent b1c0e9be41
commit d632e621be
6 changed files with 224 additions and 347 deletions

View File

@@ -116,15 +116,6 @@
"when": "view == claude-dev.SidebarProvider" "when": "view == claude-dev.SidebarProvider"
} }
] ]
},
"configuration": {
"properties": {
"cline.alwaysAllowBrowser": {
"type": "boolean",
"default": false,
"description": "Always allow browser actions without requiring confirmation"
}
}
} }
}, },
"scripts": { "scripts": {

View File

@@ -56,16 +56,6 @@ type UserContent = Array<
Anthropic.TextBlockParam | Anthropic.ImageBlockParam | Anthropic.ToolUseBlockParam | Anthropic.ToolResultBlockParam Anthropic.TextBlockParam | Anthropic.ImageBlockParam | Anthropic.ToolUseBlockParam | Anthropic.ToolResultBlockParam
> >
const ALLOWED_AUTO_EXECUTE_COMMANDS = [
'npm',
'npx',
'tsc',
'git log',
'git diff',
'git show',
'list'
] as const
export class Cline { export class Cline {
readonly taskId: string readonly taskId: string
api: ApiHandler api: ApiHandler
@@ -74,10 +64,6 @@ export class Cline {
private browserSession: BrowserSession private browserSession: BrowserSession
private didEditFile: boolean = false private didEditFile: boolean = false
customInstructions?: string customInstructions?: string
alwaysAllowReadOnly: boolean
alwaysAllowWrite: boolean
alwaysAllowExecute: boolean
alwaysAllowBrowser: boolean
apiConversationHistory: Anthropic.MessageParam[] = [] apiConversationHistory: Anthropic.MessageParam[] = []
clineMessages: ClineMessage[] = [] clineMessages: ClineMessage[] = []
@@ -107,10 +93,6 @@ export class Cline {
provider: ClineProvider, provider: ClineProvider,
apiConfiguration: ApiConfiguration, apiConfiguration: ApiConfiguration,
customInstructions?: string, customInstructions?: string,
alwaysAllowReadOnly?: boolean,
alwaysAllowWrite?: boolean,
alwaysAllowExecute?: boolean,
alwaysAllowBrowser?: boolean,
task?: string, task?: string,
images?: string[], images?: string[],
historyItem?: HistoryItem, historyItem?: HistoryItem,
@@ -122,10 +104,6 @@ export class Cline {
this.browserSession = new BrowserSession(provider.context) this.browserSession = new BrowserSession(provider.context)
this.diffViewProvider = new DiffViewProvider(cwd) this.diffViewProvider = new DiffViewProvider(cwd)
this.customInstructions = customInstructions this.customInstructions = customInstructions
this.alwaysAllowReadOnly = alwaysAllowReadOnly ?? false
this.alwaysAllowWrite = alwaysAllowWrite ?? false
this.alwaysAllowExecute = alwaysAllowExecute ?? false
this.alwaysAllowBrowser = alwaysAllowBrowser ?? false
if (historyItem) { if (historyItem) {
this.taskId = historyItem.id this.taskId = historyItem.id
@@ -138,25 +116,6 @@ export class Cline {
} }
} }
private isAllowedCommand(command?: string): boolean {
if (!command) {
return false;
}
// Check for command chaining characters
if (command.includes('&&') ||
command.includes(';') ||
command.includes('||') ||
command.includes('|') ||
command.includes('$(') ||
command.includes('`')) {
return false;
}
const trimmedCommand = command.trim().toLowerCase();
return ALLOWED_AUTO_EXECUTE_COMMANDS.some(prefix =>
trimmedCommand.startsWith(prefix.toLowerCase())
);
}
// Storing task to disk for history // Storing task to disk for history
private async ensureTaskDirectoryExists(): Promise<string> { private async ensureTaskDirectoryExists(): Promise<string> {
@@ -1101,11 +1060,7 @@ export class Cline {
if (block.partial) { if (block.partial) {
// update gui message // update gui message
const partialMessage = JSON.stringify(sharedMessageProps) const partialMessage = JSON.stringify(sharedMessageProps)
if (this.alwaysAllowWrite) {
await this.say("tool", partialMessage, undefined, block.partial)
} else {
await this.ask("tool", partialMessage, block.partial).catch(() => {}) await this.ask("tool", partialMessage, block.partial).catch(() => {})
}
// update editor // update editor
if (!this.diffViewProvider.isEditing) { if (!this.diffViewProvider.isEditing) {
// open the editor and prepare to stream content in // open the editor and prepare to stream content in
@@ -1135,11 +1090,7 @@ export class Cline {
if (!this.diffViewProvider.isEditing) { if (!this.diffViewProvider.isEditing) {
// show gui message before showing edit animation // show gui message before showing edit animation
const partialMessage = JSON.stringify(sharedMessageProps) const partialMessage = JSON.stringify(sharedMessageProps)
if (this.alwaysAllowWrite) {
await this.say("tool", partialMessage, undefined, true)
} else {
await this.ask("tool", partialMessage, true).catch(() => {}) // sending true for partial even though it's not a partial, this shows the edit row before the content is streamed into the editor await this.ask("tool", partialMessage, true).catch(() => {}) // sending true for partial even though it's not a partial, this shows the edit row before the content is streamed into the editor
}
await this.diffViewProvider.open(relPath) await this.diffViewProvider.open(relPath)
} }
await this.diffViewProvider.update(newContent, true) await this.diffViewProvider.update(newContent, true)
@@ -1158,7 +1109,7 @@ export class Cline {
) )
: undefined, : undefined,
} satisfies ClineSayTool) } satisfies ClineSayTool)
const didApprove = this.alwaysAllowWrite || (await askApproval("tool", completeMessage)) const didApprove = await askApproval("tool", completeMessage)
if (!didApprove) { if (!didApprove) {
await this.diffViewProvider.revertChanges() await this.diffViewProvider.revertChanges()
break break
@@ -1211,11 +1162,7 @@ export class Cline {
...sharedMessageProps, ...sharedMessageProps,
content: undefined, content: undefined,
} satisfies ClineSayTool) } satisfies ClineSayTool)
if (this.alwaysAllowReadOnly) {
await this.say("tool", partialMessage, undefined, block.partial)
} else {
await this.ask("tool", partialMessage, block.partial).catch(() => {}) await this.ask("tool", partialMessage, block.partial).catch(() => {})
}
break break
} else { } else {
if (!relPath) { if (!relPath) {
@@ -1229,14 +1176,10 @@ export class Cline {
...sharedMessageProps, ...sharedMessageProps,
content: absolutePath, content: absolutePath,
} satisfies ClineSayTool) } satisfies ClineSayTool)
if (this.alwaysAllowReadOnly) {
await this.say("tool", completeMessage, undefined, false) // need to be sending partialValue bool, since undefined has its own purpose in that the message is treated neither as a partial or completion of a partial, but as a single complete message
} else {
const didApprove = await askApproval("tool", completeMessage) const didApprove = await askApproval("tool", completeMessage)
if (!didApprove) { if (!didApprove) {
break break
} }
}
// now execute the tool like normal // now execute the tool like normal
const content = await extractTextFromFile(absolutePath) const content = await extractTextFromFile(absolutePath)
pushToolResult(content) pushToolResult(content)
@@ -1261,11 +1204,7 @@ export class Cline {
...sharedMessageProps, ...sharedMessageProps,
content: "", content: "",
} satisfies ClineSayTool) } satisfies ClineSayTool)
if (this.alwaysAllowReadOnly) {
await this.say("tool", partialMessage, undefined, block.partial)
} else {
await this.ask("tool", partialMessage, block.partial).catch(() => {}) await this.ask("tool", partialMessage, block.partial).catch(() => {})
}
break break
} else { } else {
if (!relDirPath) { if (!relDirPath) {
@@ -1281,14 +1220,10 @@ export class Cline {
...sharedMessageProps, ...sharedMessageProps,
content: result, content: result,
} satisfies ClineSayTool) } satisfies ClineSayTool)
if (this.alwaysAllowReadOnly) {
await this.say("tool", completeMessage, undefined, false)
} else {
const didApprove = await askApproval("tool", completeMessage) const didApprove = await askApproval("tool", completeMessage)
if (!didApprove) { if (!didApprove) {
break break
} }
}
pushToolResult(result) pushToolResult(result)
break break
} }
@@ -1309,11 +1244,7 @@ export class Cline {
...sharedMessageProps, ...sharedMessageProps,
content: "", content: "",
} satisfies ClineSayTool) } satisfies ClineSayTool)
if (this.alwaysAllowReadOnly) {
await this.say("tool", partialMessage, undefined, block.partial)
} else {
await this.ask("tool", partialMessage, block.partial).catch(() => {}) await this.ask("tool", partialMessage, block.partial).catch(() => {})
}
break break
} else { } else {
if (!relDirPath) { if (!relDirPath) {
@@ -1330,14 +1261,10 @@ export class Cline {
...sharedMessageProps, ...sharedMessageProps,
content: result, content: result,
} satisfies ClineSayTool) } satisfies ClineSayTool)
if (this.alwaysAllowReadOnly) {
await this.say("tool", completeMessage, undefined, false)
} else {
const didApprove = await askApproval("tool", completeMessage) const didApprove = await askApproval("tool", completeMessage)
if (!didApprove) { if (!didApprove) {
break break
} }
}
pushToolResult(result) pushToolResult(result)
break break
} }
@@ -1362,11 +1289,7 @@ export class Cline {
...sharedMessageProps, ...sharedMessageProps,
content: "", content: "",
} satisfies ClineSayTool) } satisfies ClineSayTool)
if (this.alwaysAllowReadOnly) {
await this.say("tool", partialMessage, undefined, block.partial)
} else {
await this.ask("tool", partialMessage, block.partial).catch(() => {}) await this.ask("tool", partialMessage, block.partial).catch(() => {})
}
break break
} else { } else {
if (!relDirPath) { if (!relDirPath) {
@@ -1386,14 +1309,10 @@ export class Cline {
...sharedMessageProps, ...sharedMessageProps,
content: results, content: results,
} satisfies ClineSayTool) } satisfies ClineSayTool)
if (this.alwaysAllowReadOnly) {
await this.say("tool", completeMessage, undefined, false)
} else {
const didApprove = await askApproval("tool", completeMessage) const didApprove = await askApproval("tool", completeMessage)
if (!didApprove) { if (!didApprove) {
break break
} }
}
pushToolResult(results) pushToolResult(results)
break break
} }
@@ -1421,24 +1340,11 @@ export class Cline {
try { try {
if (block.partial) { if (block.partial) {
if (action === "launch") { if (action === "launch") {
if (this.alwaysAllowBrowser) {
await this.say(
"browser_action",
JSON.stringify({
action: action as BrowserAction,
coordinate: undefined,
text: undefined
} satisfies ClineSayBrowserAction),
undefined,
block.partial
)
} else {
await this.ask( await this.ask(
"browser_action_launch", "browser_action_launch",
removeClosingTag("url", url), removeClosingTag("url", url),
block.partial block.partial
).catch(() => {}) ).catch(() => {})
}
} else { } else {
await this.say( await this.say(
"browser_action", "browser_action",
@@ -1464,7 +1370,7 @@ export class Cline {
break break
} }
this.consecutiveMistakeCount = 0 this.consecutiveMistakeCount = 0
const didApprove = this.alwaysAllowBrowser || await askApproval("browser_action_launch", url) const didApprove = await askApproval("browser_action_launch", url)
if (!didApprove) { if (!didApprove) {
break break
} }
@@ -1565,13 +1471,9 @@ export class Cline {
const command: string | undefined = block.params.command const command: string | undefined = block.params.command
try { try {
if (block.partial) { if (block.partial) {
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( await this.ask("command", removeClosingTag("command", command), block.partial).catch(
() => {} () => {}
) )
}
break break
} else { } else {
if (!command) { if (!command) {
@@ -1583,8 +1485,7 @@ export class Cline {
} }
this.consecutiveMistakeCount = 0 this.consecutiveMistakeCount = 0
const didApprove = (this.alwaysAllowExecute && this.isAllowedCommand(command)) || const didApprove = await askApproval("command", command)
(await askApproval("command", command))
if (!didApprove) { if (!didApprove) {
break break
} }

View File

@@ -231,40 +231,14 @@ describe('Cline', () => {
}); });
describe('constructor', () => { describe('constructor', () => {
it('should initialize with default settings', () => {
const cline = new Cline(
mockProvider,
mockApiConfig,
undefined, // customInstructions
undefined, // alwaysAllowReadOnly
undefined, // alwaysAllowWrite
undefined, // alwaysAllowExecute
undefined, // alwaysAllowBrowser
'test task'
);
expect(cline.alwaysAllowReadOnly).toBe(false);
expect(cline.alwaysAllowWrite).toBe(false);
expect(cline.alwaysAllowExecute).toBe(false);
expect(cline.alwaysAllowBrowser).toBe(false);
});
it('should respect provided settings', () => { it('should respect provided settings', () => {
const cline = new Cline( const cline = new Cline(
mockProvider, mockProvider,
mockApiConfig, mockApiConfig,
'custom instructions', 'custom instructions',
true, // alwaysAllowReadOnly
true, // alwaysAllowWrite
true, // alwaysAllowExecute
true, // alwaysAllowBrowser
'test task' 'test task'
); );
expect(cline.alwaysAllowReadOnly).toBe(true);
expect(cline.alwaysAllowWrite).toBe(true);
expect(cline.alwaysAllowExecute).toBe(true);
expect(cline.alwaysAllowBrowser).toBe(true);
expect(cline.customInstructions).toBe('custom instructions'); expect(cline.customInstructions).toBe('custom instructions');
}); });
@@ -277,142 +251,4 @@ describe('Cline', () => {
}).toThrow('Either historyItem or task/images must be provided'); }).toThrow('Either historyItem or task/images must be provided');
}); });
}); });
describe('file operations', () => {
let cline: Cline;
beforeEach(() => {
cline = new Cline(
mockProvider,
mockApiConfig,
undefined,
false,
false,
false,
false,
'test task'
);
});
it('should bypass approval when alwaysAllowWrite is true', async () => {
const writeEnabledCline = new Cline(
mockProvider,
mockApiConfig,
undefined,
false,
true, // alwaysAllowWrite
false,
false,
'test task'
);
expect(writeEnabledCline.alwaysAllowWrite).toBe(true);
// The write operation would bypass approval in actual implementation
});
it('should require approval when alwaysAllowWrite is false', async () => {
const writeDisabledCline = new Cline(
mockProvider,
mockApiConfig,
undefined,
false,
false, // alwaysAllowWrite
false,
false,
'test task'
);
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
false, // alwaysAllowBrowser
'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)
})
test('returns false for commands with chaining operators', () => {
const maliciousCommands = [
'npm install && rm -rf /',
'git status; dangerous-command',
'git log || evil-script',
'git status | malicious-pipe',
'git log $(evil-command)',
'git status `rm -rf /`',
'npm install && echo "malicious"',
'git status; curl http://evil.com',
'tsc --watch || wget malware',
];
maliciousCommands.forEach(cmd => {
expect(cline.isAllowedCommand(cmd)).toBe(false);
});
});
})
}); });

View File

@@ -197,20 +197,12 @@ export class ClineProvider implements vscode.WebviewViewProvider {
const { const {
apiConfiguration, apiConfiguration,
customInstructions, customInstructions,
alwaysAllowReadOnly,
alwaysAllowWrite,
alwaysAllowExecute,
alwaysAllowBrowser
} = await this.getState() } = await this.getState()
this.cline = new Cline( this.cline = new Cline(
this, this,
apiConfiguration, apiConfiguration,
customInstructions, customInstructions,
alwaysAllowReadOnly,
alwaysAllowWrite,
alwaysAllowExecute,
alwaysAllowBrowser,
task, task,
images images
) )
@@ -221,20 +213,12 @@ export class ClineProvider implements vscode.WebviewViewProvider {
const { const {
apiConfiguration, apiConfiguration,
customInstructions, customInstructions,
alwaysAllowReadOnly,
alwaysAllowWrite,
alwaysAllowExecute,
alwaysAllowBrowser
} = await this.getState() } = await this.getState()
this.cline = new Cline( this.cline = new Cline(
this, this,
apiConfiguration, apiConfiguration,
customInstructions, customInstructions,
alwaysAllowReadOnly,
alwaysAllowWrite,
alwaysAllowExecute,
alwaysAllowBrowser,
undefined, undefined,
undefined, undefined,
historyItem, historyItem,
@@ -440,23 +424,18 @@ export class ClineProvider implements vscode.WebviewViewProvider {
break break
case "alwaysAllowReadOnly": case "alwaysAllowReadOnly":
await this.updateGlobalState("alwaysAllowReadOnly", message.bool ?? undefined) await this.updateGlobalState("alwaysAllowReadOnly", message.bool ?? undefined)
if (this.cline) {
this.cline.alwaysAllowReadOnly = message.bool ?? false
}
await this.postStateToWebview() await this.postStateToWebview()
break break
case "alwaysAllowWrite": case "alwaysAllowWrite":
await this.updateGlobalState("alwaysAllowWrite", message.bool ?? undefined) await this.updateGlobalState("alwaysAllowWrite", message.bool ?? undefined)
if (this.cline) {
this.cline.alwaysAllowWrite = message.bool ?? false
}
await this.postStateToWebview() await this.postStateToWebview()
break break
case "alwaysAllowExecute": case "alwaysAllowExecute":
await this.updateGlobalState("alwaysAllowExecute", message.bool ?? undefined) await this.updateGlobalState("alwaysAllowExecute", message.bool ?? undefined)
if (this.cline) { await this.postStateToWebview()
this.cline.alwaysAllowExecute = message.bool ?? false break
} case "alwaysAllowBrowser":
await this.updateGlobalState("alwaysAllowBrowser", message.bool ?? undefined)
await this.postStateToWebview() await this.postStateToWebview()
break break
case "askResponse": case "askResponse":
@@ -530,13 +509,6 @@ export class ClineProvider implements vscode.WebviewViewProvider {
// await this.postStateToWebview() // new Cline instance will post state when it's ready. having this here sent an empty messages array to webview leading to virtuoso having to reload the entire list // await this.postStateToWebview() // new Cline instance will post state when it's ready. having this here sent an empty messages array to webview leading to virtuoso having to reload the entire list
} }
break
case "alwaysAllowBrowser":
await this.updateGlobalState("alwaysAllowBrowser", message.bool ?? undefined)
if (this.cline) {
this.cline.alwaysAllowBrowser = message.bool ?? false
}
await this.postStateToWebview()
break break
// Add more switch case statements here as more webview message commands // Add more switch case statements here as more webview message commands
// are created within the webview context (i.e. inside media/main.js) // are created within the webview context (i.e. inside media/main.js)
@@ -845,7 +817,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
alwaysAllowWrite, alwaysAllowWrite,
alwaysAllowExecute, alwaysAllowExecute,
alwaysAllowBrowser, alwaysAllowBrowser,
taskHistory taskHistory,
} = await this.getState() } = await this.getState()
return { return {
@@ -947,8 +919,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
alwaysAllowReadOnly, alwaysAllowReadOnly,
alwaysAllowWrite, alwaysAllowWrite,
alwaysAllowExecute, alwaysAllowExecute,
taskHistory,
alwaysAllowBrowser, alwaysAllowBrowser,
taskHistory,
] = await Promise.all([ ] = await Promise.all([
this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>, this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>,
this.getGlobalState("apiModelId") as Promise<string | undefined>, this.getGlobalState("apiModelId") as Promise<string | undefined>,
@@ -979,8 +951,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
this.getGlobalState("alwaysAllowReadOnly") as Promise<boolean | undefined>, this.getGlobalState("alwaysAllowReadOnly") as Promise<boolean | undefined>,
this.getGlobalState("alwaysAllowWrite") as Promise<boolean | undefined>, this.getGlobalState("alwaysAllowWrite") as Promise<boolean | undefined>,
this.getGlobalState("alwaysAllowExecute") as Promise<boolean | undefined>, this.getGlobalState("alwaysAllowExecute") as Promise<boolean | undefined>,
this.getGlobalState("taskHistory") as Promise<HistoryItem[] | undefined>,
this.getGlobalState("alwaysAllowBrowser") as Promise<boolean | undefined>, this.getGlobalState("alwaysAllowBrowser") as Promise<boolean | undefined>,
this.getGlobalState("taskHistory") as Promise<HistoryItem[] | undefined>,
]) ])
let apiProvider: ApiProvider let apiProvider: ApiProvider

View File

@@ -34,8 +34,18 @@ interface ChatViewProps {
export const MAX_IMAGES_PER_MESSAGE = 20 // Anthropic limits to 20 images export const MAX_IMAGES_PER_MESSAGE = 20 // Anthropic limits to 20 images
const ALLOWED_AUTO_EXECUTE_COMMANDS = [
'npm',
'npx',
'tsc',
'git log',
'git diff',
'git show',
'ls'
] as const
const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryView }: ChatViewProps) => { const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryView }: ChatViewProps) => {
const { version, clineMessages: messages, taskHistory, apiConfiguration } = useExtensionState() const { version, clineMessages: messages, taskHistory, apiConfiguration, alwaysAllowBrowser, alwaysAllowReadOnly, alwaysAllowWrite, alwaysAllowExecute } = useExtensionState()
//const task = messages.length > 0 ? (messages[0].say === "task" ? messages[0] : undefined) : undefined) : undefined //const task = messages.length > 0 ? (messages[0].say === "task" ? messages[0] : undefined) : undefined) : undefined
const task = useMemo(() => messages.at(0), [messages]) // leaving this less safe version here since if the first message is not a task, then the extension is in a bad state and needs to be debugged (see Cline.abort) const task = useMemo(() => messages.at(0), [messages]) // leaving this less safe version here since if the first message is not a task, then the extension is in a bad state and needs to be debugged (see Cline.abort)
@@ -675,6 +685,60 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
[expandedRows, modifiedMessages, groupedMessages.length, toggleRowExpansion, handleRowHeightChange], [expandedRows, modifiedMessages, groupedMessages.length, toggleRowExpansion, handleRowHeightChange],
) )
useEffect(() => {
// Only proceed if we have an ask and buttons are enabled
if (!clineAsk || !enableButtons) return
const isReadOnlyToolAction = () => {
const lastMessage = messages.at(-1)
if (lastMessage?.type === "ask" && lastMessage.text) {
const tool = JSON.parse(lastMessage.text)
return ["readFile", "listFiles", "searchFiles"].includes(tool.tool)
}
return false
}
const isWriteToolAction = () => {
const lastMessage = messages.at(-1)
if (lastMessage?.type === "ask" && lastMessage.text) {
const tool = JSON.parse(lastMessage.text)
return ["editedExistingFile", "newFileCreated"].includes(tool.tool)
}
return false
}
const isAllowedCommand = () => {
const lastMessage = messages.at(-1)
if (lastMessage?.type === "ask" && lastMessage.text) {
const command = lastMessage.text
// Check for command chaining characters
if (command.includes('&&') ||
command.includes(';') ||
command.includes('||') ||
command.includes('|') ||
command.includes('$(') ||
command.includes('`')) {
return false
}
const trimmedCommand = command.trim().toLowerCase()
return ALLOWED_AUTO_EXECUTE_COMMANDS.some(prefix =>
trimmedCommand.startsWith(prefix.toLowerCase())
)
}
return false
}
if (
(alwaysAllowBrowser && clineAsk === "browser_action_launch") ||
(alwaysAllowReadOnly && clineAsk === "tool" && isReadOnlyToolAction()) ||
(alwaysAllowWrite && clineAsk === "tool" && isWriteToolAction()) ||
(alwaysAllowExecute && clineAsk === "command" && isAllowedCommand())
) {
handlePrimaryButtonClick()
}
}, [clineAsk, enableButtons, handlePrimaryButtonClick, alwaysAllowBrowser, alwaysAllowReadOnly, alwaysAllowWrite, alwaysAllowExecute, messages])
return ( return (
<div <div
style={{ style={{

View File

@@ -38,7 +38,6 @@ jest.mock('../ChatRow', () => ({
} }
})) }))
// Mock Virtuoso component // Mock Virtuoso component
jest.mock('react-virtuoso', () => ({ jest.mock('react-virtuoso', () => ({
Virtuoso: ({ children }: any) => ( Virtuoso: ({ children }: any) => (
@@ -74,6 +73,7 @@ describe('ChatView', () => {
alwaysAllowReadOnly: true, alwaysAllowReadOnly: true,
alwaysAllowWrite: true, alwaysAllowWrite: true,
alwaysAllowExecute: true, alwaysAllowExecute: true,
alwaysAllowBrowser: true,
openRouterModels: {}, openRouterModels: {},
didHydrateState: true, didHydrateState: true,
showWelcome: false, showWelcome: false,
@@ -83,12 +83,13 @@ describe('ChatView', () => {
shouldShowAnnouncement: false, shouldShowAnnouncement: false,
uriScheme: 'vscode', uriScheme: 'vscode',
setApiConfiguration: jest.fn(),
setShowAnnouncement: jest.fn(),
setCustomInstructions: jest.fn(),
setAlwaysAllowReadOnly: jest.fn(), setAlwaysAllowReadOnly: jest.fn(),
setAlwaysAllowWrite: jest.fn(), setAlwaysAllowWrite: jest.fn(),
setCustomInstructions: jest.fn(),
setAlwaysAllowExecute: jest.fn(), setAlwaysAllowExecute: jest.fn(),
setApiConfiguration: jest.fn(), setAlwaysAllowBrowser: jest.fn()
setShowAnnouncement: jest.fn()
} }
// Mock the useExtensionState hook // Mock the useExtensionState hook
@@ -106,6 +107,118 @@ describe('ChatView', () => {
) )
} }
describe('Always Allow Logic', () => {
it('should auto-approve read-only tool actions when alwaysAllowReadOnly is true', () => {
mockState.clineMessages = [
{
type: 'ask',
ask: 'tool',
text: JSON.stringify({ tool: 'readFile' }),
ts: Date.now(),
}
]
renderChatView()
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'askResponse',
askResponse: 'yesButtonClicked'
})
})
it('should auto-approve write tool actions when alwaysAllowWrite is true', () => {
mockState.clineMessages = [
{
type: 'ask',
ask: 'tool',
text: JSON.stringify({ tool: 'editedExistingFile' }),
ts: Date.now(),
}
]
renderChatView()
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'askResponse',
askResponse: 'yesButtonClicked'
})
})
it('should auto-approve allowed execute commands when alwaysAllowExecute is true', () => {
mockState.clineMessages = [
{
type: 'ask',
ask: 'command',
text: 'npm install',
ts: Date.now(),
}
]
renderChatView()
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'askResponse',
askResponse: 'yesButtonClicked'
})
})
it('should not auto-approve disallowed execute commands even when alwaysAllowExecute is true', () => {
mockState.clineMessages = [
{
type: 'ask',
ask: 'command',
text: 'rm -rf /',
ts: Date.now(),
}
]
renderChatView()
expect(vscode.postMessage).not.toHaveBeenCalled()
})
it('should not auto-approve commands with chaining characters when alwaysAllowExecute is true', () => {
mockState.clineMessages = [
{
type: 'ask',
ask: 'command',
text: 'npm install && rm -rf /',
ts: Date.now(),
}
]
renderChatView()
expect(vscode.postMessage).not.toHaveBeenCalled()
})
it('should auto-approve browser actions when alwaysAllowBrowser is true', () => {
mockState.clineMessages = [
{
type: 'ask',
ask: 'browser_action_launch',
ts: Date.now(),
}
]
renderChatView()
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'askResponse',
askResponse: 'yesButtonClicked'
})
})
it('should not auto-approve when corresponding alwaysAllow flag is false', () => {
mockState.alwaysAllowReadOnly = false
mockState.clineMessages = [
{
type: 'ask',
ask: 'tool',
text: JSON.stringify({ tool: 'readFile' }),
ts: Date.now(),
}
]
renderChatView()
expect(vscode.postMessage).not.toHaveBeenCalled()
})
})
describe('Streaming State', () => { describe('Streaming State', () => {
it('should show cancel button while streaming and trigger cancel on click', async () => { it('should show cancel button while streaming and trigger cancel on click', async () => {
mockState.clineMessages = [ mockState.clineMessages = [