Skip to content

Commit b8fa1f5

Browse files
authored
Merge branch 'feature/agentic-chat' into feature/agentic-chat
2 parents 4b6f31f + 4e0659c commit b8fa1f5

File tree

13 files changed

+248
-8
lines changed

13 files changed

+248
-8
lines changed

packages/core/src/amazonq/webview/ui/apps/cwChatConnector.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,14 @@ export class Connector extends BaseConnector {
324324

325325
if (
326326
!this.onChatAnswerUpdated ||
327-
!['accept-code-diff', 'reject-code-diff', 'run-shell-command', 'reject-shell-command'].includes(action.id)
327+
![
328+
'accept-code-diff',
329+
'reject-code-diff',
330+
'run-shell-command',
331+
'reject-shell-command',
332+
'confirm-tool-use',
333+
'reject-tool-use',
334+
].includes(action.id)
328335
) {
329336
return
330337
}
@@ -382,6 +389,28 @@ export class Connector extends BaseConnector {
382389
},
383390
}
384391
break
392+
case 'confirm-tool-use':
393+
answer.header = {
394+
icon: 'shell' as MynahIconsType,
395+
body: 'shell',
396+
status: {
397+
icon: 'ok' as MynahIconsType,
398+
text: 'Accepted',
399+
status: 'success',
400+
},
401+
}
402+
break
403+
case 'reject-tool-use':
404+
answer.header = {
405+
icon: 'shell' as MynahIconsType,
406+
body: 'shell',
407+
status: {
408+
icon: 'cancel' as MynahIconsType,
409+
text: 'Rejected',
410+
status: 'error',
411+
},
412+
}
413+
break
385414
default:
386415
break
387416
}

