fix(security-review): Handle null shell output (#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 #165

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
KRATOS
2026-04-03 13:44:28 +05:30
committed by GitHub
parent 72c6e97094
commit f3a984dde1
5 changed files with 191 additions and 23 deletions

View File

@@ -578,10 +578,12 @@ export const BashTool = buildTool({
const block = buildImageToolResult(stdout, toolUseID); const block = buildImageToolResult(stdout, toolUseID);
if (block) return block; if (block) return block;
} }
let processedStdout = stdout; const normalizedStdout = typeof stdout === 'string' ? stdout : '';
if (stdout) { const normalizedStderr = typeof stderr === 'string' ? stderr : '';
let processedStdout = normalizedStdout;
if (normalizedStdout) {
// Replace any leading newlines or lines with only whitespace // Replace any leading newlines or lines with only whitespace
processedStdout = stdout.replace(/^(\s*\n)+/, ''); processedStdout = normalizedStdout.replace(/^(\s*\n)+/, '');
// Still trim the end as before // Still trim the end as before
processedStdout = processedStdout.trimEnd(); processedStdout = processedStdout.trimEnd();
} }
@@ -598,9 +600,9 @@ export const BashTool = buildTool({
hasMore: preview.hasMore hasMore: preview.hasMore
}); });
} }
let errorMessage = stderr.trim(); let errorMessage = normalizedStderr.trim();
if (interrupted) { if (interrupted) {
if (stderr) errorMessage += EOL; if (normalizedStderr) errorMessage += EOL;
errorMessage += '<error>Command was aborted before completion</error>'; errorMessage += '<error>Command was aborted before completion</error>';
} }
let backgroundInfo = ''; let backgroundInfo = '';

View File

