Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit 3bcd746

Browse files
committed
fix: sanitize tool outputs to prevent command/file prompt injection
Escape potential XML/HTML-like tags in untrusted tool outputs (command output, file contents, binary extraction) before they are fed back into the LLM context. This mitigates indirect prompt injection via malicious file contents or shell output. - Add sanitizeForPromptInjection() helper to text-normalization - Apply sanitization in ExecuteCommandTool result formatting - Apply sanitization in ReadFileTool text and binary paths - Apply sanitization in extract-text binary extractors
1 parent ad25634 commit 3bcd746

5 files changed

Lines changed: 47 additions & 11 deletions

File tree

src/core/tools/ExecuteCommandTool.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { Task } from "../task/Task"
1111

1212
import { ToolUse, ToolResponse } from "../../shared/tools"
1313
import { formatResponse } from "../prompts/responses"
14-
import { unescapeHtmlEntities } from "../../utils/text-normalization"
14+
import { unescapeHtmlEntities, sanitizeForPromptInjection } from "../../utils/text-normalization"
1515
import { ExitCodeDetails, RooTerminalCallbacks, RooTerminalProcess } from "../../integrations/terminal/types"
1616
import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry"
1717
import { Terminal } from "../../integrations/terminal/Terminal"
@@ -459,6 +459,8 @@ export async function executeCommandInTerminal(
459459
await onCompletedPromise
460460
}
461461

