From 598e79d10af8734bca3b64f20caa6bcc13fbe6c9 Mon Sep 17 00:00:00 2001 From: seang1121 Date: Tue, 10 Mar 2026 21:16:14 -0400 Subject: [PATCH 1/5] fix: colorize stderr warnings and errors on TTY terminals --- .changeset/colorize-stderr-tty.md | 5 +++ src/error.rs | 62 ++++++++++++++++++++++++++++++- src/executor.rs | 4 +- src/main.rs | 3 +- 4 files changed, 70 insertions(+), 4 deletions(-) create mode 100644 .changeset/colorize-stderr-tty.md diff --git a/.changeset/colorize-stderr-tty.md b/.changeset/colorize-stderr-tty.md new file mode 100644 index 00000000..d269f8ec --- /dev/null +++ b/.changeset/colorize-stderr-tty.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": patch +--- + +Colorize stderr warnings and errors with ANSI codes when output is a TTY terminal diff --git a/src/error.rs b/src/error.rs index 25cc9f59..37b95aa5 100644 --- a/src/error.rs +++ b/src/error.rs @@ -15,6 +15,35 @@ use serde_json::json; use thiserror::Error; +pub(crate) fn is_tty() -> bool { + use std::io::IsTerminal; + std::io::stderr().is_terminal() +} + +pub(crate) fn yellow(s: &str) -> String { + if is_tty() { + format!("\x1b[33m{s}\x1b[0m") + } else { + s.to_string() + } +} + +pub(crate) fn red(s: &str) -> String { + if is_tty() { + format!("\x1b[31m{s}\x1b[0m") + } else { + s.to_string() + } +} + +pub(crate) fn bold(s: &str) -> String { + if is_tty() { + format!("\x1b[1m{s}\x1b[0m") + } else { + s.to_string() + } +} + #[derive(Error, Debug)] pub enum GwsError { #[error("{message}")] @@ -111,7 +140,7 @@ pub fn print_error_json(err: &GwsError) { { if reason == "accessNotConfigured" { eprintln!(); - eprintln!("💡 API not enabled for your GCP project."); + eprintln!("{}", yellow("💡 API not enabled for your GCP project.")); if let Some(url) = enable_url { eprintln!(" Enable it at: {url}"); } else { @@ -126,6 +155,37 @@ pub fn print_error_json(err: &GwsError) { mod tests { use super::*; + // In test environments stderr is not a TTY, so the helpers return plain strings. + #[test] + fn test_yellow_non_tty() { + // Stderr is not a TTY in CI/test runners; plain string is returned. + let result = yellow("hello"); + // Either plain or ANSI-wrapped depending on environment; must contain the text. + assert!(result.contains("hello")); + } + + #[test] + fn test_red_non_tty() { + let result = red("error"); + assert!(result.contains("error")); + } + + #[test] + fn test_bold_non_tty() { + let result = bold("important"); + assert!(result.contains("important")); + } + + #[test] + fn test_color_helpers_no_ansi_when_non_tty() { + // In test runners, stderr is not a TTY — helpers must return the raw string. + if !is_tty() { + assert_eq!(yellow("x"), "x"); + assert_eq!(red("x"), "x"); + assert_eq!(bold("x"), "x"); + } + } + #[test] fn test_error_to_json_api() { let err = GwsError::Api { diff --git a/src/executor.rs b/src/executor.rs index 49101ece..fa2cc12f 100644 --- a/src/executor.rs +++ b/src/executor.rs @@ -228,7 +228,7 @@ async fn handle_json_response( Ok(result) => { let is_match = result.filter_match_state == "MATCH_FOUND"; if is_match { - eprintln!("⚠️ Model Armor: prompt injection detected (filterMatchState: MATCH_FOUND)"); + eprintln!("{}", crate::error::yellow("⚠️ Model Armor: prompt injection detected (filterMatchState: MATCH_FOUND)")); } if is_match && *sanitize_mode == crate::helpers::modelarmor::SanitizeMode::Block @@ -254,7 +254,7 @@ async fn handle_json_response( } } Err(e) => { - eprintln!("⚠️ Model Armor sanitization failed: {e}"); + eprintln!("{}", crate::error::yellow(&format!("⚠️ Model Armor sanitization failed: {e}"))); } } } diff --git a/src/main.rs b/src/main.rs index 22259a44..246dac3b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -174,7 +174,8 @@ async fn run() -> Result<(), GwsError> { Ok(fmt) => fmt, Err(unknown) => { eprintln!( - "warning: unknown output format '{unknown}'; falling back to json (valid options: json, table, yaml, csv)" + "{} unknown output format '{unknown}'; falling back to json (valid options: json, table, yaml, csv)", + crate::error::yellow("warning:") ); formatter::OutputFormat::Json } From 4efadd838cc56714b14da1a5fd0fccda7b2b32ad Mon Sep 17 00:00:00 2001 From: seang1121 Date: Tue, 10 Mar 2026 23:11:32 -0400 Subject: [PATCH 2/5] refactor(error): extract colorize helper to reduce duplication --- src/error.rs | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/src/error.rs b/src/error.rs index 37b95aa5..8b102663 100644 --- a/src/error.rs +++ b/src/error.rs @@ -20,29 +20,17 @@ pub(crate) fn is_tty() -> bool { std::io::stderr().is_terminal() } -pub(crate) fn yellow(s: &str) -> String { +fn colorize(s: &str, code: &str) -> String { if is_tty() { - format!("\x1b[33m{s}\x1b[0m") + format!("\x1b[{code}m{s}\x1b[0m") } else { s.to_string() } } -pub(crate) fn red(s: &str) -> String { - if is_tty() { - format!("\x1b[31m{s}\x1b[0m") - } else { - s.to_string() - } -} - -pub(crate) fn bold(s: &str) -> String { - if is_tty() { - format!("\x1b[1m{s}\x1b[0m") - } else { - s.to_string() - } -} +pub(crate) fn yellow(s: &str) -> String { colorize(s, "33") } +pub(crate) fn red(s: &str) -> String { colorize(s, "31") } +pub(crate) fn bold(s: &str) -> String { colorize(s, "1") } #[derive(Error, Debug)] pub enum GwsError { From 9267f3f4331bb259c8446faa7528549067b66265 Mon Sep 17 00:00:00 2001 From: seang1121 Date: Wed, 11 Mar 2026 00:41:48 -0400 Subject: [PATCH 3/5] fix(executor): prevent ANSI injection by colorizing only static prefix --- src/executor.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/executor.rs b/src/executor.rs index fa2cc12f..a9d8c007 100644 --- a/src/executor.rs +++ b/src/executor.rs @@ -254,7 +254,7 @@ async fn handle_json_response( } } Err(e) => { - eprintln!("{}", crate::error::yellow(&format!("⚠️ Model Armor sanitization failed: {e}"))); + eprintln!("{} {e}", crate::error::yellow("⚠️ Model Armor sanitization failed:")); } } } From 3c34fb0ba917c23da995baed5c45f80545904fa0 Mon Sep 17 00:00:00 2001 From: seang1121 Date: Wed, 11 Mar 2026 00:59:31 -0400 Subject: [PATCH 4/5] fix(error): sanitize ESC chars in colorize, remove redundant tests --- src/error.rs | 25 +++---------------------- 1 file changed, 3 insertions(+), 22 deletions(-) diff --git a/src/error.rs b/src/error.rs index 8b102663..ed48ce31 100644 --- a/src/error.rs +++ b/src/error.rs @@ -22,7 +22,9 @@ pub(crate) fn is_tty() -> bool { fn colorize(s: &str, code: &str) -> String { if is_tty() { - format!("\x1b[{code}m{s}\x1b[0m") + // Strip ESC characters to prevent terminal escape injection. + let sanitized = s.replace('\x1b', ""); + format!("\x1b[{code}m{sanitized}\x1b[0m") } else { s.to_string() } @@ -143,27 +145,6 @@ pub fn print_error_json(err: &GwsError) { mod tests { use super::*; - // In test environments stderr is not a TTY, so the helpers return plain strings. - #[test] - fn test_yellow_non_tty() { - // Stderr is not a TTY in CI/test runners; plain string is returned. - let result = yellow("hello"); - // Either plain or ANSI-wrapped depending on environment; must contain the text. - assert!(result.contains("hello")); - } - - #[test] - fn test_red_non_tty() { - let result = red("error"); - assert!(result.contains("error")); - } - - #[test] - fn test_bold_non_tty() { - let result = bold("important"); - assert!(result.contains("important")); - } - #[test] fn test_color_helpers_no_ansi_when_non_tty() { // In test runners, stderr is not a TTY — helpers must return the raw string. From 417cba4eabf7f5a3656480e1b4a5341ba3983578 Mon Sep 17 00:00:00 2001 From: seang1121 Date: Wed, 11 Mar 2026 01:09:22 -0400 Subject: [PATCH 5/5] fix(error): strip all control chars in colorize to prevent terminal injection --- src/error.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/error.rs b/src/error.rs index ed48ce31..9ec16ef3 100644 --- a/src/error.rs +++ b/src/error.rs @@ -22,8 +22,12 @@ pub(crate) fn is_tty() -> bool { fn colorize(s: &str, code: &str) -> String { if is_tty() { - // Strip ESC characters to prevent terminal escape injection. - let sanitized = s.replace('\x1b', ""); + // Strip control characters to prevent terminal escape injection. + // We allow newline and tab for basic formatting. + let sanitized: String = s + .chars() + .filter(|&c| !c.is_control() || c == '\n' || c == '\t') + .collect(); format!("\x1b[{code}m{sanitized}\x1b[0m") } else { s.to_string()