From 62e75d1f26b97b8682feebaf26f1dfcf88996cfa Mon Sep 17 00:00:00 2001 From: Jasper Hugo Date: Tue, 21 Jul 2026 14:49:15 +0200 Subject: [PATCH 1/4] fix(conversation): sanitize nested tool responses --- .../src/conversation/message.rs | 156 +++++++++++++++++- .../src/session/import_formats/claude_code.rs | 51 ++++++ .../goose/src/session/import_formats/codex.rs | 28 ++++ 3 files changed, 230 insertions(+), 5 deletions(-) diff --git a/crates/goose-provider-types/src/conversation/message.rs b/crates/goose-provider-types/src/conversation/message.rs index a33be08d228a..99d60f731480 100644 --- a/crates/goose-provider-types/src/conversation/message.rs +++ b/crates/goose-provider-types/src/conversation/message.rs @@ -6,7 +6,7 @@ use chrono::Utc; use rmcp::model::{ AnnotateAble, CallToolRequestParams, CallToolResult, Content, ElicitationAction, ImageContent, JsonObject, PromptMessage, PromptMessageContent, PromptMessageRole, RawContent, - RawImageContent, RawTextContent, Role, TextContent, + RawImageContent, RawTextContent, ResourceContents, Role, TextContent, }; use serde::{Deserialize, Deserializer, Serialize}; use std::collections::HashSet; @@ -75,6 +75,33 @@ where pub type ProviderMetadata = serde_json::Map; pub type ToolResult = Result; +fn sanitize_tool_result(tool_result: ToolResult) -> ToolResult { + match tool_result { + Ok(mut result) => { + for content in &mut result.content { + match &mut content.raw { + RawContent::Text(text) => { + text.text = sanitize_unicode_tags(&text.text); + } + RawContent::Resource(resource) => { + if let ResourceContents::TextResourceContents { text, .. } = + &mut resource.resource + { + *text = sanitize_unicode_tags(text); + } + } + _ => {} + } + } + Ok(result) + } + Err(mut error) => { + error.message = sanitize_unicode_tags(error.message.as_ref()).into(); + Err(error) + } + } +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ToolRequest { @@ -434,7 +461,7 @@ impl MessageContent { pub fn tool_response>(id: S, tool_result: ToolResult) -> Self { MessageContent::ToolResponse(ToolResponse { id: id.into(), - tool_result, + tool_result: sanitize_tool_result(tool_result), metadata: None, }) } @@ -446,7 +473,7 @@ impl MessageContent { ) -> Self { MessageContent::ToolResponse(ToolResponse { id: id.into(), - tool_result, + tool_result: sanitize_tool_result(tool_result), metadata: metadata.cloned(), }) } @@ -1173,12 +1200,13 @@ pub struct TokenState { #[cfg(test)] mod tests { use crate::conversation::message::{ - ActionRequiredData, Message, MessageContent, MessageMetadata, + ActionRequiredData, Message, MessageContent, MessageMetadata, ProviderMetadata, }; use crate::conversation::*; use rmcp::model::{ AnnotateAble, CallToolRequestParams, CallToolResult, PromptMessage, PromptMessageContent, - PromptMessageRole, RawEmbeddedResource, RawImageContent, RawTextContent, ResourceContents, + PromptMessageRole, RawContent, RawEmbeddedResource, RawImageContent, RawTextContent, + ResourceContents, }; use rmcp::model::{ElicitationAction, ErrorCode, ErrorData}; use rmcp::object; @@ -1198,6 +1226,124 @@ mod tests { assert_eq!(message.as_concat_text(), clean_text); } + #[test] + fn test_tool_response_sanitizes_unicode_tags() { + let content = MessageContent::tool_response( + "tool-1", + Ok(CallToolResult::success(vec![Content::text( + "visible\u{E0041}\u{E0042}text", + )])), + ); + + let MessageContent::ToolResponse(response) = content else { + panic!("expected tool response"); + }; + let result = response.tool_result.unwrap(); + let RawContent::Text(text) = &result.content[0].raw else { + panic!("expected text content"); + }; + assert_eq!(text.text, "visibletext"); + } + + #[test] + fn test_tool_response_with_metadata_sanitizes_unicode_tags() { + let mut metadata = ProviderMetadata::new(); + metadata.insert("provider".to_string(), serde_json::json!("test")); + let tagged = Content::text("result\u{E0041}").with_audience(vec![Role::Assistant]); + let mut message = Message::user(); + + message.add_tool_response_with_metadata( + "tool-1", + Ok(CallToolResult::success(vec![tagged])), + Some(&metadata), + ); + + let MessageContent::ToolResponse(response) = &message.content[0] else { + panic!("expected tool response"); + }; + assert_eq!(response.metadata.as_ref(), Some(&metadata)); + let result = response.tool_result.as_ref().unwrap(); + let text = &result.content[0]; + assert_eq!(text.audience(), Some(&vec![Role::Assistant])); + let RawContent::Text(text) = &text.raw else { + panic!("expected text content"); + }; + assert_eq!(text.text, "result"); + } + + #[test] + fn test_tool_response_sanitizes_error_message() { + let data = serde_json::json!({"retry": false}); + let content = MessageContent::tool_response( + "tool-1", + Err(ErrorData::new( + ErrorCode::INTERNAL_ERROR, + "error\u{E0041}text", + Some(data.clone()), + )), + ); + + let MessageContent::ToolResponse(response) = content else { + panic!("expected tool response"); + }; + let error = response.tool_result.unwrap_err(); + assert_eq!(error.message, "errortext"); + assert_eq!(error.code, ErrorCode::INTERNAL_ERROR); + assert_eq!(error.data, Some(data)); + } + + #[test] + fn test_tool_response_sanitizes_text_resource() { + let resource = ResourceContents::TextResourceContents { + uri: "file:///result.txt".to_string(), + mime_type: Some("text/plain".to_string()), + text: "resource\u{E0041}text".to_string(), + meta: None, + }; + let content = MessageContent::tool_response( + "tool-1", + Ok(CallToolResult::success(vec![Content::resource(resource)])), + ); + + let MessageContent::ToolResponse(response) = content else { + panic!("expected tool response"); + }; + let result = response.tool_result.unwrap(); + let RawContent::Resource(resource) = &result.content[0].raw else { + panic!("expected resource content"); + }; + let ResourceContents::TextResourceContents { + uri, + mime_type, + text, + meta, + } = &resource.resource + else { + panic!("expected text resource"); + }; + assert_eq!(uri, "file:///result.txt"); + assert_eq!(mime_type.as_deref(), Some("text/plain")); + assert_eq!(text, "resourcetext"); + assert!(meta.is_none()); + } + + #[test] + fn test_tool_response_sanitization_preserves_legitimate_content() { + let text = Content::text("世界 🌍 café").with_audience(vec![Role::Assistant]); + let image = Content::image("image-data", "image/png").with_audience(vec![Role::User]); + let mut result = CallToolResult::success(vec![text, image]); + result.structured_content = Some(serde_json::json!({"safe": "世界"})); + result.meta = Some(rmcp::model::Meta(object!({"source": "test"}))); + let expected = result.clone(); + + let content = MessageContent::tool_response("tool-1", Ok(result)); + + let MessageContent::ToolResponse(response) = content else { + panic!("expected tool response"); + }; + assert_eq!(response.tool_result.unwrap(), expected); + } + #[test] fn test_message_serialization() { let message = Message::assistant() diff --git a/crates/goose/src/session/import_formats/claude_code.rs b/crates/goose/src/session/import_formats/claude_code.rs index e7e5f942a3aa..b29c300ef032 100644 --- a/crates/goose/src/session/import_formats/claude_code.rs +++ b/crates/goose/src/session/import_formats/claude_code.rs @@ -333,6 +333,57 @@ mod tests { assert!(content.iter().any(|c| c["type"] == "toolResponse")); } + #[test] + fn sanitizes_unicode_tags_in_tool_result() { + let jsonl = serde_json::json!({ + "type": "user", + "sessionId": "s", + "uuid": "u1", + "timestamp": "2026-01-01T00:00:00Z", + "cwd": "/tmp", + "message": { + "role": "user", + "content": [{ + "type": "tool_result", + "tool_use_id": "toolu_1", + "content": [{"type": "text", "text": "visible\u{E0041}世界"}] + }] + } + }) + .to_string(); + + let json = convert(&jsonl).unwrap(); + + assert!(json.contains("visible世界")); + assert!(!json.contains('\u{E0041}')); + } + + #[test] + fn sanitizes_unicode_tags_in_tool_result_error() { + let jsonl = serde_json::json!({ + "type": "user", + "sessionId": "s", + "uuid": "u1", + "timestamp": "2026-01-01T00:00:00Z", + "cwd": "/tmp", + "message": { + "role": "user", + "content": [{ + "type": "tool_result", + "tool_use_id": "toolu_1", + "is_error": true, + "content": "failed\u{E0041}café" + }] + } + }) + .to_string(); + + let json = convert(&jsonl).unwrap(); + + assert!(json.contains("failedcafé")); + assert!(!json.contains('\u{E0041}')); + } + #[test] fn emits_cache_token_breakdown() { let jsonl = r#"{"type":"user","sessionId":"s","uuid":"u1","timestamp":"2026-01-01T00:00:01Z","cwd":"/tmp","message":{"role":"user","content":"hi"}} diff --git a/crates/goose/src/session/import_formats/codex.rs b/crates/goose/src/session/import_formats/codex.rs index e76a30350b36..88a48bb3195b 100644 --- a/crates/goose/src/session/import_formats/codex.rs +++ b/crates/goose/src/session/import_formats/codex.rs @@ -341,6 +341,34 @@ mod tests { .any(|c| c["type"] == "toolResponse")); } + #[test] + fn sanitizes_unicode_tags_in_function_call_output() { + let jsonl = [ + serde_json::json!({ + "timestamp": "2026-05-22T13:37:22Z", + "type": "session_meta", + "payload": {"id": "s", "cwd": "/w"} + }) + .to_string(), + serde_json::json!({ + "timestamp": "2026-05-22T13:37:23Z", + "type": "response_item", + "payload": { + "type": "function_call_output", + "call_id": "call_1", + "output": "visible\u{E0041}世界" + } + }) + .to_string(), + ] + .join("\n"); + + let json = convert(&jsonl).unwrap(); + + assert!(json.contains("visible世界")); + assert!(!json.contains('\u{E0041}')); + } + #[test] fn first_user_text_skips_context_blobs() { let jsonl = r#"{"timestamp":"2026-05-22T13:37:22Z","type":"session_meta","payload":{"id":"s","cwd":"/w"}} From 02b98267424efcbafb632d40b01b35366240b90a Mon Sep 17 00:00:00 2001 From: Jasper Hugo Date: Tue, 21 Jul 2026 15:11:01 +0200 Subject: [PATCH 2/4] fix(conversation): sanitize persisted and blob tool output --- .../src/conversation/message.rs | 126 +++++++++++++++--- 1 file changed, 105 insertions(+), 21 deletions(-) diff --git a/crates/goose-provider-types/src/conversation/message.rs b/crates/goose-provider-types/src/conversation/message.rs index 99d60f731480..79712d06df29 100644 --- a/crates/goose-provider-types/src/conversation/message.rs +++ b/crates/goose-provider-types/src/conversation/message.rs @@ -2,6 +2,7 @@ use crate::conversation::token_usage::{CostSource, ProviderUsage}; use crate::conversation::tool_result_serde; use crate::mcp_utils::extract_text_from_resource; use crate::utils::sanitize_unicode_tags; +use base64::Engine; use chrono::Utc; use rmcp::model::{ AnnotateAble, CallToolRequestParams, CallToolResult, Content, ElicitationAction, ImageContent, @@ -52,18 +53,24 @@ where .map_err(|e| Error::custom(format!("Failed to deserialize MessageContent: {}", e)))?; for message_content in &mut content { - if let MessageContent::Text(text_content) = message_content { - let original = &text_content.text; - let sanitized = sanitize_unicode_tags(original); - if *original != sanitized { - tracing::info!( - original = %original, - sanitized = %sanitized, - removed_count = original.len() - sanitized.len(), - "Unicode Tags sanitized during Message deserialization" - ); - text_content.text = sanitized; + match message_content { + MessageContent::Text(text_content) => { + let original = &text_content.text; + let sanitized = sanitize_unicode_tags(original); + if *original != sanitized { + tracing::info!( + original = %original, + sanitized = %sanitized, + removed_count = original.len() - sanitized.len(), + "Unicode Tags sanitized during Message deserialization" + ); + text_content.text = sanitized; + } + } + MessageContent::ToolResponse(response) => { + sanitize_tool_result_in_place(&mut response.tool_result); } + _ => {} } } @@ -75,33 +82,49 @@ where pub type ProviderMetadata = serde_json::Map; pub type ToolResult = Result; -fn sanitize_tool_result(tool_result: ToolResult) -> ToolResult { +fn sanitize_tool_result_in_place(tool_result: &mut ToolResult) { match tool_result { - Ok(mut result) => { + Ok(result) => { for content in &mut result.content { match &mut content.raw { RawContent::Text(text) => { text.text = sanitize_unicode_tags(&text.text); } - RawContent::Resource(resource) => { - if let ResourceContents::TextResourceContents { text, .. } = - &mut resource.resource - { + RawContent::Resource(resource) => match &mut resource.resource { + ResourceContents::TextResourceContents { text, .. } => { *text = sanitize_unicode_tags(text); } - } + ResourceContents::BlobResourceContents { blob, .. } => { + let Ok(bytes) = + base64::engine::general_purpose::STANDARD.decode(blob.as_bytes()) + else { + continue; + }; + let Ok(text) = String::from_utf8(bytes) else { + continue; + }; + let sanitized = sanitize_unicode_tags(&text); + if text != sanitized { + *blob = base64::engine::general_purpose::STANDARD + .encode(sanitized.as_bytes()); + } + } + }, _ => {} } } - Ok(result) } - Err(mut error) => { + Err(error) => { error.message = sanitize_unicode_tags(error.message.as_ref()).into(); - Err(error) } } } +fn sanitize_tool_result(mut tool_result: ToolResult) -> ToolResult { + sanitize_tool_result_in_place(&mut tool_result); + tool_result +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ToolRequest { @@ -1201,8 +1224,10 @@ pub struct TokenState { mod tests { use crate::conversation::message::{ ActionRequiredData, Message, MessageContent, MessageMetadata, ProviderMetadata, + ToolResponse, }; use crate::conversation::*; + use base64::Engine; use rmcp::model::{ AnnotateAble, CallToolRequestParams, CallToolResult, PromptMessage, PromptMessageContent, PromptMessageRole, RawContent, RawEmbeddedResource, RawImageContent, RawTextContent, @@ -1327,6 +1352,65 @@ mod tests { assert!(meta.is_none()); } + #[test] + fn test_tool_response_sanitizes_utf8_blob_resource() { + let blob = + base64::engine::general_purpose::STANDARD.encode("resource\u{E0041}text".as_bytes()); + let resource = ResourceContents::BlobResourceContents { + uri: "file:///result.txt".to_string(), + mime_type: Some("text/plain".to_string()), + blob, + meta: None, + }; + let content = MessageContent::tool_response( + "tool-1", + Ok(CallToolResult::success(vec![Content::resource(resource)])), + ); + + let MessageContent::ToolResponse(response) = content else { + panic!("expected tool response"); + }; + let result = response.tool_result.unwrap(); + let RawContent::Resource(resource) = &result.content[0].raw else { + panic!("expected resource content"); + }; + let ResourceContents::BlobResourceContents { blob, .. } = &resource.resource else { + panic!("expected blob resource"); + }; + assert_eq!( + base64::engine::general_purpose::STANDARD + .decode(blob) + .unwrap(), + b"resourcetext" + ); + } + + #[test] + fn test_deserialization_sanitizes_persisted_tool_response() { + let message = Message::new( + Role::User, + 1, + vec![MessageContent::ToolResponse(ToolResponse { + id: "tool-1".to_string(), + tool_result: Ok(CallToolResult::success(vec![Content::text( + "persisted\u{E0041}text", + )])), + metadata: None, + })], + ); + + let json = serde_json::to_string(&message).unwrap(); + let deserialized: Message = serde_json::from_str(&json).unwrap(); + let MessageContent::ToolResponse(response) = &deserialized.content[0] else { + panic!("expected tool response"); + }; + let result = response.tool_result.as_ref().unwrap(); + let RawContent::Text(text) = &result.content[0].raw else { + panic!("expected text content"); + }; + assert_eq!(text.text, "persistedtext"); + } + #[test] fn test_tool_response_sanitization_preserves_legitimate_content() { let text = Content::text("世界 🌍 café").with_audience(vec![Role::Assistant]); From 1e9144c7dbc40d0fb0c7870cb9bcba83128ad1d5 Mon Sep 17 00:00:00 2001 From: Jasper Hugo Date: Tue, 21 Jul 2026 15:19:40 +0200 Subject: [PATCH 3/4] fix(conversation): sanitize malformed blob output --- .../src/conversation/message.rs | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/crates/goose-provider-types/src/conversation/message.rs b/crates/goose-provider-types/src/conversation/message.rs index 79712d06df29..2627b8f69fbf 100644 --- a/crates/goose-provider-types/src/conversation/message.rs +++ b/crates/goose-provider-types/src/conversation/message.rs @@ -98,6 +98,7 @@ fn sanitize_tool_result_in_place(tool_result: &mut ToolResult) { let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(blob.as_bytes()) else { + *blob = sanitize_unicode_tags(blob); continue; }; let Ok(text) = String::from_utf8(bytes) else { @@ -1385,6 +1386,32 @@ mod tests { ); } + #[test] + fn test_tool_response_sanitizes_malformed_blob_resource() { + let resource = ResourceContents::BlobResourceContents { + uri: "file:///result.txt".to_string(), + mime_type: Some("text/plain".to_string()), + blob: "malformed\u{E0041}text".to_string(), + meta: None, + }; + let content = MessageContent::tool_response( + "tool-1", + Ok(CallToolResult::success(vec![Content::resource(resource)])), + ); + + let MessageContent::ToolResponse(response) = content else { + panic!("expected tool response"); + }; + let result = response.tool_result.unwrap(); + let RawContent::Resource(resource) = &result.content[0].raw else { + panic!("expected resource content"); + }; + let ResourceContents::BlobResourceContents { blob, .. } = &resource.resource else { + panic!("expected blob resource"); + }; + assert_eq!(blob, "malformedtext"); + } + #[test] fn test_deserialization_sanitizes_persisted_tool_response() { let message = Message::new( From 39e17bc62e90e1f393f2612a83595d5960379631 Mon Sep 17 00:00:00 2001 From: Jasper Hugo Date: Tue, 28 Jul 2026 02:20:39 +0200 Subject: [PATCH 4/4] fix: sanitize persisted tool result deserialization --- .../src/conversation/message.rs | 24 +++++++++++++++- .../src/conversation/tool_result_serde.rs | 28 +++++++++++-------- 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/crates/goose-provider-types/src/conversation/message.rs b/crates/goose-provider-types/src/conversation/message.rs index 1d5f346bb67e..31614a925aa3 100644 --- a/crates/goose-provider-types/src/conversation/message.rs +++ b/crates/goose-provider-types/src/conversation/message.rs @@ -83,7 +83,7 @@ where pub type ProviderMetadata = serde_json::Map; pub type ToolResult = Result; -fn sanitize_tool_result_in_place(tool_result: &mut ToolResult) { +pub(crate) fn sanitize_tool_result_in_place(tool_result: &mut ToolResult) { match tool_result { Ok(result) => { for content in &mut result.content { @@ -1404,6 +1404,28 @@ mod tests { assert_eq!(text.text, "persistedtext"); } + #[test] + fn test_content_deserialization_sanitizes_persisted_tool_response() { + let content = vec![MessageContentBlock::ToolResponse(ToolResponse { + id: "tool-1".to_string(), + tool_result: Ok(CallToolResult::success(vec![ContentBlock::text( + "persisted\u{E0041}text", + )])), + metadata: None, + })]; + + let json = serde_json::to_string(&content).unwrap(); + let deserialized: Vec = serde_json::from_str(&json).unwrap(); + let MessageContentBlock::ToolResponse(response) = &deserialized[0] else { + panic!("expected tool response"); + }; + let result = response.tool_result.as_ref().unwrap(); + let ContentBlock::Text(text) = &result.content[0] else { + panic!("expected text content"); + }; + assert_eq!(text.text, "persistedtext"); + } + #[test] fn test_tool_response_sanitization_preserves_legitimate_content() { let text = ContentBlock::Text( diff --git a/crates/goose-provider-types/src/conversation/tool_result_serde.rs b/crates/goose-provider-types/src/conversation/tool_result_serde.rs index 0f223a5db72e..da401ae14246 100644 --- a/crates/goose-provider-types/src/conversation/tool_result_serde.rs +++ b/crates/goose-provider-types/src/conversation/tool_result_serde.rs @@ -115,6 +115,7 @@ where pub mod call_tool_result { use super::*; + use crate::conversation::message::sanitize_tool_result_in_place; use rmcp::model::{CallToolResult, ContentBlock}; pub fn serialize( @@ -150,41 +151,44 @@ pub mod call_tool_result { let format = ResultFormat::deserialize(deserializer)?; - match format { + let mut result = match format { ResultFormat::SuccessWithCallToolResult { status, value } => { if status == "success" { - Ok(Ok(value)) + Ok(value) } else { - Err(serde::de::Error::custom(format!( + return Err(serde::de::Error::custom(format!( "Expected status 'success', got '{}'", status - ))) + ))); } } ResultFormat::SuccessWithContentVec { status, value } => { if status == "success" { - Ok(Ok(CallToolResult::success(value))) + Ok(CallToolResult::success(value)) } else { - Err(serde::de::Error::custom(format!( + return Err(serde::de::Error::custom(format!( "Expected status 'success', got '{}'", status - ))) + ))); } } ResultFormat::Error { status, error } => { if status == "error" { - Ok(Err(ErrorData { + Err(ErrorData { code: ErrorCode::INTERNAL_ERROR, message: Cow::from(error), data: None, - })) + }) } else { - Err(serde::de::Error::custom(format!( + return Err(serde::de::Error::custom(format!( "Expected status 'error', got '{}'", status - ))) + ))); } } - } + }; + + sanitize_tool_result_in_place(&mut result); + Ok(result) } }