From d6748df7b8c9d1944e7c24c8e3d392259e138463 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 7 Jul 2026 15:52:36 -0700 Subject: [PATCH 1/8] tui: sanitize terminal controls in untrusted text --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 14 +++++++ ...e_terminal_control_sequence_sanitized.snap | 13 +++++++ codex-rs/tui/src/insert_history.rs | 20 ++++++++++ codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/terminal_hyperlinks.rs | 30 +++++++++++++-- codex-rs/tui/src/terminal_text.rs | 38 +++++++++++++++++++ codex-rs/tui/src/terminal_text_tests.rs | 18 +++++++++ 7 files changed, 131 insertions(+), 3 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__paste_terminal_control_sequence_sanitized.snap create mode 100644 codex-rs/tui/src/terminal_text.rs create mode 100644 codex-rs/tui/src/terminal_text_tests.rs diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 76a5760658fb..800ffb59584f 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -222,6 +222,7 @@ use crate::render::RectExt; use crate::render::renderable::Renderable; use crate::slash_command::SlashCommand; use crate::style::user_message_style; +use crate::terminal_text::sanitize_untrusted_text; use codex_protocol::ThreadId; use codex_protocol::user_input::ByteRange; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; @@ -887,6 +888,7 @@ impl ChatComposer { /// the next user Enter key, then syncs popup state. pub fn handle_paste(&mut self, pasted: String) -> bool { let pasted = pasted.replace("\r\n", "\n").replace('\r', "\n"); + let pasted = sanitize_untrusted_text(&pasted).into_owned(); let char_count = pasted.chars().count(); if char_count > LARGE_PASTE_CHAR_THRESHOLD { let placeholder = self.next_large_paste_placeholder(char_count); @@ -7572,6 +7574,18 @@ mod tests { } } + #[test] + fn handle_paste_strips_terminal_control_sequences_snapshot() { + snapshot_composer_state( + "paste_terminal_control_sequence_sanitized", + /*enhanced_keys_supported*/ false, + |composer| { + composer.handle_paste("_count_r\x1b[13;2:3uows".to_string()); + assert_eq!(composer.draft.textarea.text(), "_count_rows"); + }, + ); + } + #[test] fn empty_enter_returns_none() { use crossterm::event::KeyCode; diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__paste_terminal_control_sequence_sanitized.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__paste_terminal_control_sequence_sanitized.snap new file mode 100644 index 000000000000..f2369cbe13bd --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__paste_terminal_control_sequence_sanitized.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› _count_rows " +" " +" " +" " +" " +" " +" " +" 100% context left " diff --git a/codex-rs/tui/src/insert_history.rs b/codex-rs/tui/src/insert_history.rs index 760b5164216f..4eb3011b4570 100644 --- a/codex-rs/tui/src/insert_history.rs +++ b/codex-rs/tui/src/insert_history.rs @@ -284,6 +284,9 @@ fn write_history_line( line: &HyperlinkLine, wrap_width: usize, ) -> io::Result<()> { + let mut line = line.clone(); + line.sanitize(); + let line = &line; let physical_rows = line.width().max(1).div_ceil(wrap_width) as u16; if physical_rows > 1 { queue!(writer, SavePosition)?; @@ -512,6 +515,23 @@ mod tests { ); } + #[test] + fn write_history_line_strips_untrusted_terminal_sequences() { + let sequence = "\x1b[13;2:3u"; + let line = HyperlinkLine { + line: Line::from(format!("_count_r{sequence}ows")), + hyperlinks: Vec::new(), + }; + let mut actual = Vec::new(); + + write_history_line(&mut actual, &line, /*wrap_width*/ 80) + .expect("write sanitized history line"); + + let output = String::from_utf8(actual).expect("UTF-8 terminal output"); + assert!(!output.contains(sequence)); + assert!(output.contains("_count_rows")); + } + #[test] fn writes_semantic_web_link_without_changing_visible_text() { let destination = "https://example.com/long/path"; diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index dc8262f7db30..e6109c54e56a 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -174,6 +174,7 @@ mod style; mod terminal_hyperlinks; mod terminal_palette; mod terminal_probe; +mod terminal_text; mod terminal_title; mod terminal_visualization_instructions; mod text_formatting; diff --git a/codex-rs/tui/src/terminal_hyperlinks.rs b/codex-rs/tui/src/terminal_hyperlinks.rs index 21d6b5d2e489..750ed00200b4 100644 --- a/codex-rs/tui/src/terminal_hyperlinks.rs +++ b/codex-rs/tui/src/terminal_hyperlinks.rs @@ -20,6 +20,7 @@ use unicode_width::UnicodeWidthStr; use url::Url; use crate::render::line_utils::line_to_static; +use crate::terminal_text::sanitize_untrusted_text; use crate::wrapping::RtOptions; use crate::wrapping::adaptive_wrap_line; @@ -37,17 +38,20 @@ pub(crate) struct HyperlinkLine { impl HyperlinkLine { pub(crate) fn new(line: Line<'static>) -> Self { - Self { + let mut line = Self { line, hyperlinks: Vec::new(), - } + }; + line.sanitize(); + line } pub(crate) fn width(&self) -> usize { self.line.width() } - pub(crate) fn push_span(&mut self, span: Span<'static>, destination: Option<&str>) { + pub(crate) fn push_span(&mut self, mut span: Span<'static>, destination: Option<&str>) { + sanitize_span(&mut span); let start = self.width(); let end = start + span.content.width(); self.line.push_span(span); @@ -61,12 +65,32 @@ impl HyperlinkLine { } } + pub(crate) fn sanitize(&mut self) { + let mut changed = false; + for span in &mut self.line.spans { + changed |= sanitize_span(span); + } + if changed { + self.hyperlinks.clear(); + } + } + pub(crate) fn style(mut self, style: ratatui::style::Style) -> Self { self.line = self.line.style(style); self } } +fn sanitize_span(span: &mut Span<'static>) -> bool { + let sanitized = sanitize_untrusted_text(span.content.as_ref()); + if sanitized.as_ref() == span.content.as_ref() { + return false; + } + + span.content = sanitized.into_owned().into(); + true +} + impl From> for HyperlinkLine { fn from(line: Line<'static>) -> Self { Self::new(line) diff --git a/codex-rs/tui/src/terminal_text.rs b/codex-rs/tui/src/terminal_text.rs new file mode 100644 index 000000000000..ac594e5fe8b6 --- /dev/null +++ b/codex-rs/tui/src/terminal_text.rs @@ -0,0 +1,38 @@ +//! Sanitizes untrusted text before it is persisted or written to a terminal. + +use std::borrow::Cow; + +/// Remove terminal escape sequences and non-whitespace control characters. +/// +/// Newlines and tabs remain intact so callers can safely use this for pasted source text. Terminal +/// rendering expands tabs separately and represents newlines as distinct lines. +pub(crate) fn sanitize_untrusted_text(text: &str) -> Cow<'_, str> { + let needs_sanitizing = text.contains('\x1b') + || text + .chars() + .any(|ch| ch.is_control() && !matches!(ch, '\n' | '\t')); + if !needs_sanitizing { + return Cow::Borrowed(text); + } + + let mut sanitized = String::with_capacity(text.len()); + for (index, line) in text.split('\n').enumerate() { + if index > 0 { + sanitized.push('\n'); + } + for parsed_line in codex_ansi_escape::ansi_escape(line).lines { + for span in parsed_line.spans { + sanitized.extend( + span.content + .chars() + .filter(|ch| *ch == '\t' || !ch.is_control()), + ); + } + } + } + Cow::Owned(sanitized) +} + +#[cfg(test)] +#[path = "terminal_text_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/terminal_text_tests.rs b/codex-rs/tui/src/terminal_text_tests.rs new file mode 100644 index 000000000000..6b2869d56306 --- /dev/null +++ b/codex-rs/tui/src/terminal_text_tests.rs @@ -0,0 +1,18 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn removes_reported_keyboard_escape_sequence() { + assert_eq!( + sanitize_untrusted_text("_count_r\x1b[13;2:3uows"), + "_count_rows" + ); +} + +#[test] +fn preserves_paste_whitespace_and_removes_other_controls() { + assert_eq!( + sanitize_untrusted_text("one\tindent\n\0two\u{7f}\n"), + "one\tindent\ntwo\n" + ); +} From 1a08407bf58e301732a6a6d38890176921b4a42c Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 7 Jul 2026 16:05:01 -0700 Subject: [PATCH 2/8] codex: address PR review feedback (#31494) --- codex-rs/tui/src/terminal_hyperlinks.rs | 39 +++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/codex-rs/tui/src/terminal_hyperlinks.rs b/codex-rs/tui/src/terminal_hyperlinks.rs index 750ed00200b4..1c0e24178173 100644 --- a/codex-rs/tui/src/terminal_hyperlinks.rs +++ b/codex-rs/tui/src/terminal_hyperlinks.rs @@ -110,7 +110,13 @@ impl From for HyperlinkLine { } pub(crate) fn visible_lines(lines: Vec) -> Vec> { - lines.into_iter().map(|line| line.line).collect() + lines + .into_iter() + .map(|mut line| { + line.sanitize(); + line.line + }) + .collect() } pub(crate) fn plain_hyperlink_lines(lines: Vec>) -> Vec { @@ -173,12 +179,13 @@ pub(crate) fn annotate_web_urls(lines: Vec>) -> Vec } pub(crate) fn annotate_web_urls_in_line(line: Line<'static>) -> HyperlinkLine { - let text = line + let mut out = HyperlinkLine::new(line); + let text = out + .line .spans .iter() .map(|span| span.content.as_ref()) .collect::(); - let mut out = HyperlinkLine::new(line); out.hyperlinks = web_links_in_text(&text); out } @@ -567,6 +574,32 @@ mod tests { ); } + #[test] + fn discovers_web_url_columns_after_sanitizing_text() { + let destination = "https://example.com"; + assert_eq!( + annotate_web_urls_in_line(Line::from(format!("\x1b[13;2:3u{destination}"))), + HyperlinkLine { + line: Line::from(destination), + hyperlinks: vec![TerminalHyperlink { + columns: 0..destination.width(), + destination: destination.to_string(), + }], + } + ); + } + + #[test] + fn visible_lines_sanitize_spans_appended_directly() { + let mut line = HyperlinkLine::new(Line::from("before")); + line.line.push_span("\x1b[13;2:3uafter"); + + assert_eq!( + visible_lines(vec![line]), + vec![Line::from(vec![Span::from("before"), Span::from("after")])] + ); + } + #[test] fn preserves_balanced_parentheses_in_bare_web_urls() { let destination = "https://en.wikipedia.org/wiki/Function_(mathematics)"; From 32f6fd5a1c6f3842b7608d19fcd230690b132432 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 7 Jul 2026 16:20:55 -0700 Subject: [PATCH 3/8] codex: address PR review feedback (#31494) --- codex-rs/tui/src/history_cell/base.rs | 23 +++++++++++++++ codex-rs/tui/src/history_cell/mcp.rs | 34 +++++++++++----------- codex-rs/tui/src/history_cell/tests.rs | 14 +++++++++ codex-rs/tui/src/insert_history.rs | 39 ++++++++++++++++++++++++++ 4 files changed, 92 insertions(+), 18 deletions(-) diff --git a/codex-rs/tui/src/history_cell/base.rs b/codex-rs/tui/src/history_cell/base.rs index e9ade8494530..a76c2da93cb5 100644 --- a/codex-rs/tui/src/history_cell/base.rs +++ b/codex-rs/tui/src/history_cell/base.rs @@ -23,6 +23,29 @@ impl HistoryCell for PlainHistoryCell { } } +#[derive(Debug)] +pub(crate) struct HyperlinkHistoryCell { + pub(super) lines: Vec, +} + +impl HistoryCell for HyperlinkHistoryCell { + fn display_lines(&self, _width: u16) -> Vec> { + visible_lines(self.lines.clone()) + } + + fn display_hyperlink_lines(&self, _width: u16) -> Vec { + self.lines.clone() + } + + fn transcript_hyperlink_lines(&self, width: u16) -> Vec { + self.display_hyperlink_lines(width) + } + + fn raw_lines(&self) -> Vec> { + plain_lines(visible_lines(self.lines.clone())) + } +} + #[derive(Debug)] pub(crate) struct WebHyperlinkHistoryCell { lines: Vec>, diff --git a/codex-rs/tui/src/history_cell/mcp.rs b/codex-rs/tui/src/history_cell/mcp.rs index b71af3d251a2..660beba7b481 100644 --- a/codex-rs/tui/src/history_cell/mcp.rs +++ b/codex-rs/tui/src/history_cell/mcp.rs @@ -316,26 +316,24 @@ fn decode_mcp_image(block: &serde_json::Value) -> Option { .ok() } /// Render a summary of configured MCP servers from the current `Config`. -pub(crate) fn empty_mcp_output() -> PlainHistoryCell { - let lines: Vec> = vec![ - "/mcp".magenta().into(), - "".into(), - vec!["🔌 ".into(), "MCP Tools".bold()].into(), - "".into(), - " • No MCP servers configured.".italic().into(), - Line::from(vec![ - " See the ".into(), - crate::terminal_hyperlinks::osc8_hyperlink( - "https://developers.openai.com/codex/mcp", - "MCP docs", - ) - .underlined(), - " to configure them.".into(), - ]) - .style(Style::default().add_modifier(Modifier::DIM)), +pub(crate) fn empty_mcp_output() -> HyperlinkHistoryCell { + let mut docs_line = HyperlinkLine::new(Line::default()); + docs_line.push_span(" See the ".into(), /*destination*/ None); + docs_line.push_span( + "MCP docs".underlined(), + /*destination*/ Some("https://developers.openai.com/codex/mcp"), + ); + docs_line.push_span(" to configure them.".into(), /*destination*/ None); + let lines = vec![ + HyperlinkLine::new("/mcp".magenta().into()), + HyperlinkLine::from(""), + HyperlinkLine::new(vec!["🔌 ".into(), "MCP Tools".bold()].into()), + HyperlinkLine::from(""), + HyperlinkLine::new(Line::from(" • No MCP servers configured.".italic())), + docs_line.style(Style::default().add_modifier(Modifier::DIM)), ]; - PlainHistoryCell::new(lines) + HyperlinkHistoryCell { lines } } #[cfg(test)] diff --git a/codex-rs/tui/src/history_cell/tests.rs b/codex-rs/tui/src/history_cell/tests.rs index 42dadb8535bc..fe8b8493a0cb 100644 --- a/codex-rs/tui/src/history_cell/tests.rs +++ b/codex-rs/tui/src/history_cell/tests.rs @@ -356,6 +356,20 @@ fn composite_cell_preserves_child_web_links() { ); } +#[test] +fn empty_mcp_output_preserves_docs_link() { + let cell = empty_mcp_output(); + let lines = cell.display_hyperlink_lines(/*width*/ 80); + + assert_eq!( + lines.last().expect("MCP docs line").hyperlinks, + vec![crate::terminal_hyperlinks::TerminalHyperlink { + columns: 12..20, + destination: "https://developers.openai.com/codex/mcp".to_string(), + }] + ); +} + #[test] fn proposed_plan_cell_unwraps_markdown_fenced_table() { let plan = new_proposed_plan( diff --git a/codex-rs/tui/src/insert_history.rs b/codex-rs/tui/src/insert_history.rs index 4eb3011b4570..6b587613b23e 100644 --- a/codex-rs/tui/src/insert_history.rs +++ b/codex-rs/tui/src/insert_history.rs @@ -110,6 +110,10 @@ pub(crate) fn insert_history_hyperlink_lines_with_mode_and_wrap_policy( where B: Backend + Write, { + let mut lines = lines; + for line in &mut lines { + line.sanitize(); + } let screen_size = terminal.backend().size().unwrap_or(Size::new(0, 0)); let mut area = terminal.viewport_area; @@ -532,6 +536,41 @@ mod tests { assert!(output.contains("_count_rows")); } + #[test] + fn sanitizes_history_before_wrapping() { + let width = 12; + let height = 6; + let backend = VT100Backend::new(width, height); + let mut terminal = + crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); + terminal.set_viewport_area(Rect::new( + /*x*/ 0, + /*y*/ height - 1, + /*width*/ width, + /*height*/ 1, + )); + let mut line = HyperlinkLine::new(Line::from("_count_r")); + line.line + .push_span(format!("\x1b[{}uows", "13;2:3;".repeat(width.into()))); + + insert_history_hyperlink_lines_with_mode_and_wrap_policy( + &mut terminal, + vec![line], + InsertHistoryMode::Standard, + HistoryLineWrapPolicy::PreWrap, + ) + .expect("insert sanitized history"); + + let rows = terminal + .backend() + .vt100() + .screen() + .rows(0, width) + .collect::>(); + assert!(rows.iter().any(|row| row.contains("_count_rows"))); + assert!(!rows.iter().any(|row| row.contains("13;2:3"))); + } + #[test] fn writes_semantic_web_link_without_changing_visible_text() { let destination = "https://example.com/long/path"; From 9c252bea5878cd01850439c9ed06c10adcf31064 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 7 Jul 2026 16:37:27 -0700 Subject: [PATCH 4/8] codex: narrow terminal control sanitization (#31494) --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 20 ++---- ...e_terminal_control_sequence_sanitized.snap | 13 ---- codex-rs/tui/src/history_cell/base.rs | 23 ------- codex-rs/tui/src/history_cell/mcp.rs | 34 ++++----- codex-rs/tui/src/history_cell/messages.rs | 17 +++-- ...wraps_and_prefixes_each_line_snapshot.snap | 9 +-- codex-rs/tui/src/history_cell/tests.rs | 16 +---- codex-rs/tui/src/insert_history.rs | 59 ---------------- codex-rs/tui/src/terminal_hyperlinks.rs | 69 ++----------------- codex-rs/tui/src/terminal_text.rs | 22 +++--- codex-rs/tui/src/terminal_text_tests.rs | 18 ----- 11 files changed, 55 insertions(+), 245 deletions(-) delete mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__paste_terminal_control_sequence_sanitized.snap delete mode 100644 codex-rs/tui/src/terminal_text_tests.rs diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 800ffb59584f..1c05c88ec9a6 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -7561,31 +7561,21 @@ mod tests { /*disable_paste_burst*/ false, ); - let needs_redraw = composer.handle_paste("hello".to_string()); + let sanitized = "_count_rows\tindent\ntwo"; + let needs_redraw = + composer.handle_paste("_count_r\x1b[13;2:3uows\tindent\n\0two\u{7f}".to_string()); assert!(needs_redraw); - assert_eq!(composer.draft.textarea.text(), "hello"); + assert_eq!(composer.draft.textarea.text(), sanitized); assert!(composer.draft.pending_pastes.is_empty()); let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { - InputResult::Submitted { text, .. } => assert_eq!(text, "hello"), + InputResult::Submitted { text, .. } => assert_eq!(text, sanitized), _ => panic!("expected Submitted"), } } - #[test] - fn handle_paste_strips_terminal_control_sequences_snapshot() { - snapshot_composer_state( - "paste_terminal_control_sequence_sanitized", - /*enhanced_keys_supported*/ false, - |composer| { - composer.handle_paste("_count_r\x1b[13;2:3uows".to_string()); - assert_eq!(composer.draft.textarea.text(), "_count_rows"); - }, - ); - } - #[test] fn empty_enter_returns_none() { use crossterm::event::KeyCode; diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__paste_terminal_control_sequence_sanitized.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__paste_terminal_control_sequence_sanitized.snap deleted file mode 100644 index f2369cbe13bd..000000000000 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__paste_terminal_control_sequence_sanitized.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› _count_rows " -" " -" " -" " -" " -" " -" " -" 100% context left " diff --git a/codex-rs/tui/src/history_cell/base.rs b/codex-rs/tui/src/history_cell/base.rs index a76c2da93cb5..e9ade8494530 100644 --- a/codex-rs/tui/src/history_cell/base.rs +++ b/codex-rs/tui/src/history_cell/base.rs @@ -23,29 +23,6 @@ impl HistoryCell for PlainHistoryCell { } } -#[derive(Debug)] -pub(crate) struct HyperlinkHistoryCell { - pub(super) lines: Vec, -} - -impl HistoryCell for HyperlinkHistoryCell { - fn display_lines(&self, _width: u16) -> Vec> { - visible_lines(self.lines.clone()) - } - - fn display_hyperlink_lines(&self, _width: u16) -> Vec { - self.lines.clone() - } - - fn transcript_hyperlink_lines(&self, width: u16) -> Vec { - self.display_hyperlink_lines(width) - } - - fn raw_lines(&self) -> Vec> { - plain_lines(visible_lines(self.lines.clone())) - } -} - #[derive(Debug)] pub(crate) struct WebHyperlinkHistoryCell { lines: Vec>, diff --git a/codex-rs/tui/src/history_cell/mcp.rs b/codex-rs/tui/src/history_cell/mcp.rs index 660beba7b481..b71af3d251a2 100644 --- a/codex-rs/tui/src/history_cell/mcp.rs +++ b/codex-rs/tui/src/history_cell/mcp.rs @@ -316,24 +316,26 @@ fn decode_mcp_image(block: &serde_json::Value) -> Option { .ok() } /// Render a summary of configured MCP servers from the current `Config`. -pub(crate) fn empty_mcp_output() -> HyperlinkHistoryCell { - let mut docs_line = HyperlinkLine::new(Line::default()); - docs_line.push_span(" See the ".into(), /*destination*/ None); - docs_line.push_span( - "MCP docs".underlined(), - /*destination*/ Some("https://developers.openai.com/codex/mcp"), - ); - docs_line.push_span(" to configure them.".into(), /*destination*/ None); - let lines = vec![ - HyperlinkLine::new("/mcp".magenta().into()), - HyperlinkLine::from(""), - HyperlinkLine::new(vec!["🔌 ".into(), "MCP Tools".bold()].into()), - HyperlinkLine::from(""), - HyperlinkLine::new(Line::from(" • No MCP servers configured.".italic())), - docs_line.style(Style::default().add_modifier(Modifier::DIM)), +pub(crate) fn empty_mcp_output() -> PlainHistoryCell { + let lines: Vec> = vec![ + "/mcp".magenta().into(), + "".into(), + vec!["🔌 ".into(), "MCP Tools".bold()].into(), + "".into(), + " • No MCP servers configured.".italic().into(), + Line::from(vec![ + " See the ".into(), + crate::terminal_hyperlinks::osc8_hyperlink( + "https://developers.openai.com/codex/mcp", + "MCP docs", + ) + .underlined(), + " to configure them.".into(), + ]) + .style(Style::default().add_modifier(Modifier::DIM)), ]; - HyperlinkHistoryCell { lines } + PlainHistoryCell::new(lines) } #[cfg(test)] diff --git a/codex-rs/tui/src/history_cell/messages.rs b/codex-rs/tui/src/history_cell/messages.rs index 787f999cf1ee..10ae83482949 100644 --- a/codex-rs/tui/src/history_cell/messages.rs +++ b/codex-rs/tui/src/history_cell/messages.rs @@ -1,6 +1,7 @@ //! User, assistant, reasoning, and streaming message history cells. use super::*; +use crate::terminal_text::sanitize_untrusted_text; #[derive(Debug)] pub(crate) struct UserHistoryCell { @@ -93,6 +94,12 @@ fn trim_trailing_blank_lines(mut lines: Vec>) -> Vec impl HistoryCell for UserHistoryCell { fn display_lines(&self, width: u16) -> Vec> { + let message = sanitize_untrusted_text(&self.message); + let text_elements = if message.as_ref() == self.message.as_str() { + self.text_elements.as_slice() + } else { + &[] + }; let wrap_width = width .saturating_sub( LIVE_PREFIX_COLS + 1, /* keep a one-column right margin for wrapping */ @@ -117,10 +124,10 @@ impl HistoryCell for UserHistoryCell { )) }; - let wrapped_message = if self.message.is_empty() && self.text_elements.is_empty() { + let wrapped_message = if message.is_empty() && text_elements.is_empty() { None - } else if self.text_elements.is_empty() { - let message_without_trailing_newlines = self.message.trim_end_matches(['\r', '\n']); + } else if text_elements.is_empty() { + let message_without_trailing_newlines = message.trim_end_matches(['\r', '\n']); let wrapped = adaptive_wrap_lines( message_without_trailing_newlines .split('\n') @@ -133,8 +140,8 @@ impl HistoryCell for UserHistoryCell { (!wrapped.is_empty()).then_some(wrapped) } else { let raw_lines = build_user_message_lines_with_elements( - &self.message, - &self.text_elements, + message.as_ref(), + text_elements, style, element_style, ); diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__user_history_cell_wraps_and_prefixes_each_line_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__user_history_cell_wraps_and_prefixes_each_line_snapshot.snap index 51ce9e312fa2..beaad7984de2 100644 --- a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__user_history_cell_wraps_and_prefixes_each_line_snapshot.snap +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__user_history_cell_wraps_and_prefixes_each_line_snapshot.snap @@ -1,9 +1,6 @@ --- -source: tui/src/history_cell.rs +source: tui/src/history_cell/tests.rs expression: rendered --- - -› one two - three - four five - six seven +› _count_ro + ws diff --git a/codex-rs/tui/src/history_cell/tests.rs b/codex-rs/tui/src/history_cell/tests.rs index fe8b8493a0cb..fbfbf5ce20bd 100644 --- a/codex-rs/tui/src/history_cell/tests.rs +++ b/codex-rs/tui/src/history_cell/tests.rs @@ -356,20 +356,6 @@ fn composite_cell_preserves_child_web_links() { ); } -#[test] -fn empty_mcp_output_preserves_docs_link() { - let cell = empty_mcp_output(); - let lines = cell.display_hyperlink_lines(/*width*/ 80); - - assert_eq!( - lines.last().expect("MCP docs line").hyperlinks, - vec![crate::terminal_hyperlinks::TerminalHyperlink { - columns: 12..20, - destination: "https://developers.openai.com/codex/mcp".to_string(), - }] - ); -} - #[test] fn proposed_plan_cell_unwraps_markdown_fenced_table() { let plan = new_proposed_plan( @@ -1983,7 +1969,7 @@ fn ran_cell_multiline_with_stderr_snapshot() { } #[test] fn user_history_cell_wraps_and_prefixes_each_line_snapshot() { - let msg = "one two three four five six seven"; + let msg = "_count_r\x1b[13;2:3uows"; let cell = UserHistoryCell { message: msg.to_string(), text_elements: Vec::new(), diff --git a/codex-rs/tui/src/insert_history.rs b/codex-rs/tui/src/insert_history.rs index 6b587613b23e..760b5164216f 100644 --- a/codex-rs/tui/src/insert_history.rs +++ b/codex-rs/tui/src/insert_history.rs @@ -110,10 +110,6 @@ pub(crate) fn insert_history_hyperlink_lines_with_mode_and_wrap_policy( where B: Backend + Write, { - let mut lines = lines; - for line in &mut lines { - line.sanitize(); - } let screen_size = terminal.backend().size().unwrap_or(Size::new(0, 0)); let mut area = terminal.viewport_area; @@ -288,9 +284,6 @@ fn write_history_line( line: &HyperlinkLine, wrap_width: usize, ) -> io::Result<()> { - let mut line = line.clone(); - line.sanitize(); - let line = &line; let physical_rows = line.width().max(1).div_ceil(wrap_width) as u16; if physical_rows > 1 { queue!(writer, SavePosition)?; @@ -519,58 +512,6 @@ mod tests { ); } - #[test] - fn write_history_line_strips_untrusted_terminal_sequences() { - let sequence = "\x1b[13;2:3u"; - let line = HyperlinkLine { - line: Line::from(format!("_count_r{sequence}ows")), - hyperlinks: Vec::new(), - }; - let mut actual = Vec::new(); - - write_history_line(&mut actual, &line, /*wrap_width*/ 80) - .expect("write sanitized history line"); - - let output = String::from_utf8(actual).expect("UTF-8 terminal output"); - assert!(!output.contains(sequence)); - assert!(output.contains("_count_rows")); - } - - #[test] - fn sanitizes_history_before_wrapping() { - let width = 12; - let height = 6; - let backend = VT100Backend::new(width, height); - let mut terminal = - crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); - terminal.set_viewport_area(Rect::new( - /*x*/ 0, - /*y*/ height - 1, - /*width*/ width, - /*height*/ 1, - )); - let mut line = HyperlinkLine::new(Line::from("_count_r")); - line.line - .push_span(format!("\x1b[{}uows", "13;2:3;".repeat(width.into()))); - - insert_history_hyperlink_lines_with_mode_and_wrap_policy( - &mut terminal, - vec![line], - InsertHistoryMode::Standard, - HistoryLineWrapPolicy::PreWrap, - ) - .expect("insert sanitized history"); - - let rows = terminal - .backend() - .vt100() - .screen() - .rows(0, width) - .collect::>(); - assert!(rows.iter().any(|row| row.contains("_count_rows"))); - assert!(!rows.iter().any(|row| row.contains("13;2:3"))); - } - #[test] fn writes_semantic_web_link_without_changing_visible_text() { let destination = "https://example.com/long/path"; diff --git a/codex-rs/tui/src/terminal_hyperlinks.rs b/codex-rs/tui/src/terminal_hyperlinks.rs index 1c0e24178173..21d6b5d2e489 100644 --- a/codex-rs/tui/src/terminal_hyperlinks.rs +++ b/codex-rs/tui/src/terminal_hyperlinks.rs @@ -20,7 +20,6 @@ use unicode_width::UnicodeWidthStr; use url::Url; use crate::render::line_utils::line_to_static; -use crate::terminal_text::sanitize_untrusted_text; use crate::wrapping::RtOptions; use crate::wrapping::adaptive_wrap_line; @@ -38,20 +37,17 @@ pub(crate) struct HyperlinkLine { impl HyperlinkLine { pub(crate) fn new(line: Line<'static>) -> Self { - let mut line = Self { + Self { line, hyperlinks: Vec::new(), - }; - line.sanitize(); - line + } } pub(crate) fn width(&self) -> usize { self.line.width() } - pub(crate) fn push_span(&mut self, mut span: Span<'static>, destination: Option<&str>) { - sanitize_span(&mut span); + pub(crate) fn push_span(&mut self, span: Span<'static>, destination: Option<&str>) { let start = self.width(); let end = start + span.content.width(); self.line.push_span(span); @@ -65,32 +61,12 @@ impl HyperlinkLine { } } - pub(crate) fn sanitize(&mut self) { - let mut changed = false; - for span in &mut self.line.spans { - changed |= sanitize_span(span); - } - if changed { - self.hyperlinks.clear(); - } - } - pub(crate) fn style(mut self, style: ratatui::style::Style) -> Self { self.line = self.line.style(style); self } } -fn sanitize_span(span: &mut Span<'static>) -> bool { - let sanitized = sanitize_untrusted_text(span.content.as_ref()); - if sanitized.as_ref() == span.content.as_ref() { - return false; - } - - span.content = sanitized.into_owned().into(); - true -} - impl From> for HyperlinkLine { fn from(line: Line<'static>) -> Self { Self::new(line) @@ -110,13 +86,7 @@ impl From for HyperlinkLine { } pub(crate) fn visible_lines(lines: Vec) -> Vec> { - lines - .into_iter() - .map(|mut line| { - line.sanitize(); - line.line - }) - .collect() + lines.into_iter().map(|line| line.line).collect() } pub(crate) fn plain_hyperlink_lines(lines: Vec>) -> Vec { @@ -179,13 +149,12 @@ pub(crate) fn annotate_web_urls(lines: Vec>) -> Vec } pub(crate) fn annotate_web_urls_in_line(line: Line<'static>) -> HyperlinkLine { - let mut out = HyperlinkLine::new(line); - let text = out - .line + let text = line .spans .iter() .map(|span| span.content.as_ref()) .collect::(); + let mut out = HyperlinkLine::new(line); out.hyperlinks = web_links_in_text(&text); out } @@ -574,32 +543,6 @@ mod tests { ); } - #[test] - fn discovers_web_url_columns_after_sanitizing_text() { - let destination = "https://example.com"; - assert_eq!( - annotate_web_urls_in_line(Line::from(format!("\x1b[13;2:3u{destination}"))), - HyperlinkLine { - line: Line::from(destination), - hyperlinks: vec![TerminalHyperlink { - columns: 0..destination.width(), - destination: destination.to_string(), - }], - } - ); - } - - #[test] - fn visible_lines_sanitize_spans_appended_directly() { - let mut line = HyperlinkLine::new(Line::from("before")); - line.line.push_span("\x1b[13;2:3uafter"); - - assert_eq!( - visible_lines(vec![line]), - vec![Line::from(vec![Span::from("before"), Span::from("after")])] - ); - } - #[test] fn preserves_balanced_parentheses_in_bare_web_urls() { let destination = "https://en.wikipedia.org/wiki/Function_(mathematics)"; diff --git a/codex-rs/tui/src/terminal_text.rs b/codex-rs/tui/src/terminal_text.rs index ac594e5fe8b6..84bd78b9c68e 100644 --- a/codex-rs/tui/src/terminal_text.rs +++ b/codex-rs/tui/src/terminal_text.rs @@ -20,19 +20,17 @@ pub(crate) fn sanitize_untrusted_text(text: &str) -> Cow<'_, str> { if index > 0 { sanitized.push('\n'); } - for parsed_line in codex_ansi_escape::ansi_escape(line).lines { - for span in parsed_line.spans { - sanitized.extend( - span.content - .chars() - .filter(|ch| *ch == '\t' || !ch.is_control()), - ); - } + for span in codex_ansi_escape::ansi_escape(line) + .lines + .into_iter() + .flat_map(|line| line.spans) + { + sanitized.extend( + span.content + .chars() + .filter(|ch| *ch == '\t' || !ch.is_control()), + ); } } Cow::Owned(sanitized) } - -#[cfg(test)] -#[path = "terminal_text_tests.rs"] -mod tests; diff --git a/codex-rs/tui/src/terminal_text_tests.rs b/codex-rs/tui/src/terminal_text_tests.rs deleted file mode 100644 index 6b2869d56306..000000000000 --- a/codex-rs/tui/src/terminal_text_tests.rs +++ /dev/null @@ -1,18 +0,0 @@ -use super::*; -use pretty_assertions::assert_eq; - -#[test] -fn removes_reported_keyboard_escape_sequence() { - assert_eq!( - sanitize_untrusted_text("_count_r\x1b[13;2:3uows"), - "_count_rows" - ); -} - -#[test] -fn preserves_paste_whitespace_and_removes_other_controls() { - assert_eq!( - sanitize_untrusted_text("one\tindent\n\0two\u{7f}\n"), - "one\tindent\ntwo\n" - ); -} From cacebe620b32ba3df441d20d2669ce12c393956b Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 7 Jul 2026 16:44:27 -0700 Subject: [PATCH 5/8] codex: handle CSI and OSC terminators (#31494) --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 8 +++-- codex-rs/tui/src/terminal_text.rs | 33 +++++++++++-------- 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 1c05c88ec9a6..bec3ee68d57d 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -7561,9 +7561,11 @@ mod tests { /*disable_paste_burst*/ false, ); - let sanitized = "_count_rows\tindent\ntwo"; - let needs_redraw = - composer.handle_paste("_count_r\x1b[13;2:3uows\tindent\n\0two\u{7f}".to_string()); + let sanitized = "_count_rows\tindent\ntworeadylabeldone"; + let needs_redraw = composer.handle_paste( + "_count_r\x1b[13;2:3uows\tindent\n\0two\u{7f}\x1b[200~ready\x1b]8;;https://example.com\x1b\\label\x1b]8;;\x1b\\done" + .to_string(), + ); assert!(needs_redraw); assert_eq!(composer.draft.textarea.text(), sanitized); assert!(composer.draft.pending_pastes.is_empty()); diff --git a/codex-rs/tui/src/terminal_text.rs b/codex-rs/tui/src/terminal_text.rs index 84bd78b9c68e..7eab149d5574 100644 --- a/codex-rs/tui/src/terminal_text.rs +++ b/codex-rs/tui/src/terminal_text.rs @@ -16,20 +16,25 @@ pub(crate) fn sanitize_untrusted_text(text: &str) -> Cow<'_, str> { } let mut sanitized = String::with_capacity(text.len()); - for (index, line) in text.split('\n').enumerate() { - if index > 0 { - sanitized.push('\n'); - } - for span in codex_ansi_escape::ansi_escape(line) - .lines - .into_iter() - .flat_map(|line| line.spans) - { - sanitized.extend( - span.content - .chars() - .filter(|ch| *ch == '\t' || !ch.is_control()), - ); + let mut chars = text.chars().peekable(); + while let Some(ch) = chars.next() { + match ch { + '\x1b' if chars.next_if_eq(&'[').is_some() => { + let _ = chars.find(|ch| ('@'..='~').contains(ch)); + } + '\x1b' if chars.next_if_eq(&']').is_some() => { + while let Some(ch) = chars.next() { + if matches!(ch, '\x07' | '\u{9c}') + || ch == '\x1b' && chars.next_if_eq(&'\\').is_some() + { + break; + } + } + } + '\x1b' => {} + '\n' | '\t' => sanitized.push(ch), + _ if !ch.is_control() => sanitized.push(ch), + _ => {} } } Cow::Owned(sanitized) From dd4638542a29d4731a267342b7c88dd9d43e5bec Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 7 Jul 2026 16:54:40 -0700 Subject: [PATCH 6/8] codex: sanitize stored user history (#31494) --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 4 +-- codex-rs/tui/src/history_cell/messages.rs | 24 ++++++++-------- codex-rs/tui/src/history_cell/tests.rs | 9 ++---- codex-rs/tui/src/terminal_text.rs | 28 +++++++++++-------- codex-rs/tui/src/thread_transcript.rs | 14 +++++----- 5 files changed, 40 insertions(+), 39 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index bec3ee68d57d..da3fb31b0225 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -7561,9 +7561,9 @@ mod tests { /*disable_paste_burst*/ false, ); - let sanitized = "_count_rows\tindent\ntworeadylabeldone"; + let sanitized = "_count_rows\tindent\ntworeadylabeldonetailsafewide"; let needs_redraw = composer.handle_paste( - "_count_r\x1b[13;2:3uows\tindent\n\0two\u{7f}\x1b[200~ready\x1b]8;;https://example.com\x1b\\label\x1b]8;;\x1b\\done" + "_count_r\x1b[13;2:3uows\tindent\n\0two\u{7f}\x1b[200~ready\x1b]8;;https://example.com\x1b\\label\x1b]8;;\x1b\\done\x1bPqpayload\x1b\\tail\x1bcsafe\x1b(Bwide" .to_string(), ); assert!(needs_redraw); diff --git a/codex-rs/tui/src/history_cell/messages.rs b/codex-rs/tui/src/history_cell/messages.rs index 10ae83482949..dd9b158b1090 100644 --- a/codex-rs/tui/src/history_cell/messages.rs +++ b/codex-rs/tui/src/history_cell/messages.rs @@ -94,12 +94,6 @@ fn trim_trailing_blank_lines(mut lines: Vec>) -> Vec impl HistoryCell for UserHistoryCell { fn display_lines(&self, width: u16) -> Vec> { - let message = sanitize_untrusted_text(&self.message); - let text_elements = if message.as_ref() == self.message.as_str() { - self.text_elements.as_slice() - } else { - &[] - }; let wrap_width = width .saturating_sub( LIVE_PREFIX_COLS + 1, /* keep a one-column right margin for wrapping */ @@ -124,10 +118,10 @@ impl HistoryCell for UserHistoryCell { )) }; - let wrapped_message = if message.is_empty() && text_elements.is_empty() { + let wrapped_message = if self.message.is_empty() && self.text_elements.is_empty() { None - } else if text_elements.is_empty() { - let message_without_trailing_newlines = message.trim_end_matches(['\r', '\n']); + } else if self.text_elements.is_empty() { + let message_without_trailing_newlines = self.message.trim_end_matches(['\r', '\n']); let wrapped = adaptive_wrap_lines( message_without_trailing_newlines .split('\n') @@ -140,8 +134,8 @@ impl HistoryCell for UserHistoryCell { (!wrapped.is_empty()).then_some(wrapped) } else { let raw_lines = build_user_message_lines_with_elements( - message.as_ref(), - text_elements, + &self.message, + &self.text_elements, style, element_style, ); @@ -467,12 +461,16 @@ impl HistoryCell for StreamingAgentTailCell { } pub(crate) fn new_user_prompt( message: String, - text_elements: Vec, + mut text_elements: Vec, local_image_paths: Vec, remote_image_urls: Vec, ) -> UserHistoryCell { + let sanitized = sanitize_untrusted_text(&message); + if sanitized.as_ref() != message.as_str() { + text_elements.clear(); + } UserHistoryCell { - message, + message: sanitized.into_owned(), text_elements, local_image_paths, remote_image_urls, diff --git a/codex-rs/tui/src/history_cell/tests.rs b/codex-rs/tui/src/history_cell/tests.rs index fbfbf5ce20bd..ff6e0f9a7134 100644 --- a/codex-rs/tui/src/history_cell/tests.rs +++ b/codex-rs/tui/src/history_cell/tests.rs @@ -1970,18 +1970,15 @@ fn ran_cell_multiline_with_stderr_snapshot() { #[test] fn user_history_cell_wraps_and_prefixes_each_line_snapshot() { let msg = "_count_r\x1b[13;2:3uows"; - let cell = UserHistoryCell { - message: msg.to_string(), - text_elements: Vec::new(), - local_image_paths: Vec::new(), - remote_image_urls: Vec::new(), - }; + let cell = new_user_prompt(msg.to_string(), Vec::new(), Vec::new(), Vec::new()); // Small width to force wrapping more clearly. Effective wrap width is width-2 due to the ▌ prefix and trailing space. let width: u16 = 12; let lines = cell.display_lines(width); let rendered = render_lines(&lines).join("\n"); + assert_eq!(cell.message, "_count_rows"); + assert_eq!(render_lines(&cell.raw_lines()), ["_count_rows"]); insta::assert_snapshot!(rendered); } diff --git a/codex-rs/tui/src/terminal_text.rs b/codex-rs/tui/src/terminal_text.rs index 7eab149d5574..c032c72c7dd2 100644 --- a/codex-rs/tui/src/terminal_text.rs +++ b/codex-rs/tui/src/terminal_text.rs @@ -19,19 +19,25 @@ pub(crate) fn sanitize_untrusted_text(text: &str) -> Cow<'_, str> { let mut chars = text.chars().peekable(); while let Some(ch) = chars.next() { match ch { - '\x1b' if chars.next_if_eq(&'[').is_some() => { - let _ = chars.find(|ch| ('@'..='~').contains(ch)); - } - '\x1b' if chars.next_if_eq(&']').is_some() => { - while let Some(ch) = chars.next() { - if matches!(ch, '\x07' | '\u{9c}') - || ch == '\x1b' && chars.next_if_eq(&'\\').is_some() - { - break; + '\x1b' => match chars.next() { + Some('[') => { + let _ = chars.find(|ch| ('@'..='~').contains(ch)); + } + Some(introducer @ (']' | 'P' | 'X' | '^' | '_')) => { + while let Some(ch) = chars.next() { + if ch == '\u{9c}' + || introducer == ']' && ch == '\x07' + || ch == '\x1b' && chars.next_if_eq(&'\\').is_some() + { + break; + } } } - } - '\x1b' => {} + Some(' '..='/') => { + let _ = chars.find(|ch| ('0'..='~').contains(ch)); + } + Some(_) | None => {} + }, '\n' | '\t' => sanitized.push(ch), _ if !ch.is_control() => sanitized.push(ch), _ => {} diff --git a/codex-rs/tui/src/thread_transcript.rs b/codex-rs/tui/src/thread_transcript.rs index cdffaab27c0b..aaed5255aa00 100644 --- a/codex-rs/tui/src/thread_transcript.rs +++ b/codex-rs/tui/src/thread_transcript.rs @@ -8,7 +8,7 @@ use crate::history_cell::AgentMarkdownCell; use crate::history_cell::HistoryCell; use crate::history_cell::PlainHistoryCell; use crate::history_cell::ReasoningSummaryCell; -use crate::history_cell::UserHistoryCell; +use crate::history_cell::new_user_prompt; use crate::multi_agents::sub_agent_activity_summary; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadItem; @@ -62,12 +62,12 @@ pub(crate) fn thread_to_transcript_cells( .map(codex_app_server_protocol::UserInput::into_core) .collect(), }; - cells.push(Arc::new(UserHistoryCell { - message: item.message(), - text_elements: item.text_elements(), - local_image_paths: item.local_image_paths(), - remote_image_urls: item.image_urls(), - })); + cells.push(Arc::new(new_user_prompt( + item.message(), + item.text_elements(), + item.local_image_paths(), + item.image_urls(), + ))); } ThreadItem::AgentMessage { text, .. } => { let parsed = parse_assistant_markdown(text, cwd); From cc6f7fa7af969f60ed4889787ba83c6de6c28b5e Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 7 Jul 2026 17:12:18 -0700 Subject: [PATCH 7/8] codex: narrow user text sanitization (#31494) --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 12 ++--- codex-rs/tui/src/history_cell/messages.rs | 39 ++++++++++----- codex-rs/tui/src/history_cell/tests.rs | 9 ++-- codex-rs/tui/src/lib.rs | 1 - codex-rs/tui/src/terminal_text.rs | 47 ------------------- codex-rs/tui/src/thread_transcript.rs | 14 +++--- 6 files changed, 45 insertions(+), 77 deletions(-) delete mode 100644 codex-rs/tui/src/terminal_text.rs diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index da3fb31b0225..b07e83945328 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -211,6 +211,7 @@ use super::slash_commands::BuiltinCommandFlags; use super::slash_commands::ServiceTierCommand; use super::slash_commands::SlashCommandItem; use crate::bottom_pane::paste_burst::FlushResult; +use crate::history_cell::sanitize_user_text; use crate::key_hint::KeyBindingListExt; use crate::keymap::EditorKeymap; use crate::keymap::RuntimeKeymap; @@ -222,7 +223,6 @@ use crate::render::RectExt; use crate::render::renderable::Renderable; use crate::slash_command::SlashCommand; use crate::style::user_message_style; -use crate::terminal_text::sanitize_untrusted_text; use codex_protocol::ThreadId; use codex_protocol::user_input::ByteRange; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; @@ -888,7 +888,7 @@ impl ChatComposer { /// the next user Enter key, then syncs popup state. pub fn handle_paste(&mut self, pasted: String) -> bool { let pasted = pasted.replace("\r\n", "\n").replace('\r', "\n"); - let pasted = sanitize_untrusted_text(&pasted).into_owned(); + let pasted = sanitize_user_text(&pasted); let char_count = pasted.chars().count(); if char_count > LARGE_PASTE_CHAR_THRESHOLD { let placeholder = self.next_large_paste_placeholder(char_count); @@ -7561,11 +7561,9 @@ mod tests { /*disable_paste_burst*/ false, ); - let sanitized = "_count_rows\tindent\ntworeadylabeldonetailsafewide"; - let needs_redraw = composer.handle_paste( - "_count_r\x1b[13;2:3uows\tindent\n\0two\u{7f}\x1b[200~ready\x1b]8;;https://example.com\x1b\\label\x1b]8;;\x1b\\done\x1bPqpayload\x1b\\tail\x1bcsafe\x1b(Bwide" - .to_string(), - ); + let sanitized = "_count_rows\tindent\ntwo"; + let needs_redraw = + composer.handle_paste("_count_r\x1b[13;2:3uows\tindent\n\0two\u{7f}".to_string()); assert!(needs_redraw); assert_eq!(composer.draft.textarea.text(), sanitized); assert!(composer.draft.pending_pastes.is_empty()); diff --git a/codex-rs/tui/src/history_cell/messages.rs b/codex-rs/tui/src/history_cell/messages.rs index dd9b158b1090..898b91fc4838 100644 --- a/codex-rs/tui/src/history_cell/messages.rs +++ b/codex-rs/tui/src/history_cell/messages.rs @@ -1,7 +1,6 @@ //! User, assistant, reasoning, and streaming message history cells. use super::*; -use crate::terminal_text::sanitize_untrusted_text; #[derive(Debug)] pub(crate) struct UserHistoryCell { @@ -12,6 +11,20 @@ pub(crate) struct UserHistoryCell { pub remote_image_urls: Vec, } +/// Remove CSI sequences and control characters, preserving tabs and newlines. +pub(crate) fn sanitize_user_text(text: &str) -> String { + let mut sanitized = String::with_capacity(text.len()); + let mut chars = text.chars().peekable(); + while let Some(ch) = chars.next() { + if ch == '\x1b' && chars.next_if_eq(&'[').is_some() { + let _ = chars.find(|ch| ('@'..='~').contains(ch)); + } else if matches!(ch, '\n' | '\t') || !ch.is_control() { + sanitized.push(ch); + } + } + sanitized +} + /// Build logical lines for a user message with styled text elements. /// /// This preserves explicit newlines while interleaving element spans and skips @@ -94,6 +107,12 @@ fn trim_trailing_blank_lines(mut lines: Vec>) -> Vec impl HistoryCell for UserHistoryCell { fn display_lines(&self, width: u16) -> Vec> { + let message = sanitize_user_text(&self.message); + let text_elements = if message == self.message { + self.text_elements.as_slice() + } else { + &[] + }; let wrap_width = width .saturating_sub( LIVE_PREFIX_COLS + 1, /* keep a one-column right margin for wrapping */ @@ -118,10 +137,10 @@ impl HistoryCell for UserHistoryCell { )) }; - let wrapped_message = if self.message.is_empty() && self.text_elements.is_empty() { + let wrapped_message = if message.is_empty() && text_elements.is_empty() { None - } else if self.text_elements.is_empty() { - let message_without_trailing_newlines = self.message.trim_end_matches(['\r', '\n']); + } else if text_elements.is_empty() { + let message_without_trailing_newlines = message.trim_end_matches(['\r', '\n']); let wrapped = adaptive_wrap_lines( message_without_trailing_newlines .split('\n') @@ -134,8 +153,8 @@ impl HistoryCell for UserHistoryCell { (!wrapped.is_empty()).then_some(wrapped) } else { let raw_lines = build_user_message_lines_with_elements( - &self.message, - &self.text_elements, + &message, + text_elements, style, element_style, ); @@ -461,16 +480,12 @@ impl HistoryCell for StreamingAgentTailCell { } pub(crate) fn new_user_prompt( message: String, - mut text_elements: Vec, + text_elements: Vec, local_image_paths: Vec, remote_image_urls: Vec, ) -> UserHistoryCell { - let sanitized = sanitize_untrusted_text(&message); - if sanitized.as_ref() != message.as_str() { - text_elements.clear(); - } UserHistoryCell { - message: sanitized.into_owned(), + message, text_elements, local_image_paths, remote_image_urls, diff --git a/codex-rs/tui/src/history_cell/tests.rs b/codex-rs/tui/src/history_cell/tests.rs index ff6e0f9a7134..fbfbf5ce20bd 100644 --- a/codex-rs/tui/src/history_cell/tests.rs +++ b/codex-rs/tui/src/history_cell/tests.rs @@ -1970,15 +1970,18 @@ fn ran_cell_multiline_with_stderr_snapshot() { #[test] fn user_history_cell_wraps_and_prefixes_each_line_snapshot() { let msg = "_count_r\x1b[13;2:3uows"; - let cell = new_user_prompt(msg.to_string(), Vec::new(), Vec::new(), Vec::new()); + let cell = UserHistoryCell { + message: msg.to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), + }; // Small width to force wrapping more clearly. Effective wrap width is width-2 due to the ▌ prefix and trailing space. let width: u16 = 12; let lines = cell.display_lines(width); let rendered = render_lines(&lines).join("\n"); - assert_eq!(cell.message, "_count_rows"); - assert_eq!(render_lines(&cell.raw_lines()), ["_count_rows"]); insta::assert_snapshot!(rendered); } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index e6109c54e56a..dc8262f7db30 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -174,7 +174,6 @@ mod style; mod terminal_hyperlinks; mod terminal_palette; mod terminal_probe; -mod terminal_text; mod terminal_title; mod terminal_visualization_instructions; mod text_formatting; diff --git a/codex-rs/tui/src/terminal_text.rs b/codex-rs/tui/src/terminal_text.rs deleted file mode 100644 index c032c72c7dd2..000000000000 --- a/codex-rs/tui/src/terminal_text.rs +++ /dev/null @@ -1,47 +0,0 @@ -//! Sanitizes untrusted text before it is persisted or written to a terminal. - -use std::borrow::Cow; - -/// Remove terminal escape sequences and non-whitespace control characters. -/// -/// Newlines and tabs remain intact so callers can safely use this for pasted source text. Terminal -/// rendering expands tabs separately and represents newlines as distinct lines. -pub(crate) fn sanitize_untrusted_text(text: &str) -> Cow<'_, str> { - let needs_sanitizing = text.contains('\x1b') - || text - .chars() - .any(|ch| ch.is_control() && !matches!(ch, '\n' | '\t')); - if !needs_sanitizing { - return Cow::Borrowed(text); - } - - let mut sanitized = String::with_capacity(text.len()); - let mut chars = text.chars().peekable(); - while let Some(ch) = chars.next() { - match ch { - '\x1b' => match chars.next() { - Some('[') => { - let _ = chars.find(|ch| ('@'..='~').contains(ch)); - } - Some(introducer @ (']' | 'P' | 'X' | '^' | '_')) => { - while let Some(ch) = chars.next() { - if ch == '\u{9c}' - || introducer == ']' && ch == '\x07' - || ch == '\x1b' && chars.next_if_eq(&'\\').is_some() - { - break; - } - } - } - Some(' '..='/') => { - let _ = chars.find(|ch| ('0'..='~').contains(ch)); - } - Some(_) | None => {} - }, - '\n' | '\t' => sanitized.push(ch), - _ if !ch.is_control() => sanitized.push(ch), - _ => {} - } - } - Cow::Owned(sanitized) -} diff --git a/codex-rs/tui/src/thread_transcript.rs b/codex-rs/tui/src/thread_transcript.rs index aaed5255aa00..cdffaab27c0b 100644 --- a/codex-rs/tui/src/thread_transcript.rs +++ b/codex-rs/tui/src/thread_transcript.rs @@ -8,7 +8,7 @@ use crate::history_cell::AgentMarkdownCell; use crate::history_cell::HistoryCell; use crate::history_cell::PlainHistoryCell; use crate::history_cell::ReasoningSummaryCell; -use crate::history_cell::new_user_prompt; +use crate::history_cell::UserHistoryCell; use crate::multi_agents::sub_agent_activity_summary; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadItem; @@ -62,12 +62,12 @@ pub(crate) fn thread_to_transcript_cells( .map(codex_app_server_protocol::UserInput::into_core) .collect(), }; - cells.push(Arc::new(new_user_prompt( - item.message(), - item.text_elements(), - item.local_image_paths(), - item.image_urls(), - ))); + cells.push(Arc::new(UserHistoryCell { + message: item.message(), + text_elements: item.text_elements(), + local_image_paths: item.local_image_paths(), + remote_image_urls: item.image_urls(), + })); } ThreadItem::AgentMessage { text, .. } => { let parsed = parse_assistant_markdown(text, cwd); From fb46ef3c99e9d4bc9cc75950c82c6b3678220ba9 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 8 Jul 2026 08:36:43 -0700 Subject: [PATCH 8/8] codex: sanitize raw user history rendering (#31494) --- codex-rs/tui/src/history_cell/messages.rs | 3 ++- codex-rs/tui/src/history_cell/tests.rs | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/codex-rs/tui/src/history_cell/messages.rs b/codex-rs/tui/src/history_cell/messages.rs index 898b91fc4838..350ae81db172 100644 --- a/codex-rs/tui/src/history_cell/messages.rs +++ b/codex-rs/tui/src/history_cell/messages.rs @@ -197,7 +197,8 @@ impl HistoryCell for UserHistoryCell { } fn raw_lines(&self) -> Vec> { - let mut lines = raw_lines_from_source(self.message.trim_end_matches(['\r', '\n'])); + let message = sanitize_user_text(&self.message); + let mut lines = raw_lines_from_source(message.trim_end_matches(['\r', '\n'])); if !self.remote_image_urls.is_empty() { if !lines.is_empty() { lines.push(Line::from("")); diff --git a/codex-rs/tui/src/history_cell/tests.rs b/codex-rs/tui/src/history_cell/tests.rs index fbfbf5ce20bd..315d84b3d98a 100644 --- a/codex-rs/tui/src/history_cell/tests.rs +++ b/codex-rs/tui/src/history_cell/tests.rs @@ -1982,6 +1982,7 @@ fn user_history_cell_wraps_and_prefixes_each_line_snapshot() { let lines = cell.display_lines(width); let rendered = render_lines(&lines).join("\n"); + assert_eq!(render_lines(&cell.raw_lines()), ["_count_rows"]); insta::assert_snapshot!(rendered); }