packages/core/src/codewhispererChat/controllers/chat/controller.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -867,7 +867,12 @@ export class ChatController {
867867

868868
const session = this.sessionStorage.getSession(message.tabID!)
869869
const currentToolUse = session.toolUseWithError?.toolUse
870-
if (currentToolUse && currentToolUse.name === ToolType.ExecuteBash) {
870+
if (
871+
currentToolUse &&
872+
(currentToolUse.name === ToolType.ExecuteBash ||
873+
currentToolUse.name === ToolType.FsRead ||
874+
currentToolUse.name === ToolType.ListDirectory)
875+
) {
871876
session.toolUseWithError.error = new Error('Tool use was rejected by the user.')
872877
} else {
873878
getLogger().error(
@@ -883,6 +888,7 @@ export class ChatController {
883888
break
884889
case 'run-shell-command':
885890
case 'generic-tool-execution':
891+
case 'confirm-tool-use':
886892
await this.processToolUseMessage(message)
887893
if (message.action.id === 'run-shell-command' && message.action.text === 'Run') {
888894
this.telemetryHelper.recordInteractionWithAgenticChat(
@@ -900,6 +906,7 @@ export class ChatController {
900906
this.telemetryHelper.recordInteractionWithAgenticChat(AgenticChatInteractionType.RejectDiff, message)
901907
break
902908
case 'reject-shell-command':
909+
case 'reject-tool-use':
903910
await this.rejectShellCommand(message)
904911
await this.processToolUseMessage(message)
905912
break

packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -635,13 +635,13 @@ export class Messenger {
635635
const buttons: ChatItemButton[] = [
636636
{
637637
id: 'reject-shell-command',
638-
text: localize('AWS.amazonq.executeBash.reject', 'Reject'),
638+
text: localize('AWS.generic.reject', 'Reject'),
639639
status: 'clear',
640640
icon: 'cancel' as MynahIconsType,
641641
},
642642
{
643643
id: 'run-shell-command',
644-
text: localize('AWS.amazonq.executeBash.run', 'Run'),
644+
text: localize('AWS.generic.run', 'Run'),
645645
status: 'clear',
646646
icon: 'play' as MynahIconsType,
647647
},
@@ -689,6 +689,28 @@ export class Messenger {
689689
buttons,
690690
fileList,
691691
}
692+
} else if (toolUse?.name === ToolType.ListDirectory || toolUse?.name === ToolType.FsRead) {
693+
if (validation.requiresAcceptance) {
694+
const buttons: ChatItemButton[] = [
695+
{
696+
id: 'confirm-tool-use',
697+
text: localize('AWS.generic.run', 'Run'),
698+
status: 'main',
699+
icon: 'play' as MynahIconsType,
700+
},
701+
{
702+
id: 'reject-tool-use',
703+
text: localize('AWS.generic.reject', 'Reject'),
704+
status: 'clear',
705+
icon: 'cancel' as MynahIconsType,
706+
},
707+
]
708+
header = {
709+
icon: 'shell' as MynahIconsType,
710+
body: 'shell',
711+
buttons,
712+
}
713+
}
692714
}
693715

694716
if (this.isTriggerCancelled(triggerID)) {

packages/core/src/codewhispererChat/tools/executeBash.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ import { fs } from '../../shared/fs/fs'
99
import { ChildProcess, ChildProcessOptions } from '../../shared/utilities/processUtils'
1010
import { InvokeOutput, OutputKind, sanitizePath } from './toolShared'
1111
import { split } from 'shlex'
12+
import path from 'path'
1213
import * as vscode from 'vscode'
14+
import { isInDirectory } from '../../shared/filesystemUtilities'
1315

1416
export enum CommandCategory {
1517
ReadOnly,
@@ -187,6 +189,27 @@ export class ExecuteBash {
187189
return { requiresAcceptance: true }
188190
}
189191

192+
// For each command, validate arguments for path safety within workspace
193+
for (const arg of cmdArgs) {
194+
if (this.looksLikePath(arg)) {
195+
// If not absolute, resolve using workingDirectory if available.
196+
let fullPath = arg
197+
if (!path.isAbsolute(arg) && this.workingDirectory) {
198+
fullPath = path.join(this.workingDirectory, arg)
199+
}
200+
const workspaceFolders = vscode.workspace.workspaceFolders
201+
if (!workspaceFolders || workspaceFolders.length === 0) {
202+
return { requiresAcceptance: true, warning: destructiveCommandWarningMessage }
203+
}
204+
const isInWorkspace = workspaceFolders.some((folder) =>
205+
isInDirectory(folder.uri.fsPath, fullPath)
206+
)
207+
if (!isInWorkspace) {
208+
return { requiresAcceptance: true, warning: destructiveCommandWarningMessage }
209+
}
210+
}
211+
}
212+
190213
const command = cmdArgs[0]
191214
const category = commandCategories.get(command)
192215

@@ -409,4 +432,8 @@ export class ExecuteBash {
409432
updates.write('```shell\n' + this.command + '\n```')
410433
updates.end()
411434
}
435+
436+
private looksLikePath(arg: string): boolean {
437+
return arg.startsWith('/') || arg.startsWith('./') || arg.startsWith('../')
438+
}
412439
}

packages/core/src/codewhispererChat/tools/fsRead.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@
55
import * as vscode from 'vscode'
66
import { getLogger } from '../../shared/logger/logger'
77
import fs from '../../shared/fs/fs'
8-
import { InvokeOutput, OutputKind, sanitizePath } from './toolShared'
98
import { Writable } from 'stream'
109
import path from 'path'
10+
import { InvokeOutput, OutputKind, sanitizePath, CommandValidation } from './toolShared'
11+
import { isInDirectory } from '../../shared/filesystemUtilities'
1112

1213
export interface FsReadParams {
1314
path: string
@@ -68,6 +69,18 @@ export class FsRead {
6869
updates.end()
6970
}
7071

72+
public requiresAcceptance(): CommandValidation {
73+
const workspaceFolders = vscode.workspace.workspaceFolders
74+
if (!workspaceFolders || workspaceFolders.length === 0) {
75+
return { requiresAcceptance: true }
76+
}
77+
const isInWorkspace = workspaceFolders.some((folder) => isInDirectory(folder.uri.fsPath, this.fsPath))
78+
if (!isInWorkspace) {
79+
return { requiresAcceptance: true }
80+
}
81+
return { requiresAcceptance: false }
82+
}
83+
7184
public async invoke(updates?: Writable): Promise<InvokeOutput> {
7285
try {
7386
const fileUri = vscode.Uri.file(this.fsPath)

packages/core/src/codewhispererChat/tools/listDirectory.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@ import * as vscode from 'vscode'
66
import { getLogger } from '../../shared/logger/logger'
77
import { readDirectoryRecursively } from '../../shared/utilities/workspaceUtils'
88
import fs from '../../shared/fs/fs'
9-
import { InvokeOutput, OutputKind, sanitizePath } from './toolShared'
109
import { Writable } from 'stream'
1110
import path from 'path'
11+
import { InvokeOutput, OutputKind, sanitizePath, CommandValidation } from './toolShared'
12+
import { isInDirectory } from '../../shared/filesystemUtilities'
1213

1314
export interface ListDirectoryParams {
1415
path: string
@@ -61,6 +62,18 @@ export class ListDirectory {
6162
updates.end()
6263
}
6364

65+
public requiresAcceptance(): CommandValidation {
66+
const workspaceFolders = vscode.workspace.workspaceFolders
67+
if (!workspaceFolders || workspaceFolders.length === 0) {
68+
return { requiresAcceptance: true }
69+
}
70+
const isInWorkspace = workspaceFolders.some((folder) => isInDirectory(folder.uri.fsPath, this.fsPath))
71+
if (!isInWorkspace) {
72+
return { requiresAcceptance: true }
73+
}
74+
return { requiresAcceptance: false }
75+
}
76+
6477
public async invoke(updates?: Writable): Promise<InvokeOutput> {
6578
try {
6679
const fileUri = vscode.Uri.file(this.fsPath)

packages/core/src/codewhispererChat/tools/toolShared.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,8 @@ export function sanitizePath(inputPath: string): string {
3232
}
3333
return sanitized
3434
}
35+
36+
export interface CommandValidation {
37+
requiresAcceptance: boolean
38+
warning?: string
39+
}

packages/core/src/codewhispererChat/tools/toolUtils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,13 @@ export class ToolUtils {
4141
static requiresAcceptance(tool: Tool): CommandValidation {
4242
switch (tool.type) {
4343
case ToolType.FsRead:
44-
return { requiresAcceptance: false }
44+
return tool.tool.requiresAcceptance()
4545
case ToolType.FsWrite:
4646
return { requiresAcceptance: false }
4747
case ToolType.ExecuteBash:
4848
return tool.tool.requiresAcceptance()
4949
case ToolType.ListDirectory:
50-
return { requiresAcceptance: false }
50+
return tool.tool.requiresAcceptance()
5151
}
5252
}
5353

packages/core/src/shared/localizedText.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ export const invalidArn = localize('AWS.error.invalidArn', 'Invalid ARN')
1818
export const localizedDelete = localize('AWS.generic.delete', 'Delete')
1919
export const cancel = localize('AWS.generic.cancel', 'Cancel')
2020
export const help = localize('AWS.generic.help', 'Help')
21+
export const run = localize('AWS.generic.run', 'Run')
22+
export const reject = localize('AWS.generic.reject', 'Reject')
2123
export const invalidNumberWarning = localize('AWS.validateTime.error.invalidNumber', 'Input must be a positive number')
2224
export const viewDocs = localize('AWS.generic.viewDocs', 'View Documentation')
2325
export const recentlyUsed = localize('AWS.generic.recentlyUsed', 'recently used')

packages/core/src/test/codewhispererChat/tools/executeBash.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { strict as assert } from 'assert'
77
import sinon from 'sinon'
88
import { destructiveCommandWarningMessage, ExecuteBash } from '../../../codewhispererChat/tools/executeBash'
99
import { ChildProcess } from '../../../shared/utilities/processUtils'
10+
import * as vscode from 'vscode'
1011

1112
describe('ExecuteBash Tool', () => {
1213
let runStub: sinon.SinonStub
@@ -114,4 +115,55 @@ describe('ExecuteBash Tool', () => {
114115

115116
assert.strictEqual(invokeStub.callCount, 1)
116117
})
118+
119+
it('requires acceptance if the command references an absolute file path outside the workspace', () => {
120+
// Stub workspace folders to simulate a workspace at '/workspace/folder'
121+
const workspaceStub = sinon
122+
.stub(vscode.workspace, 'workspaceFolders')
123+
.value([{ uri: { fsPath: '/workspace/folder' } } as any])
124+
// Command references an absolute path outside the workspace
125+
const execBash = new ExecuteBash({ command: 'cat /not/in/workspace/file.txt', cwd: '/workspace/folder' })
126+
const result = execBash.requiresAcceptance()
127+
128+
assert.equal(
129+
result.requiresAcceptance,
130+
true,
131+
'Should require acceptance for an absolute path outside of workspace'
132+
)
133+
workspaceStub.restore()
134+
})
135+
136+
it('does NOT require acceptance if the command references a relative file path inside the workspace', () => {
137+
// Stub workspace folders to simulate a workspace at '/workspace/folder'
138+
const workspaceStub = sinon
139+
.stub(vscode.workspace, 'workspaceFolders')
140+
.value([{ uri: { fsPath: '/workspace/folder' } } as any])
141+
142+
// Command references a relative path that resolves within the workspace
143+
const execBash = new ExecuteBash({ command: 'cat ./file.txt', cwd: '/workspace/folder' })
144+
const result = execBash.requiresAcceptance()
145+
146+
assert.equal(result.requiresAcceptance, false, 'Relative path inside workspace should not require acceptance')
147+
148+
workspaceStub.restore()
149+
})
150+
151+
it('does NOT require acceptance if there is no path-like token in the command', () => {
152+
// Stub workspace folders (even though they are not used in this case)
153+
const workspaceStub = sinon
154+
.stub(vscode.workspace, 'workspaceFolders')
155+
.value([{ uri: { fsPath: '/workspace/folder' } } as any])
156+
157+
// Command with tokens that do not look like file paths
158+
const execBash = new ExecuteBash({ command: 'echo hello world', cwd: '/workspace/folder' })
159+
const result = execBash.requiresAcceptance()
160+
161+
assert.equal(
162+
result.requiresAcceptance,
163+
false,
164+
'A command without any path-like token should not require acceptance'
165+
)
166+
167+
workspaceStub.restore()
168+
})
117169
})

0 commit comments

Comments
 (0)