From 3b3719de4e3b253c6d0235ddb09046c63b1dc0ad Mon Sep 17 00:00:00 2001 From: filip <44206832+filipkujawa@users.noreply.github.com> Date: Wed, 5 Aug 2026 19:11:19 -0700 Subject: [PATCH] fix(developer): byte-bound the shell truncation preview (#10992) --- .../platform_extensions/developer/shell.rs | 42 ++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/crates/goose/src/agents/platform_extensions/developer/shell.rs b/crates/goose/src/agents/platform_extensions/developer/shell.rs index c18b74137..61d76ec34 100644 --- a/crates/goose/src/agents/platform_extensions/developer/shell.rs +++ b/crates/goose/src/agents/platform_extensions/developer/shell.rs @@ -158,6 +158,7 @@ fn unix_shell() -> String { const OUTPUT_LIMIT_LINES: usize = 2000; pub const OUTPUT_LIMIT_BYTES: usize = 50_000; const OUTPUT_PREVIEW_LINES: usize = 50; +const OUTPUT_PREVIEW_BYTES: usize = 10_000; const OUTPUT_SLOTS: usize = 8; @@ -890,7 +891,7 @@ fn truncate_output( let output_path = save_full_output(full_output, label, output_dir)?; let preview_start = total_lines.saturating_sub(OUTPUT_PREVIEW_LINES); - let preview = lines[preview_start..].join("\n"); + let preview = truncate_preview_bytes(lines[preview_start..].join("\n")); let reason = if exceeded_lines { format!("Output exceeded {OUTPUT_LIMIT_LINES} line limit ({total_lines} lines total).") @@ -910,6 +911,21 @@ fn truncate_output( }) } +/// Keep the preview's tail within OUTPUT_PREVIEW_BYTES so lines without +/// newlines (progress bars using `\r`, minified or base64 content) cannot +/// smuggle an unbounded preview past the line-based truncation. +#[allow(clippy::string_slice)] // The start index is snapped to a char boundary. +fn truncate_preview_bytes(preview: String) -> String { + if preview.len() <= OUTPUT_PREVIEW_BYTES { + return preview; + } + let mut start = preview.len() - OUTPUT_PREVIEW_BYTES; + while !preview.is_char_boundary(start) { + start += 1; + } + preview[start..].to_string() +} + fn save_full_output( output: &str, label: &str, @@ -1200,6 +1216,30 @@ mod tests { assert!(notice.contains("Full output saved to")); } + #[test] + fn render_output_preview_is_byte_bounded_for_giant_lines() { + let dir = tempfile::tempdir().unwrap(); + let input = "x".repeat(200_000); + + let result = render_output(&input, "test_giant_line", dir.path()).unwrap(); + + assert!(result.text.len() <= OUTPUT_PREVIEW_BYTES); + let info = result + .truncation + .as_ref() + .expect("expected truncation info"); + assert!(info.reason.contains("byte limit")); + assert_eq!(std::fs::read_to_string(&info.path).unwrap().len(), 200_000); + } + + #[test] + fn truncate_preview_bytes_respects_char_boundaries() { + let preview = "é".repeat(OUTPUT_PREVIEW_BYTES); + let truncated = truncate_preview_bytes(preview); + assert!(truncated.len() <= OUTPUT_PREVIEW_BYTES); + assert!(truncated.chars().all(|c| c == 'é')); + } + #[test] fn save_full_output_reuses_same_path() { let dir = tempfile::tempdir().unwrap();