Skip to content

Commit 6269bce

Browse files
authored
Merge pull request aws#6848 from tsmithsz/feature/agentic-chat
feat(chat): Add executeBash tool for Amazon Q Agentic Chat
2 parents 8392a4c + ef7da20 commit 6269bce

File tree

4 files changed

+325
-4
lines changed

4 files changed

+325
-4
lines changed
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { Writable } from 'stream'
7+
import { getLogger } from '../../shared/logger/logger'
8+
import { fs } from '../../shared/fs/fs' // e.g. for getUserHomeDir()
9+
import { ChildProcess, ChildProcessOptions } from '../../shared/utilities/processUtils'
10+
import { InvokeOutput, OutputKind, sanitizePath } from './toolShared'
11+
12+
export const readOnlyCommands: string[] = ['ls', 'cat', 'echo', 'pwd', 'which', 'head', 'tail']
13+
export const maxBashToolResponseSize: number = 1024 * 1024 // 1MB
14+
export const lineCount: number = 1024
15+
export const dangerousPatterns: string[] = ['|', '<(', '$(', '`', '>', '&&', '||']
16+
17+
export interface ExecuteBashParams {
18+
command: string
19+
cwd?: string
20+
}
21+
22+
export class ExecuteBash {
23+
private readonly command: string
24+
private readonly workingDirectory?: string
25+
private readonly logger = getLogger('executeBash')
26+
27+
constructor(params: ExecuteBashParams) {
28+
this.command = params.command
29+
this.workingDirectory = params.cwd ? sanitizePath(params.cwd) : fs.getUserHomeDir()
30+
}
31+
32+
public async validate(): Promise<void> {
33+
if (!this.command.trim()) {
34+
throw new Error('Bash command cannot be empty.')
35+
}
36+
37+
const args = ExecuteBash.parseCommand(this.command)
38+
if (!args || args.length === 0) {
39+
throw new Error('No command found.')
40+
}
41+
42+
try {
43+
await ExecuteBash.whichCommand(args[0])
44+
} catch {
45+
throw new Error(`Command "${args[0]}" not found on PATH.`)
46+
}
47+
}
48+
49+
public requiresAcceptance(): boolean {
50+
try {
51+
const args = ExecuteBash.parseCommand(this.command)
52+
if (!args || args.length === 0) {
53+
return true
54+
}
55+
56+
if (args.some((arg) => dangerousPatterns.some((pattern) => arg.includes(pattern)))) {
57+
return true
58+
}
59+
60+
const command = args[0]
61+
return !readOnlyCommands.includes(command)
62+
} catch (error) {
63+
this.logger.warn(`Error while checking acceptance: ${(error as Error).message}`)
64+
return true
65+
}
66+
}
67+
68+
public async invoke(updates: Writable): Promise<InvokeOutput> {
69+
this.logger.info(`Invoking bash command: "${this.command}" in cwd: "${this.workingDirectory}"`)
70+
71+
return new Promise(async (resolve, reject) => {
72+
this.logger.debug(`Spawning process with command: bash -c "${this.command}" (cwd=${this.workingDirectory})`)
73+
74+
const stdoutBuffer: string[] = []
75+
const stderrBuffer: string[] = []
76+
77+
const childProcessOptions: ChildProcessOptions = {
78+
spawnOptions: {
79+
cwd: this.workingDirectory,
80+
stdio: ['pipe', 'pipe', 'pipe'],
81+
},
82+
collect: false,
83+
waitForStreams: true,
84+
onStdout: (chunk: string) => {
85+
ExecuteBash.handleChunk(chunk, stdoutBuffer, updates)
86+
},
87+
onStderr: (chunk: string) => {
88+
ExecuteBash.handleChunk(chunk, stderrBuffer, updates)
89+
},
90+
}
91+
92+
const childProcess = new ChildProcess('bash', ['-c', this.command], childProcessOptions)
93+
94+
try {
95+
const result = await childProcess.run()
96+
const exitStatus = result.exitCode ?? 0
97+
const stdout = stdoutBuffer.join('\n')
98+
const stderr = stderrBuffer.join('\n')
99+
const [stdoutTrunc, stdoutSuffix] = ExecuteBash.truncateSafelyWithSuffix(
100+
stdout,
101+
maxBashToolResponseSize / 3
102+
)
103+
const [stderrTrunc, stderrSuffix] = ExecuteBash.truncateSafelyWithSuffix(
104+
stderr,
105+
maxBashToolResponseSize / 3
106+
)
107+
108+
const outputJson = {
109+
exitStatus: exitStatus.toString(),
110+
stdout: stdoutTrunc + (stdoutSuffix ? ' ... truncated' : ''),
111+
stderr: stderrTrunc + (stderrSuffix ? ' ... truncated' : ''),
112+
}
113+
114+
return {
115+
output: {
116+
kind: OutputKind.Json,
117+
content: outputJson,
118+
},
119+
}
120+
} catch (err: any) {
121+
this.logger.error(`Failed to execute bash command '${this.command}': ${err.message}`)
122+
throw new Error(`Failed to execute command: ${err.message}`)
123+
}
124+
})
125+
}
126+
127+
private static handleChunk(chunk: string, buffer: string[], updates: Writable) {
128+
const lines = chunk.split(/\r?\n/)
129+
for (const line of lines) {
130+
updates.write(`${line}\n`)
131+
buffer.push(line)
132+
if (buffer.length > lineCount) {
133+
buffer.shift()
134+
}
135+
}
136+
}
137+
138+
private static truncateSafelyWithSuffix(str: string, maxLength: number): [string, boolean] {
139+
if (str.length > maxLength) {
140+
return [str.substring(0, maxLength), true]
141+
}
142+
return [str, false]
143+
}
144+
145+
private static async whichCommand(cmd: string): Promise<string> {
146+
const cp = new ChildProcess('which', [cmd], {
147+
collect: true,
148+
waitForStreams: true,
149+
})
150+
const result = await cp.run()
151+
152+
if (result.exitCode !== 0) {
153+
throw new Error(`Command "${cmd}" not found on PATH.`)
154+
}
155+
156+
const output = result.stdout.trim()
157+
if (!output) {
158+
throw new Error(`Command "${cmd}" found but 'which' returned empty output.`)
159+
}
160+
return output
161+
}
162+
163+
private static parseCommand(command: string): string[] | undefined {
164+
const result: string[] = []
165+
let current = ''
166+
let inQuote: string | undefined
167+
let escaped = false
168+
169+
for (const char of command) {
170+
if (escaped) {
171+
current += char
172+
escaped = false
173+
} else if (char === '\\') {
174+
escaped = true
175+
} else if (inQuote) {
176+
if (char === inQuote) {
177+
inQuote = undefined
178+
} else {
179+
current += char
180+
}
181+
} else if (char === '"' || char === "'") {
182+
inQuote = char
183+
} else if (char === ' ' || char === '\t') {
184+
if (current) {
185+
result.push(current)
186+
current = ''
187+
}
188+
} else {
189+
current += char
190+
}
191+
}
192+
193+
if (current) {
194+
result.push(current)
195+
}
196+
197+
return result
198+
}
199+
200+
public queueDescription(updates: Writable): void {
201+
updates.write(`I will run the following shell command: `)
202+
203+
if (this.command.length > 20) {
204+
updates.write('\n')
205+
}
206+
updates.write(`\x1b[32m${this.command}\x1b[0m\n`)
207+
}
208+
}

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export interface FsReadParams {
1616
export class FsRead {
1717
private fsPath: string
1818
private readonly readRange?: number[]
19-
private type?: boolean // true for file, false for directory
19+
private isFile?: boolean // true for file, false for directory
2020
private readonly logger = getLogger('fsRead')
2121

2222
constructor(params: FsReadParams) {
@@ -44,19 +44,19 @@ export class FsRead {
4444
throw new Error(`Path: "${this.fsPath}" does not exist or cannot be accessed. (${err})`)
4545
}
4646

47-
this.type = await fs.existsFile(fileUri)
47+
this.isFile = await fs.existsFile(fileUri)
4848
this.logger.debug(`Validation succeeded for path: ${this.fsPath}`)
4949
}
5050

5151
public async invoke(): Promise<InvokeOutput> {
5252
try {
5353
const fileUri = vscode.Uri.file(this.fsPath)
5454

55-
if (this.type) {
55+
if (this.isFile) {
5656
const fileContents = await this.readFile(fileUri)
5757
this.logger.info(`Read file: ${this.fsPath}, size: ${fileContents.length}`)
5858
return this.handleFileRange(fileContents)
59-
} else if (!this.type) {
59+
} else if (!this.isFile) {
6060
const maxDepth = this.getDirectoryDepth() ?? 0
6161
const listing = await readDirectoryRecursively(fileUri, maxDepth)
6262
return this.createOutput(listing.join('\n'))

packages/core/src/shared/logger/logger.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export type LogTopic =
1616
| 'stepfunctions'
1717
| 'fsRead'
1818
| 'fsWrite'
19+
| 'executeBash'
1920

2021
class ErrorLog {
2122
constructor(
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { strict as assert } from 'assert'
7+
import sinon from 'sinon'
8+
import { ExecuteBash } from '../../../codewhispererChat/tools/executeBash'
9+
import { ChildProcess } from '../../../shared/utilities/processUtils'
10+
11+
describe('ExecuteBash Tool', () => {
12+
let runStub: sinon.SinonStub
13+
let invokeStub: sinon.SinonStub
14+
15+
beforeEach(() => {
16+
runStub = sinon.stub(ChildProcess.prototype, 'run')
17+
invokeStub = sinon.stub(ExecuteBash.prototype, 'invoke')
18+
})
19+
20+
afterEach(() => {
21+
sinon.restore()
22+
})
23+
24+
it('pass validation for a safe command (read-only)', async () => {
25+
runStub.resolves({
26+
exitCode: 0,
27+
stdout: '/bin/ls',
28+
stderr: '',
29+
error: undefined,
30+
signal: undefined,
31+
})
32+
const execBash = new ExecuteBash({ command: 'ls' })
33+
await execBash.validate()
34+
})
35+
36+
it('fail validation if the command is empty', async () => {
37+
const execBash = new ExecuteBash({ command: ' ' })
38+
await assert.rejects(
39+
execBash.validate(),
40+
/Bash command cannot be empty/i,
41+
'Expected an error for empty command'
42+
)
43+
})
44+
45+
it('set requiresAcceptance=true if the command has dangerous patterns', () => {
46+
const execBash = new ExecuteBash({ command: 'ls && rm -rf /' })
47+
const needsAcceptance = execBash.requiresAcceptance()
48+
assert.equal(needsAcceptance, true, 'Should require acceptance for dangerous pattern')
49+
})
50+
51+
it('set requiresAcceptance=false if it is a read-only command', () => {
52+
const execBash = new ExecuteBash({ command: 'cat file.txt' })
53+
const needsAcceptance = execBash.requiresAcceptance()
54+
assert.equal(needsAcceptance, false, 'Read-only command should not require acceptance')
55+
})
56+
57+
it('whichCommand cannot find the first arg', async () => {
58+
runStub.resolves({
59+
exitCode: 1,
60+
stdout: '',
61+
stderr: '',
62+
error: undefined,
63+
signal: undefined,
64+
})
65+
66+
const execBash = new ExecuteBash({ command: 'noSuchCmd' })
67+
await assert.rejects(execBash.validate(), /not found on PATH/i, 'Expected not found error from whichCommand')
68+
})
69+
70+
it('whichCommand sees first arg on PATH', async () => {
71+
runStub.resolves({
72+
exitCode: 0,
73+
stdout: '/usr/bin/noSuchCmd\n',
74+
stderr: '',
75+
error: undefined,
76+
signal: undefined,
77+
})
78+
79+
const execBash = new ExecuteBash({ command: 'noSuchCmd' })
80+
await execBash.validate()
81+
})
82+
83+
it('stub invoke() call', async () => {
84+
invokeStub.resolves({
85+
output: {
86+
kind: 'json',
87+
content: {
88+
exitStatus: '0',
89+
stdout: 'mocked stdout lines',
90+
stderr: '',
91+
},
92+
},
93+
})
94+
95+
const execBash = new ExecuteBash({ command: 'ls' })
96+
97+
const dummyWritable = { write: () => {} } as any
98+
const result = await execBash.invoke(dummyWritable)
99+
100+
assert.strictEqual(result.output.kind, 'json')
101+
const out = result.output.content as unknown as {
102+
exitStatus: string
103+
stdout: string
104+
stderr: string
105+
}
106+
assert.strictEqual(out.exitStatus, '0')
107+
assert.strictEqual(out.stdout, 'mocked stdout lines')
108+
assert.strictEqual(out.stderr, '')
109+
110+
assert.strictEqual(invokeStub.callCount, 1)
111+
})
112+
})

0 commit comments

Comments
 (0)