Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/output-hygiene.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@googleworkspace/cli": patch
---

Consolidate terminal sanitization, coloring, and output helpers into a new `output.rs` module. Fixes raw ANSI escape codes in `watch.rs` that bypassed `NO_COLOR` and TTY detection, upgrades `sanitize_for_terminal` to also strip dangerous Unicode characters (bidi overrides, zero-width spaces, directional isolates), and sanitizes previously raw API error body and user query outputs.
2 changes: 1 addition & 1 deletion src/credential_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

use std::path::PathBuf;

use crate::error::sanitize_for_terminal;
use crate::output::sanitize_for_terminal;

use aes_gcm::aead::{Aead, KeyInit, OsRng};
use aes_gcm::{AeadCore, Aes256Gcm, Nonce};
Expand Down
31 changes: 1 addition & 30 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,36 +148,7 @@ impl GwsError {
}
}

/// Returns true when stderr is connected to an interactive terminal,
/// meaning ANSI color codes will be visible to the user.
fn stderr_supports_color() -> bool {
use std::io::IsTerminal;
std::io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none()
}

/// Wrap `text` in ANSI bold + the given color code, resetting afterwards.
/// Returns the plain text unchanged when stderr is not a TTY.
fn colorize(text: &str, ansi_color: &str) -> String {
if stderr_supports_color() {
format!("\x1b[1;{ansi_color}m{text}\x1b[0m")
} else {
text.to_string()
}
}

/// Strip terminal control characters from `text` to prevent escape-sequence
/// injection when printing untrusted content (API responses, user input) to
/// stderr. Preserves newlines and tabs for readability.
pub(crate) fn sanitize_for_terminal(text: &str) -> String {
text.chars()
.filter(|&c| {
if c == '\n' || c == '\t' {
return true;
}
!c.is_control()
})
.collect()
}
use crate::output::{colorize, sanitize_for_terminal};

/// Format a colored error label for the given error variant.
fn error_label(err: &GwsError) -> String {
Expand Down
3 changes: 2 additions & 1 deletion src/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ use serde_json::{json, Map, Value};
use tokio::io::AsyncWriteExt;

use crate::discovery::{RestDescription, RestMethod};
use crate::error::{sanitize_for_terminal, GwsError};
use crate::error::GwsError;
use crate::output::sanitize_for_terminal;

/// Tracks what authentication method was used for the request.
#[derive(Debug, Clone, PartialEq)]
Expand Down
3 changes: 2 additions & 1 deletion src/generate_skills.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@

use crate::commands;
use crate::discovery;
use crate::error::{sanitize_for_terminal, GwsError};
use crate::error::GwsError;
use crate::output::sanitize_for_terminal;
use crate::services;
use clap::Command;
use std::path::Path;
Expand Down
2 changes: 1 addition & 1 deletion src/helpers/events/subscribe.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use super::*;
use crate::auth::AccessTokenProvider;
use crate::error::sanitize_for_terminal;
use crate::helpers::PUBSUB_API_BASE;
use crate::output::sanitize_for_terminal;
use std::path::PathBuf;

#[derive(Debug, Clone, Default, Builder)]
Expand Down
2 changes: 1 addition & 1 deletion src/helpers/gmail/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ use triage::handle_triage;
use watch::handle_watch;

pub(super) use crate::auth;
use crate::error::sanitize_for_terminal;
pub(super) use crate::error::GwsError;
pub(super) use crate::executor;
use crate::output::sanitize_for_terminal;
pub(super) use anyhow::Context;
pub(super) use base64::{engine::general_purpose::URL_SAFE, Engine as _};
pub(super) use clap::{Arg, ArgAction, ArgMatches, Command};
Expand Down
12 changes: 5 additions & 7 deletions src/helpers/gmail/read.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ pub(super) async fn handle_read(
continue;
}
// Replace newlines to prevent header spoofing in the output, then sanitize.
let sanitized_value = sanitize_terminal_output(&value.replace(['\r', '\n'], " "));
let sanitized_value = sanitize_for_terminal(&value.replace(['\r', '\n'], " "));
writeln!(stdout, "{}: {}", name, sanitized_value)
.with_context(|| format!("Failed to write '{name}' header"))?;
}
Expand All @@ -87,8 +87,7 @@ pub(super) async fn handle_read(
&original.body_text
};

