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

@@ -34,8 +34,18 @@ interface ChatViewProps {
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 { 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 = 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],
)
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 (
<div
style={{

View File

@@ -38,7 +38,6 @@ jest.mock('../ChatRow', () => ({
}
}))
// Mock Virtuoso component
jest.mock('react-virtuoso', () => ({
Virtuoso: ({ children }: any) => (
@@ -74,6 +73,7 @@ describe('ChatView', () => {
alwaysAllowReadOnly: true,
alwaysAllowWrite: true,
alwaysAllowExecute: true,
alwaysAllowBrowser: true,
openRouterModels: {},
didHydrateState: true,
showWelcome: false,
@@ -82,13 +82,14 @@ describe('ChatView', () => {
taskHistory: [],
shouldShowAnnouncement: false,
uriScheme: 'vscode',
setApiConfiguration: jest.fn(),
setShowAnnouncement: jest.fn(),
setCustomInstructions: jest.fn(),
setAlwaysAllowReadOnly: jest.fn(),
setAlwaysAllowWrite: jest.fn(),
setCustomInstructions: jest.fn(),
setAlwaysAllowExecute: jest.fn(),
setApiConfiguration: jest.fn(),
setShowAnnouncement: jest.fn()
setAlwaysAllowBrowser: jest.fn()
}
// 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', () => {
it('should show cancel button while streaming and trigger cancel on click', async () => {
mockState.clineMessages = [
@@ -168,4 +281,4 @@ describe('ChatView', () => {
})
})
})
})
})