Redact read_file and mention payloads from ui_messages.json; prevent storing file contents in clineMessages (Fixes #8690)

This commit is contained in:
Hannes Rudolph 2025-10-16 17:19:52 -06:00
parent 270dce5050
commit 119210233a
2 changed files with 102 additions and 55 deletions

View file

@ -127,7 +127,7 @@ suite("Roo Code read_file Tool", function () {
let taskCompleted = false
let errorOccurred: string | null = null
let toolExecuted = false
let toolResult: string | null = null
let _toolResult: string | null = null
// Listen for messages
const messageHandler = ({ message }: { message: ClineMessage }) => {
@ -157,8 +157,8 @@ suite("Roo Code read_file Tool", function () {
resultMatch = requestData.request.match(/Result:\s*\n([\s\S]+?)(?:\n\n|$)/)
}
if (resultMatch) {
toolResult = resultMatch[1]
console.log("Extracted tool result:", toolResult)
_toolResult = resultMatch[1]
console.log("Extracted tool result:", _toolResult)
} else {
console.log("Could not extract tool result from request")
}
@ -235,16 +235,7 @@ suite("Roo Code read_file Tool", function () {
// Check that no errors occurred
assert.strictEqual(errorOccurred, null, "No errors should have occurred")
// Verify the tool returned the correct content
assert.ok(toolResult !== null, "Tool should have returned a result")
// The tool returns content with line numbers, so we need to extract just the content
// For single line, the format is "1 | Hello, World!"
const actualContent = (toolResult as string).replace(/^\d+\s*\|\s*/, "")
assert.strictEqual(
actualContent.trim(),
"Hello, World!",
"Tool should have returned the exact file content",
)
// Note: read_file tool result content is redacted from clineMessages; verify via AI response instead.
// Also verify the AI mentioned the content in its response
const hasContent = messages.some(
@ -270,7 +261,7 @@ suite("Roo Code read_file Tool", function () {
const messages: ClineMessage[] = []
let taskCompleted = false
let toolExecuted = false
let toolResult: string | null = null
let _toolResult: string | null = null
// Listen for messages
const messageHandler = ({ message }: { message: ClineMessage }) => {
@ -297,7 +288,7 @@ suite("Roo Code read_file Tool", function () {
resultMatch = requestData.request.match(/Result:\s*\n([\s\S]+?)(?:\n\n|$)/)
}
if (resultMatch) {
toolResult = resultMatch[1]
_toolResult = resultMatch[1]
console.log("Extracted multiline tool result")
} else {
console.log("Could not extract tool result from request")
@ -344,20 +335,7 @@ suite("Roo Code read_file Tool", function () {
// Verify the read_file tool was executed
assert.ok(toolExecuted, "The read_file tool should have been executed")
// Verify the tool returned the correct multiline content
assert.ok(toolResult !== null, "Tool should have returned a result")
// The tool returns content with line numbers, so we need to extract just the content
const lines = (toolResult as string).split("\n").map((line) => {
const match = line.match(/^\d+\s*\|\s*(.*)$/)
return match ? match[1] : line
})
const actualContent = lines.join("\n")
const expectedContent = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
assert.strictEqual(
actualContent.trim(),
expectedContent,
"Tool should have returned the exact multiline content",
)
// Note: read_file tool result content is redacted from clineMessages; verify via AI response instead.
// Also verify the AI mentioned the correct number of lines
const hasLineCount = messages.some(
@ -381,7 +359,7 @@ suite("Roo Code read_file Tool", function () {
const messages: ClineMessage[] = []
let taskCompleted = false
let toolExecuted = false
let toolResult: string | null = null
let _toolResult: string | null = null
// Listen for messages
const messageHandler = ({ message }: { message: ClineMessage }) => {
@ -408,7 +386,7 @@ suite("Roo Code read_file Tool", function () {
resultMatch = requestData.request.match(/Result:\s*\n([\s\S]+?)(?:\n\n|$)/)
}
if (resultMatch) {
toolResult = resultMatch[1]
_toolResult = resultMatch[1]
console.log("Extracted line range tool result")
} else {
console.log("Could not extract tool result from request")
@ -455,22 +433,7 @@ suite("Roo Code read_file Tool", function () {
// Verify tool was executed
assert.ok(toolExecuted, "The read_file tool should have been executed")
// Verify the tool returned the correct lines (when line range is used)
if (toolResult && (toolResult as string).includes(" | ")) {
// The result includes line numbers
assert.ok(
(toolResult as string).includes("2 | Line 2"),
"Tool result should include line 2 with line number",
)
assert.ok(
(toolResult as string).includes("3 | Line 3"),
"Tool result should include line 3 with line number",
)
assert.ok(
(toolResult as string).includes("4 | Line 4"),
"Tool result should include line 4 with line number",
)
}
// Note: read_file tool result content is redacted from clineMessages; verify via AI response instead.
// Also verify the AI mentioned the specific lines
const hasLines = messages.some(

View file

@ -603,11 +603,55 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// Cline Messages
// Redact file payloads from UI-persisted messages (ui_messages.json)
// while leaving full content intact for apiConversationHistory.
private sanitizeMessageText(text?: string): string | undefined {
if (!text) return text
const scrub = (s: string): string => {
// Replace inner contents of known file payload tags with an omission marker
// Order matters: scrub more specific tags first.
s = s.replace(/<file_content\b[\s\S]*?<\/file_content>/gi, "<file_content>[omitted]</file_content>")
s = s.replace(/<content\b[^>]*>[\s\S]*?<\/content>/gi, "<content>[omitted]</content>")
s = s.replace(/<file\b[^>]*>[\s\S]*?<\/file>/gi, "<file>[omitted]</file>")
s = s.replace(/<files\b[^>]*>[\s\S]*?<\/files>/gi, "<files>[omitted]</files>")
return s
}
// If this is a JSON payload (e.g. api_req_started), try to sanitize the 'request' field.
try {
const obj = JSON.parse(text)
if (obj && typeof obj === "object" && typeof obj.request === "string") {
obj.request = scrub(obj.request)
return JSON.stringify(obj)
}
} catch {
// Not JSON, fall-through to raw scrub
}
return scrub(text)
}
// Sanitize an array of messages for persistence to UI storage
private sanitizeMessagesArray(messages: ClineMessage[]): ClineMessage[] {
return messages.map((m) => {
if (typeof (m as any).text === "string") {
return { ...m, text: this.sanitizeMessageText((m as any).text) }
}
return m
})
}
private async getSavedClineMessages(): Promise<ClineMessage[]> {
return readTaskMessages({ taskId: this.taskId, globalStoragePath: this.globalStoragePath })
const msgs = await readTaskMessages({ taskId: this.taskId, globalStoragePath: this.globalStoragePath })
return this.sanitizeMessagesArray(msgs)
}
private async addToClineMessages(message: ClineMessage) {
// Sanitize any UI-persisted text before storing
if (typeof message.text === "string") {
message.text = this.sanitizeMessageText(message.text)
}
this.clineMessages.push(message)
const provider = this.providerRef.deref()
await provider?.postStateToWebview()
@ -625,7 +669,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
}
public async overwriteClineMessages(newMessages: ClineMessage[]) {
this.clineMessages = newMessages
this.clineMessages = this.sanitizeMessagesArray(newMessages)
// If deletion or history truncation leaves a condense_context as the last message,
// ensure the next API call suppresses previous_response_id so the condensed context is respected.
@ -643,6 +687,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
}
private async updateClineMessage(message: ClineMessage) {
// Ensure any updates are also sanitized before persisting/posting
if (typeof message.text === "string") {
message.text = this.sanitizeMessageText(message.text)
}
const provider = this.providerRef.deref()
await provider?.postMessageToWebview({ type: "messageUpdated", clineMessage: message })
this.emit(RooCodeEventName.Message, { action: "updated", message })
@ -659,8 +707,11 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
private async saveClineMessages() {
try {
// Sanitize just before persisting to ensure any direct mutations are scrubbed
const sanitizedMessages = this.sanitizeMessagesArray(this.clineMessages)
await saveTaskMessages({
messages: this.clineMessages,
messages: sanitizedMessages,
taskId: this.taskId,
globalStoragePath: this.globalStoragePath,
})
@ -670,7 +721,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
rootTaskId: this.rootTaskId,
parentTaskId: this.parentTaskId,
taskNumber: this.taskNumber,
messages: this.clineMessages,
messages: sanitizedMessages,
globalStoragePath: this.globalStoragePath,
workspace: this.cwd,
mode: this._taskMode || defaultModeSlug, // Use the task's own mode, not the current provider mode.
@ -1790,12 +1841,45 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
const modelId = getModelId(this.apiConfiguration)
const apiProtocol = getApiProtocol(this.apiConfiguration.apiProvider, modelId)
// Redact any read_file results or file payload blocks from UI messages.
// This prevents file contents from being persisted to ui_messages.json while
// still sending full content to the LLM via apiConversationHistory.
const formatRequestWithReadFileRedaction = (blocks: Anthropic.Messages.ContentBlockParam[]) => {
let redactNext = false
const parts = blocks.map((block: any) => {
if (block?.type === "text") {
const text = String(block.text ?? "")
// 1) Detect the explicit read_file header line emitted by pushToolResult
const isReadFileHeader = /^\[read_file\b[\s\S]*\]\s*Result:/i.test(text)
// 2) Detect any XML-like file payloads that tools may include
// Examples: <files>...</files>, <file>...</file>, <content ...>...</content>, <file_content ...>...</file_content>
const looksLikeFilePayload = /<files[\s>]|<file[\s>]|<content\b|<file_content\b/i.test(text)
// If we see the header, show the header but redact the next text block (payload)
if (isReadFileHeader) {
redactNext = true
return text
}
// If the previous block was a read_file header, or this block itself looks like a file payload, redact it
if (redactNext || looksLikeFilePayload) {
redactNext = false
return "[tool output omitted from UI storage]"
}
}
// Default formatting for other blocks
return formatContentBlockToMarkdown(block as any)
})
return parts.join("\n\n")
}
await this.say(
"api_req_started",
JSON.stringify({
request:
currentUserContent.map((block) => formatContentBlockToMarkdown(block)).join("\n\n") +
"\n\nLoading...",
request: formatRequestWithReadFileRedaction(currentUserContent) + "\n\nLoading...",
apiProtocol,
}),
)
@ -1835,7 +1919,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
const lastApiReqIndex = findLastIndex(this.clineMessages, (m) => m.say === "api_req_started")
this.clineMessages[lastApiReqIndex].text = JSON.stringify({
request: finalUserContent.map((block) => formatContentBlockToMarkdown(block)).join("\n\n"),
request: formatRequestWithReadFileRedaction(finalUserContent),
apiProtocol,
} satisfies ClineApiReqInfo)