Skip to content

Commit 69572a7

Browse files
committed
feat(chat): Add executeBash tool for Amazon Q Agentic Chat
1 parent 567a954 commit 69572a7

File tree

5 files changed

+326
-4
lines changed

5 files changed

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

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/codewhispererChat/tools/toolShared.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ import path from 'path'
77
import fs from '../../shared/fs/fs'
88

99
export const maxToolResponseSize = 30720 // 30KB
10+
export const readOnlyCommands: string[] = ['ls', 'cat', 'echo', 'pwd', 'which', 'head', 'tail']
11+
export const maxBashToolResponseSize: number = 1024 * 1024 // 1MB
12+
export const lineCount: number = 1024
13+
export const dangerousPatterns: string[] = ['|', '<(', '$(', '`', '>', '&&', '||']
1014

1115
export enum OutputKind {
1216
Text = 'text',

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)