Skip to content

Commit f3a984d

Browse files
gnanam1990claude
andauthored
fix(security-review): Handle null shell output (Gitlawb#231)
Normalize shell command stdout and stderr before the prompt-shell path and shared tool-result mappers use string operations. This prevents /security-review from crashing when a shell tool returns null output fields and adds regression coverage for both direct mapper calls and prompt generation. Fixes Gitlawb#165 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 72c6e97 commit f3a984d

5 files changed

Lines changed: 191 additions & 23 deletions

File tree

src/tools/BashTool/BashTool.tsx

Lines changed: 8 additions & 6 deletions
Large diffs are not rendered by default.

src/tools/PowerShellTool/PowerShellTool.tsx

Lines changed: 11 additions & 7 deletions
Large diffs are not rendered by default.
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { expect, test } from 'bun:test'
2+
import { BashTool } from './BashTool/BashTool.js'
3+
import { PowerShellTool } from './PowerShellTool/PowerShellTool.js'
4+
5+
test('BashTool result mapper tolerates null stderr', () => {
6+
const result = BashTool.mapToolResultToToolResultBlockParam(
7+
{
8+
stdout: 'ok',
9+
stderr: null as unknown as string,
10+
interrupted: false,
11+
},
12+
'tool-1',
13+
)
14+
15+
expect(result).toMatchObject({
16+
type: 'tool_result',
17+
tool_use_id: 'tool-1',
18+
content: 'ok',
19+
})
20+
})
21+
22+
test('BashTool result mapper tolerates null stdout', () => {
23+
const result = BashTool.mapToolResultToToolResultBlockParam(
24+
{
25+
stdout: null as unknown as string,
26+
stderr: 'problem',
27+
interrupted: false,
28+
},
29+
'tool-2',
30+
)
31+
32+
expect(result).toMatchObject({
33+
type: 'tool_result',
34+
tool_use_id: 'tool-2',
35+
content: 'problem',
36+
})
37+
})
38+
39+
test('PowerShellTool result mapper tolerates null stderr', () => {
40+
const result = PowerShellTool.mapToolResultToToolResultBlockParam(
41+
{
42+
stdout: 'ok',
43+
stderr: null as unknown as string,
44+
interrupted: false,
45+
},
46+
'tool-3',
47+
)
48+
49+
expect(result).toMatchObject({
50+
type: 'tool_result',
51+
tool_use_id: 'tool-3',
52+
content: 'ok',
53+
})
54+
})
55+
56+
test('PowerShellTool result mapper tolerates null stdout', () => {
57+
const result = PowerShellTool.mapToolResultToToolResultBlockParam(
58+
{
59+
stdout: null as unknown as string,
60+
stderr: 'problem',
61+
interrupted: false,
62+
},
63+
'tool-4',
64+
)
65+
66+
expect(result).toMatchObject({
67+
type: 'tool_result',
68+
tool_use_id: 'tool-4',
69+
content: 'problem',
70+
})
71+
})
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { afterEach, expect, test } from 'bun:test'
2+
import { getEmptyToolPermissionContext } from '../Tool.js'
3+
import { BashTool } from '../tools/BashTool/BashTool.js'
4+
import { executeShellCommandsInPrompt } from './promptShellExecution.js'
5+
6+
const originalCall = BashTool.call
7+
const originalMapToolResultToToolResultBlockParam =
8+
BashTool.mapToolResultToToolResultBlockParam
9+
10+
afterEach(() => {
11+
BashTool.call = originalCall
12+
BashTool.mapToolResultToToolResultBlockParam =
13+
originalMapToolResultToToolResultBlockParam
14+
})
15+
16+
test('executeShellCommandsInPrompt normalizes null shell output', async () => {
17+
let normalizedResult:
18+
| { stdout: string; stderr: string; interrupted: boolean }
19+
| undefined
20+
21+
BashTool.call = (async () => ({
22+
data: {
23+
stdout: null,
24+
stderr: null,
25+
interrupted: false,
26+
},
27+
})) as unknown as typeof BashTool.call
28+
29+
BashTool.mapToolResultToToolResultBlockParam = (result, toolUseID) => {
30+
normalizedResult = result as {
31+
stdout: string
32+
stderr: string
33+
interrupted: boolean
34+
}
35+
return originalMapToolResultToToolResultBlockParam(result, toolUseID)
36+
}
37+
38+
await executeShellCommandsInPrompt(
39+
'```!\ngit status\n```',
40+
{
41+
abortController: new AbortController(),
42+
options: {
43+
commands: [],
44+
debug: false,
45+
mainLoopModel: 'sonnet',
46+
tools: new Map(),
47+
verbose: false,
48+
thinkingConfig: { type: 'disabled' },
49+
mcpClients: [],
50+
mcpResources: {},
51+
isNonInteractiveSession: false,
52+
agentDefinitions: {
53+
systemDefinitions: [],
54+
projectDefinitions: [],
55+
userDefinitions: [],
56+
},
57+
},
58+
readFileState: new Map(),
59+
getAppState() {
60+
return {
61+
toolPermissionContext: {
62+
...getEmptyToolPermissionContext(),
63+
alwaysAllowRules: { command: ['Bash(*)'] },
64+
},
65+
}
66+
},
67+
setAppState() {},
68+
} as never,
69+
'security-review',
70+
)
71+
72+
expect(normalizedResult).toEqual({
73+
stdout: '',
74+
stderr: '',
75+
interrupted: false,
76+
})
77+
})

src/utils/promptShellExecution.ts

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@ import { processToolResultBlock } from './toolResultStorage.js'
1616
// _simulatedSedEdit) that PowerShellTool's does not.
1717
// NOTE: call() is invoked directly here, bypassing validateInput — any
1818
// load-bearing check must live in call() itself (see PR #23311).
19-
type ShellOut = { stdout: string; stderr: string; interrupted: boolean }
19+
type ShellOut = {
20+
stdout: string | null | undefined
21+
stderr: string | null | undefined
22+
interrupted: boolean
23+
}
2024
type PromptShellTool = Tool & {
2125
call(
2226
input: { command: string },
@@ -113,17 +117,25 @@ export async function executeShellCommandsInPrompt(
113117
}
114118

115119
const { data } = await shellTool.call({ command }, context)
120+
const normalizedData = {
121+
...data,
122+
stdout: typeof data.stdout === 'string' ? data.stdout : '',
123+
stderr: typeof data.stderr === 'string' ? data.stderr : '',
124+
}
116125
// Reuse the same persistence flow as regular Bash tool calls
117126
const toolResultBlock = await processToolResultBlock(
118127
shellTool,
119-
data,
128+
normalizedData,
120129
randomUUID(),
121130
)
122131
// Extract the string content from the block
123132
const output =
124133
typeof toolResultBlock.content === 'string'
125134
? toolResultBlock.content
126-
: formatBashOutput(data.stdout, data.stderr)
135+
: formatBashOutput(
136+
normalizedData.stdout,
137+
normalizedData.stderr,
138+
)
127139
// Function replacer — String.replace interprets $$, $&, $`, $' in
128140
// the replacement string even with a string search pattern. Shell
129141
// output (especially PowerShell: $env:PATH, $$, $PSVersionTable)
@@ -143,21 +155,23 @@ export async function executeShellCommandsInPrompt(
143155
}
144156

145157
function formatBashOutput(
146-
stdout: string,
147-
stderr: string,
158+
stdout: string | null | undefined,
159+
stderr: string | null | undefined,
148160
inline = false,
149161
): string {
162+
const normalizedStdout = typeof stdout === 'string' ? stdout : ''
163+
const normalizedStderr = typeof stderr === 'string' ? stderr : ''
150164
const parts: string[] = []
151165

152-
if (stdout.trim()) {
153-
parts.push(stdout.trim())
166+
if (normalizedStdout.trim()) {
167+
parts.push(normalizedStdout.trim())
154168
}
155169

156-
if (stderr.trim()) {
170+
if (normalizedStderr.trim()) {
157171
if (inline) {
158-
parts.push(`[stderr: ${stderr.trim()}]`)
172+
parts.push(`[stderr: ${normalizedStderr.trim()}]`)
159173
} else {
160-
parts.push(`[stderr]\n${stderr.trim()}`)
174+
parts.push(`[stderr]\n${normalizedStderr.trim()}`)
161175
}
162176
}
163177

0 commit comments

Comments
 (0)