@@ -396,9 +396,13 @@ export const PowerShellTool = buildTool({
const block = buildImageToolResult(stdout, toolUseID); const block = buildImageToolResult(stdout, toolUseID);
if (block) return block; if (block) return block;
} }
let processedStdout = stdout; const normalizedStdout = typeof stdout === 'string' ? stdout : '';
const normalizedStderr = typeof stderr === 'string' ? stderr : '';
let processedStdout = normalizedStdout;
if (persistedOutputPath) { if (persistedOutputPath) {
const trimmed = stdout ? stdout.replace(/^(\s*\n)+/, '').trimEnd() : ''; const trimmed = normalizedStdout
? normalizedStdout.replace(/^(\s*\n)+/, '').trimEnd()
: '';
const preview = generatePreview(trimmed, PREVIEW_SIZE_BYTES); const preview = generatePreview(trimmed, PREVIEW_SIZE_BYTES);
processedStdout = buildLargeToolResultMessage({ processedStdout = buildLargeToolResultMessage({
filepath: persistedOutputPath, filepath: persistedOutputPath,
@@ -407,13 +411,13 @@ export const PowerShellTool = buildTool({
preview: preview.preview, preview: preview.preview,
hasMore: preview.hasMore hasMore: preview.hasMore
}); });
} else if (stdout) { } else if (normalizedStdout) {
processedStdout = stdout.replace(/^(\s*\n)+/, ''); processedStdout = normalizedStdout.replace(/^(\s*\n)+/, '');
processedStdout = processedStdout.trimEnd(); processedStdout = processedStdout.trimEnd();
} }
let errorMessage = stderr.trim(); let errorMessage = normalizedStderr.trim();
if (interrupted) { if (interrupted) {
if (stderr) errorMessage += EOL; if (normalizedStderr) errorMessage += EOL;
errorMessage += '<error>Command was aborted before completion</error>'; errorMessage += '<error>Command was aborted before completion</error>';
} }
let backgroundInfo = ''; let backgroundInfo = '';

View File

@@ -0,0 +1,71 @@
import { expect, test } from 'bun:test'
import { BashTool } from './BashTool/BashTool.js'
import { PowerShellTool } from './PowerShellTool/PowerShellTool.js'
test('BashTool result mapper tolerates null stderr', () => {
const result = BashTool.mapToolResultToToolResultBlockParam(
{
stdout: 'ok',
stderr: null as unknown as string,
interrupted: false,
},
'tool-1',
)
expect(result).toMatchObject({
type: 'tool_result',
tool_use_id: 'tool-1',
content: 'ok',
})
})
test('BashTool result mapper tolerates null stdout', () => {
const result = BashTool.mapToolResultToToolResultBlockParam(
{
stdout: null as unknown as string,
stderr: 'problem',
interrupted: false,
},
'tool-2',
)
expect(result).toMatchObject({
type: 'tool_result',
tool_use_id: 'tool-2',
content: 'problem',
})
})
test('PowerShellTool result mapper tolerates null stderr', () => {
const result = PowerShellTool.mapToolResultToToolResultBlockParam(
{
stdout: 'ok',
stderr: null as unknown as string,
interrupted: false,
},
'tool-3',
)
expect(result).toMatchObject({
type: 'tool_result',
tool_use_id: 'tool-3',
content: 'ok',
})
})
test('PowerShellTool result mapper tolerates null stdout', () => {
const result = PowerShellTool.mapToolResultToToolResultBlockParam(
{
stdout: null as unknown as string,
stderr: 'problem',
interrupted: false,
},
'tool-4',
)
expect(result).toMatchObject({
type: 'tool_result',
tool_use_id: 'tool-4',
content: 'problem',
})
})

View File

@@ -0,0 +1,77 @@
import { afterEach, expect, test } from 'bun:test'
import { getEmptyToolPermissionContext } from '../Tool.js'
import { BashTool } from '../tools/BashTool/BashTool.js'
import { executeShellCommandsInPrompt } from './promptShellExecution.js'
const originalCall = BashTool.call
const originalMapToolResultToToolResultBlockParam =
BashTool.mapToolResultToToolResultBlockParam
afterEach(() => {
BashTool.call = originalCall
BashTool.mapToolResultToToolResultBlockParam =
originalMapToolResultToToolResultBlockParam
})
test('executeShellCommandsInPrompt normalizes null shell output', async () => {
let normalizedResult:
| { stdout: string; stderr: string; interrupted: boolean }
| undefined
BashTool.call = (async () => ({
data: {
stdout: null,
stderr: null,
interrupted: false,
},
})) as unknown as typeof BashTool.call
BashTool.mapToolResultToToolResultBlockParam = (result, toolUseID) => {
normalizedResult = result as {
stdout: string
stderr: string
interrupted: boolean
}
return originalMapToolResultToToolResultBlockParam(result, toolUseID)
}
await executeShellCommandsInPrompt(
'```!\ngit status\n```',
{
abortController: new AbortController(),
options: {
commands: [],
debug: false,
mainLoopModel: 'sonnet',
tools: new Map(),
verbose: false,
thinkingConfig: { type: 'disabled' },
mcpClients: [],
mcpResources: {},
isNonInteractiveSession: false,
agentDefinitions: {
systemDefinitions: [],
projectDefinitions: [],
userDefinitions: [],
},
},
readFileState: new Map(),
getAppState() {
return {
toolPermissionContext: {
...getEmptyToolPermissionContext(),
alwaysAllowRules: { command: ['Bash(*)'] },
},
}
},
setAppState() {},
} as never,
'security-review',
)
expect(normalizedResult).toEqual({
stdout: '',
stderr: '',
interrupted: false,
})
})

View File

@@ -16,7 +16,11 @@ import { processToolResultBlock } from './toolResultStorage.js'
// _simulatedSedEdit) that PowerShellTool's does not. // _simulatedSedEdit) that PowerShellTool's does not.
// NOTE: call() is invoked directly here, bypassing validateInput — any // NOTE: call() is invoked directly here, bypassing validateInput — any
// load-bearing check must live in call() itself (see PR #23311). // load-bearing check must live in call() itself (see PR #23311).
type ShellOut = { stdout: string; stderr: string; interrupted: boolean } type ShellOut = {
stdout: string | null | undefined
stderr: string | null | undefined
interrupted: boolean
}
type PromptShellTool = Tool & { type PromptShellTool = Tool & {
call( call(
input: { command: string }, input: { command: string },
@@ -113,17 +117,25 @@ export async function executeShellCommandsInPrompt(
} }
const { data } = await shellTool.call({ command }, context) const { data } = await shellTool.call({ command }, context)
const normalizedData = {
...data,
stdout: typeof data.stdout === 'string' ? data.stdout : '',
stderr: typeof data.stderr === 'string' ? data.stderr : '',
}
// Reuse the same persistence flow as regular Bash tool calls // Reuse the same persistence flow as regular Bash tool calls
const toolResultBlock = await processToolResultBlock( const toolResultBlock = await processToolResultBlock(
shellTool, shellTool,
data, normalizedData,
randomUUID(), randomUUID(),
) )
// Extract the string content from the block // Extract the string content from the block
const output = const output =
typeof toolResultBlock.content === 'string' typeof toolResultBlock.content === 'string'
? toolResultBlock.content ? toolResultBlock.content
: formatBashOutput(data.stdout, data.stderr) : formatBashOutput(
normalizedData.stdout,
normalizedData.stderr,
)
// Function replacer — String.replace interprets $$, $&, $`, $' in // Function replacer — String.replace interprets $$, $&, $`, $' in
// the replacement string even with a string search pattern. Shell // the replacement string even with a string search pattern. Shell
// output (especially PowerShell: $env:PATH, $$, $PSVersionTable) // output (especially PowerShell: $env:PATH, $$, $PSVersionTable)
@@ -143,21 +155,23 @@ export async function executeShellCommandsInPrompt(
} }
function formatBashOutput( function formatBashOutput(
stdout: string, stdout: string | null | undefined,
stderr: string, stderr: string | null | undefined,
inline = false, inline = false,
): string { ): string {
const normalizedStdout = typeof stdout === 'string' ? stdout : ''
const normalizedStderr = typeof stderr === 'string' ? stderr : ''
const parts: string[] = [] const parts: string[] = []
if (stdout.trim()) { if (normalizedStdout.trim()) {
parts.push(stdout.trim()) parts.push(normalizedStdout.trim())
} }
if (stderr.trim()) { if (normalizedStderr.trim()) {
if (inline) { if (inline) {
parts.push(`[stderr: ${stderr.trim()}]`) parts.push(`[stderr: ${normalizedStderr.trim()}]`)
} else { } else {
parts.push(`[stderr]\n${stderr.trim()}`) parts.push(`[stderr]\n${normalizedStderr.trim()}`)
} }
} }