462+
const safeResult = sanitizeForPromptInjection(result)
463+
462464
if (message) {
463465
const { text, images } = message
464466
await task.say("user_feedback", text, images)
@@ -468,7 +470,7 @@ export async function executeCommandInTerminal(
468470
formatResponse.toolResult(
469471
[
470472
`Command is still running in terminal from '${terminal.getCurrentWorkingDirectory().toPosix()}'.`,
471-
result.length > 0 ? `Here's the output so far:\n${result}\n` : "\n",
473+
safeResult.length > 0 ? `Here's the output so far:\n${safeResult}\n` : "\n",
472474
`<user_message>\n${text}\n</user_message>`,
473475
].join("\n"),
474476
images,
@@ -509,14 +511,14 @@ export async function executeCommandInTerminal(
509511

510512
return [
511513
false,
512-
`Command executed in terminal within working directory '${currentWorkingDir}'. ${exitStatus}\nOutput:\n${result}`,
514+
`Command executed in terminal within working directory '${currentWorkingDir}'. ${exitStatus}\nOutput:\n${safeResult}`,
513515
]
514516
} else {
515517
return [
516518
false,
517519
[
518520
`Command is still running in terminal ${workingDir ? ` from '${workingDir.toPosix()}'` : ""}.`,
519-
result.length > 0 ? `Here's the output so far:\n${result}\n` : "\n",
521+
safeResult.length > 0 ? `Here's the output so far:\n${safeResult}\n` : "\n",
520522
"You will be updated on the terminal status and new output in the future.",
521523
].join("\n"),
522524
]
@@ -569,7 +571,7 @@ function formatPersistedOutput(
569571
`Output (${sizeStr}) persisted. Artifact ID: ${artifactId}`,
570572
"",
571573
"Preview:",
572-
result.preview,
574+
sanitizeForPromptInjection(result.preview),
573575
"",
574576
"Use read_command_output tool to view full output if needed.",
575577
].join("\n")

src/core/tools/ReadFileTool.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { RecordSource } from "../context-tracking/FileContextTrackerTypes"
2121
import { isPathOutsideWorkspace } from "../../utils/pathUtils"
2222
import { getReadablePath } from "../../utils/path"
2323
import { extractTextFromFile, addLineNumbers, getSupportedBinaryFormats } from "../../integrations/misc/extract-text"
24+
import { sanitizeForPromptInjection } from "../../utils/text-normalization"
2425
import { readWithIndentation, readWithSlice } from "../../integrations/misc/indentation-reader"
2526
import { DEFAULT_LINE_LIMIT } from "../prompts/tools/native-tools/read_file"
2627
import type { ToolUse, PushToolResult } from "../../shared/tools"
@@ -221,7 +222,7 @@ export class ReadFileTool extends BaseTool<"read_file"> {
221222
await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource)
222223

223224
updateFileResult(relPath, {
224-
nativeContent: `File: ${relPath}\n${result}`,
225+
nativeContent: `File: ${relPath}\n${sanitizeForPromptInjection(result)}`,
225226
})
226227
} catch (error) {
227228
const errorMsg = error instanceof Error ? error.message : String(error)
@@ -397,7 +398,7 @@ export class ReadFileTool extends BaseTool<"read_file"> {
397398
updateFileResult(relPath, {
398399
nativeContent:
399400
lineCount > 0
400-
? `File: ${relPath}\nLines 1-${lineCount}:\n${numberedContent}`
401+
? `File: ${relPath}\nLines 1-${lineCount}:\n${sanitizeForPromptInjection(numberedContent)}`
401402
: `File: ${relPath}\nNote: File is empty`,
402403
})
403404
return
@@ -794,7 +795,7 @@ export class ReadFileTool extends BaseTool<"read_file"> {
794795
}
795796
}
796797

797-
results.push(`File: ${relPath}\n${content}`)
798+
results.push(`File: ${relPath}\n${sanitizeForPromptInjection(content)}`)
798799

799800
// Track file in context
800801
await task.fileContextTracker.trackFileContext(relPath, "read_tool")

src/integrations/misc/extract-text.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { isBinaryFile } from "isbinaryfile"
77
import { extractTextFromXLSX } from "./extract-text-from-xlsx"
88
import { readWithSlice } from "./indentation-reader"
99
import { DEFAULT_LINE_LIMIT } from "../../core/prompts/tools/native-tools/read_file"
10+
import { sanitizeForPromptInjection } from "../../utils/text-normalization"
1011

1112
async function extractTextFromPDF(filePath: string): Promise<string> {
1213
const dataBuffer = await fs.readFile(filePath)
@@ -91,7 +92,7 @@ export async function extractTextFromFileWithMetadata(
9192
const extractor = SUPPORTED_BINARY_FORMATS[fileExtension as keyof typeof SUPPORTED_BINARY_FORMATS]
9293
if (extractor) {
9394
// For binary formats, extract and count lines
94-
const content = await extractor(filePath)
95+
const content = sanitizeForPromptInjection(await extractor(filePath))
9596
const lines = content.split("\n")
9697
return {
9798
content,
@@ -130,7 +131,7 @@ export async function extractTextFromFileWithMetadata(
130131
*/
131132
export async function extractTextFromFile(filePath: string): Promise<string> {
132133
const result = await extractTextFromFileWithMetadata(filePath)
133-
return result.content
134+
return sanitizeForPromptInjection(result.content)
134135
}
135136

136137
export function addLineNumbers(content: string, startLine: number = 1): string {

src/utils/__tests__/text-normalization.spec.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { normalizeString, unescapeHtmlEntities } from "../text-normalization"
1+
import { normalizeString, unescapeHtmlEntities, sanitizeForPromptInjection } from "../text-normalization"
22

33
describe("Text normalization utilities", () => {
44
describe("normalizeString", () => {
@@ -100,5 +100,26 @@ describe("Text normalization utilities", () => {
100100
const expected = "array[0] and [1]"
101101
expect(unescapeHtmlEntities(input)).toBe(expected)
102102
})
103+
104+
describe("sanitizeForPromptInjection", () => {
105+
it("escapes XML-like tags", () => {
106+
expect(sanitizeForPromptInjection("<user_message>inject</user_message>")).toBe(
107+
"\\<user_message>inject\\</user_message>",
108+
)
109+
})
110+
111+
it("escapes HTML comment-like sequences", () => {
112+
expect(sanitizeForPromptInjection("<!-- inject -->")).toBe("\\<!-- inject -->")
113+
})
114+
115+
it("does not escape standalone less-than signs", () => {
116+
expect(sanitizeForPromptInjection("a < b")).toBe("a < b")
117+
})
118+
119+
it("returns original string when no tags are present", () => {
120+
const original = "Plain text without any markup"
121+
expect(sanitizeForPromptInjection(original)).toBe(original)
122+
})
123+
})
103124
})
104125
})

src/utils/text-normalization.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,17 @@ export function normalizeString(str: string, options: NormalizeOptions = DEFAULT
7676
return normalized
7777
}
7878

79+
/**
80+
* Escapes potential XML/HTML-like tags to prevent indirect prompt injection
81+
* via tool outputs (command output, file contents, etc.).
82+
*
83+
* @param content The untrusted content to sanitize
84+
* @returns The sanitized content with tag-like sequences escaped
85+
*/
86+
export function sanitizeForPromptInjection(content: string): string {
87+
return content.replace(/<(\/?[a-zA-Z!?])/g, "\\<$1")
88+
}
89+
7990
/**
8091
* Unescapes common HTML entities in a string
8192
*

0 commit comments

Comments
 (0)