writeln!(stdout, "{}", sanitize_terminal_output(body))
.context("Failed to write message body")?;
writeln!(stdout, "{}", sanitize_for_terminal(body)).context("Failed to write message body")?;

Ok(())
}
Expand All @@ -102,17 +101,16 @@ fn format_mailbox_list(mailboxes: &[Mailbox]) -> String {
.join(", ")
}

/// Re-export the crate-wide terminal sanitizer for use in this module.
use crate::error::sanitize_for_terminal as sanitize_terminal_output;
use crate::output::sanitize_for_terminal;

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_sanitize_terminal_output() {
fn test_sanitize_for_terminal() {
let malicious = "Subject: \x1b]0;MALICIOUS\x07Hello\nWorld\r\t";
let sanitized = sanitize_terminal_output(malicious);
let sanitized = sanitize_for_terminal(malicious);
// ANSI escape sequences (control chars) should be removed
assert!(!sanitized.contains('\x1b'));
assert!(!sanitized.contains('\x07'));
Expand Down
5 changes: 4 additions & 1 deletion src/helpers/gmail/triage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,10 @@ pub async fn handle_triage(matches: &ArgMatches) -> Result<(), GwsError> {
/// Returns the human-readable "no messages" diagnostic string.
/// Extracted so the test can reference the exact same message without duplication.
fn no_messages_msg(query: &str) -> String {
format!("No messages found matching query: {query}")
format!(
"No messages found matching query: {}",
crate::output::sanitize_for_terminal(query)
)
}

#[cfg(test)]
Expand Down
19 changes: 14 additions & 5 deletions src/helpers/gmail/watch.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
use super::*;
use crate::auth::AccessTokenProvider;
use crate::error::sanitize_for_terminal;
use crate::helpers::PUBSUB_API_BASE;
use crate::output::colorize;
use crate::output::sanitize_for_terminal;

const GMAIL_API_BASE: &str = "https://gmail.googleapis.com/gmail/v1";

Expand Down Expand Up @@ -100,7 +101,7 @@ pub(super) async fn handle_watch(
" --member=serviceAccount:gmail-api-push@system.gserviceaccount.com \\"
);
eprintln!(" --role=roles/pubsub.publisher");
eprintln!("Error: {body}");
eprintln!("Error: {}", sanitize_for_terminal(&body));
}

t
Expand Down Expand Up @@ -454,7 +455,11 @@ async fn fetch_and_output_messages(
}
}
Err(e) => {
eprintln!("\x1b[33m[WARNING]\x1b[0m Model Armor sanitization failed for message {msg_id}: {}", sanitize_for_terminal(&e.to_string()));
eprintln!(
"{} Model Armor sanitization failed for message {msg_id}: {}",
colorize("warning:", "33"),
sanitize_for_terminal(&e.to_string())
);
}
}
}
Expand Down Expand Up @@ -498,12 +503,16 @@ fn apply_sanitization_result(
match sanitize_config.mode {
crate::helpers::modelarmor::SanitizeMode::Block => {
eprintln!(
"\x1b[31m[BLOCKED]\x1b[0m Message {msg_id} blocked by Model Armor (match found)"
"{} Message {msg_id} blocked by Model Armor (match found)",
colorize("blocked:", "31")
);
return None;
}
crate::helpers::modelarmor::SanitizeMode::Warn => {
eprintln!("\x1b[33m[WARNING]\x1b[0m Model Armor match found in message {msg_id}");
eprintln!(
"{} Model Armor match found in message {msg_id}",
colorize("warning:", "33")
);
full_msg["_sanitization"] = serde_json::json!({
"filterMatchState": result.filter_match_state,
"filterResults": result.filter_results,
Expand Down
3 changes: 2 additions & 1 deletion src/helpers/workflows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@

use super::Helper;
use crate::auth;
use crate::error::{sanitize_for_terminal, GwsError};
use crate::error::GwsError;
use crate::output::sanitize_for_terminal;
use clap::{Arg, ArgMatches, Command};
use serde_json::{json, Value};
use std::future::Future;
Expand Down
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ mod generate_skills;
mod helpers;
mod logging;
mod oauth_config;
mod output;
mod schema;
mod services;
mod setup;
Expand Down
Loading
Loading