From 3ec98488bdf0b33cdeac236b7d24895adfecb5bc Mon Sep 17 00:00:00 2001 From: dumko2001 Date: Mon, 16 Mar 2026 14:38:20 +0530 Subject: [PATCH 1/3] feat(gmail): implement robust draft-only mode with centralized policy enforcement --- .changeset/gmail-draft-only-hardening.md | 5 ++ Cargo.lock | 75 ++++++++++++++++++ Cargo.toml | 3 +- src/commands.rs | 9 +++ src/executor.rs | 28 +++++-- src/helpers/calendar.rs | 5 +- src/helpers/chat.rs | 5 +- src/helpers/docs.rs | 5 +- src/helpers/drive.rs | 5 +- src/helpers/events/mod.rs | 2 +- src/helpers/gmail/forward.rs | 2 + src/helpers/gmail/mod.rs | 31 +++++--- src/helpers/gmail/reply.rs | 3 +- src/helpers/gmail/send.rs | 3 +- src/helpers/gmail/triage.rs | 6 +- src/helpers/gmail/watch.rs | 19 ++--- src/helpers/mod.rs | 2 +- src/helpers/modelarmor.rs | 16 ++-- src/helpers/script.rs | 5 +- src/helpers/sheets.rs | 8 +- src/helpers/workflows.rs | 2 +- src/main.rs | 98 ++++++++++++------------ 22 files changed, 227 insertions(+), 110 deletions(-) create mode 100644 .changeset/gmail-draft-only-hardening.md diff --git a/.changeset/gmail-draft-only-hardening.md b/.changeset/gmail-draft-only-hardening.md new file mode 100644 index 00000000..19cd2b2d --- /dev/null +++ b/.changeset/gmail-draft-only-hardening.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": patch +--- + +Implement robust Gmail draft-only mode with centralized policy enforcement in the executor layer to prevent bypasses. diff --git a/Cargo.lock b/Cargo.lock index 0d55ea8d..678cb0ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -117,6 +117,21 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "assert_cmd" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a686bbee5efb88a82df0621b236e74d925f470e5445d3220a5648b892ec99c9" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -191,6 +206,17 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -593,6 +619,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "digest" version = "0.10.7" @@ -890,6 +922,7 @@ version = "0.16.0" dependencies = [ "aes-gcm", "anyhow", + "assert_cmd", "async-trait", "base64", "bytes", @@ -1797,6 +1830,33 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -2605,6 +2665,12 @@ dependencies = [ "libc", ] +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "termwiz" version = "0.23.3" @@ -3068,6 +3134,15 @@ dependencies = [ "utf8parse", ] +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "want" version = "0.3.1" diff --git a/Cargo.toml b/Cargo.toml index 24bc253b..943ed2dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,7 +34,7 @@ path = "src/main.rs" [dependencies] aes-gcm = "0.10" anyhow = "1" -clap = { version = "4", features = ["derive", "string"] } +clap = { version = "4", features = ["derive", "string", "env"] } dirs = "5" dotenvy = "0.15" hostname = "0.4" @@ -80,5 +80,6 @@ inherits = "release" lto = "thin" [dev-dependencies] +assert_cmd = "2" serial_test = "3.4.0" tempfile = "3" diff --git a/src/commands.rs b/src/commands.rs index 27324e42..4d4f3b4d 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -33,6 +33,15 @@ pub fn build_cli(doc: &RestDescription) -> Command { .value_name("TEMPLATE") .global(true), ) + .arg( + clap::Arg::new("draft-only") + .long("draft-only") + .help("Gmail draft-only mode: block sending and strictly allow only draft creation/updates. Also reads GOOGLE_WORKSPACE_GMAIL_DRAFT_ONLY env var.") + .action(clap::ArgAction::SetTrue) + .env("GOOGLE_WORKSPACE_GMAIL_DRAFT_ONLY") + .global(true), + ) + .arg( clap::Arg::new("dry-run") .long("dry-run") diff --git a/src/executor.rs b/src/executor.rs index 73fd772f..c68f8b1e 100644 --- a/src/executor.rs +++ b/src/executor.rs @@ -217,8 +217,7 @@ async fn build_http_request( async fn handle_json_response( body_text: &str, pagination: &PaginationConfig, - sanitize_template: Option<&str>, - sanitize_mode: &crate::helpers::modelarmor::SanitizeMode, + policy: &crate::helpers::modelarmor::ExecutionPolicy, output_format: &crate::formatter::OutputFormat, pages_fetched: &mut u32, page_token: &mut Option, @@ -229,7 +228,7 @@ async fn handle_json_response( *pages_fetched += 1; // Run Model Armor sanitization if --sanitize is enabled - if let Some(template) = sanitize_template { + if let Some(ref template) = policy.template { let text_to_check = serde_json::to_string(&json_val).unwrap_or_default(); match crate::helpers::modelarmor::sanitize_text(template, &text_to_check).await { Ok(result) => { @@ -238,7 +237,7 @@ async fn handle_json_response( eprintln!("⚠️ Model Armor: prompt injection detected (filterMatchState: MATCH_FOUND)"); } - if is_match && *sanitize_mode == crate::helpers::modelarmor::SanitizeMode::Block + if is_match && policy.mode == crate::helpers::modelarmor::SanitizeMode::Block { let blocked = serde_json::json!({ "error": "Content blocked by Model Armor", @@ -377,13 +376,27 @@ pub async fn execute_method( upload_content_type: Option<&str>, dry_run: bool, pagination: &PaginationConfig, - sanitize_template: Option<&str>, - sanitize_mode: &crate::helpers::modelarmor::SanitizeMode, + policy: &crate::helpers::modelarmor::ExecutionPolicy, output_format: &crate::formatter::OutputFormat, capture_output: bool, ) -> Result, GwsError> { let input = parse_and_validate_inputs(doc, method, params_json, body_json, upload_path)?; + // Gmail-specific safety policy: block sending if draft-only mode is active + if policy.draft_only && !dry_run && doc.name == "gmail" { + let is_send = if let Some(ref id) = method.id { + id == "gmail.users.messages.send" || id == "gmail.users.drafts.send" + } else { + // Fallback to Discovery path if ID is missing. + // Standard Gmail send path: users/{userId}/messages/send + method.path.contains("messages/send") || method.path.contains("drafts/send") + }; + + if is_send { + return Err(GwsError::Validation("Gmail draft-only mode is active. Sending mail is blocked (preparing a draft is still allowed).".to_string())); + } + } + if dry_run { let dry_run_info = json!({ "dry_run": true, @@ -470,8 +483,7 @@ pub async fn execute_method( let should_continue = handle_json_response( &body_text, pagination, - sanitize_template, - sanitize_mode, + policy, output_format, &mut pages_fetched, &mut page_token, diff --git a/src/helpers/calendar.rs b/src/helpers/calendar.rs index 8ac00a50..0fd86180 100644 --- a/src/helpers/calendar.rs +++ b/src/helpers/calendar.rs @@ -151,7 +151,7 @@ TIPS: &'a self, doc: &'a crate::discovery::RestDescription, matches: &'a ArgMatches, - _sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig, + _policy: &'a crate::helpers::modelarmor::ExecutionPolicy, ) -> Pin> + Send + 'a>> { Box::pin(async move { if let Some(matches) = matches.subcommand_matches("+insert") { @@ -182,8 +182,7 @@ TIPS: None, matches.get_flag("dry-run"), &executor::PaginationConfig::default(), - None, - &crate::helpers::modelarmor::SanitizeMode::Warn, + _policy, &crate::formatter::OutputFormat::default(), false, ) diff --git a/src/helpers/chat.rs b/src/helpers/chat.rs index 94493e53..e7e65bdc 100644 --- a/src/helpers/chat.rs +++ b/src/helpers/chat.rs @@ -63,7 +63,7 @@ TIPS: &'a self, doc: &'a crate::discovery::RestDescription, matches: &'a ArgMatches, - _sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig, + _policy: &'a crate::helpers::modelarmor::ExecutionPolicy, ) -> Pin> + Send + 'a>> { // We use `Box::pin` to create a pinned future on the heap. // This is necessary because the `Helper` trait returns a generic `Future`, @@ -112,8 +112,7 @@ TIPS: None, matches.get_flag("dry-run"), &pagination, - None, - &crate::helpers::modelarmor::SanitizeMode::Warn, + _policy, &crate::formatter::OutputFormat::default(), false, ) diff --git a/src/helpers/docs.rs b/src/helpers/docs.rs index 3f6b3896..ec786850 100644 --- a/src/helpers/docs.rs +++ b/src/helpers/docs.rs @@ -63,7 +63,7 @@ TIPS: &'a self, doc: &'a crate::discovery::RestDescription, matches: &'a ArgMatches, - _sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig, + _policy: &'a crate::helpers::modelarmor::ExecutionPolicy, ) -> Pin> + Send + 'a>> { Box::pin(async move { if let Some(matches) = matches.subcommand_matches("+write") { @@ -102,8 +102,7 @@ TIPS: None, matches.get_flag("dry-run"), &pagination, - None, - &crate::helpers::modelarmor::SanitizeMode::Warn, + _policy, &crate::formatter::OutputFormat::default(), false, ) diff --git a/src/helpers/drive.rs b/src/helpers/drive.rs index 393e0fde..1d461c7f 100644 --- a/src/helpers/drive.rs +++ b/src/helpers/drive.rs @@ -70,7 +70,7 @@ TIPS: &'a self, doc: &'a crate::discovery::RestDescription, matches: &'a ArgMatches, - _sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig, + _policy: &'a crate::helpers::modelarmor::ExecutionPolicy, ) -> Pin> + Send + 'a>> { Box::pin(async move { if let Some(matches) = matches.subcommand_matches("+upload") { @@ -113,8 +113,7 @@ TIPS: None, matches.get_flag("dry-run"), &executor::PaginationConfig::default(), - None, - &crate::helpers::modelarmor::SanitizeMode::Warn, + _policy, &crate::formatter::OutputFormat::default(), false, ) diff --git a/src/helpers/events/mod.rs b/src/helpers/events/mod.rs index 9d668645..40aa8c74 100644 --- a/src/helpers/events/mod.rs +++ b/src/helpers/events/mod.rs @@ -174,7 +174,7 @@ TIPS: &'a self, doc: &'a crate::discovery::RestDescription, matches: &'a ArgMatches, - _sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig, + _policy: &'a crate::helpers::modelarmor::ExecutionPolicy, ) -> Pin> + Send + 'a>> { Box::pin(async move { if let Some(sub_matches) = matches.subcommand_matches("+subscribe") { diff --git a/src/helpers/gmail/forward.rs b/src/helpers/gmail/forward.rs index e9b76da6..fe35692b 100644 --- a/src/helpers/gmail/forward.rs +++ b/src/helpers/gmail/forward.rs @@ -18,6 +18,7 @@ use super::*; pub(super) async fn handle_forward( doc: &crate::discovery::RestDescription, matches: &ArgMatches, + policy: &crate::helpers::modelarmor::ExecutionPolicy, ) -> Result<(), GwsError> { let config = parse_forward_args(matches); let dry_run = matches.get_flag("dry-run"); @@ -54,6 +55,7 @@ pub(super) async fn handle_forward( &raw, Some(&original.thread_id), token.as_deref(), + policy, ) .await } diff --git a/src/helpers/gmail/mod.rs b/src/helpers/gmail/mod.rs index 999d65ce..d3e935be 100644 --- a/src/helpers/gmail/mod.rs +++ b/src/helpers/gmail/mod.rs @@ -14,7 +14,7 @@ use super::Helper; pub mod forward; -pub mod reply; +mod reply; pub mod send; pub mod triage; pub mod watch; @@ -31,6 +31,7 @@ pub(super) use crate::executor; pub(super) use anyhow::Context; pub(super) use base64::{engine::general_purpose::URL_SAFE, Engine as _}; pub(super) use clap::{Arg, ArgAction, ArgMatches, Command}; +pub(super) pub(super) use serde_json::{json, Value}; use std::future::Future; use std::pin::Pin; @@ -637,7 +638,16 @@ pub(super) async fn send_raw_email( raw_message: &str, thread_id: Option<&str>, existing_token: Option<&str>, + policy: &crate::helpers::modelarmor::ExecutionPolicy, ) -> Result<(), GwsError> { + // Gmail-specific safety policy: block sending if draft-only mode is active + if policy.draft_only && !matches.get_flag("dry-run") { + return Err(GwsError::Validation( + "Gmail send operation blocked by --draft-only policy. Use --dry-run for oversight." + .to_string(), + )); + } + let body = build_raw_send_body(raw_message, thread_id); let body_str = body.to_string(); @@ -675,8 +685,7 @@ pub(super) async fn send_raw_email( None, matches.get_flag("dry-run"), &pagination, - None, - &crate::helpers::modelarmor::SanitizeMode::Warn, + policy, &crate::formatter::OutputFormat::default(), false, ) @@ -1006,6 +1015,7 @@ TIPS: ), ); + cmd = cmd.subcommand( Command::new("+watch") .about("[Helper] Watch for new emails and stream them as NDJSON") @@ -1095,36 +1105,37 @@ TIPS: &'a self, doc: &'a crate::discovery::RestDescription, matches: &'a ArgMatches, - sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig, + policy: &'a crate::helpers::modelarmor::ExecutionPolicy, ) -> Pin> + Send + 'a>> { Box::pin(async move { if let Some(matches) = matches.subcommand_matches("+send") { - handle_send(doc, matches).await?; + handle_send(doc, matches, policy).await?; return Ok(true); } + if let Some(matches) = matches.subcommand_matches("+reply") { - handle_reply(doc, matches, false).await?; + handle_reply(doc, matches, false, policy).await?; return Ok(true); } if let Some(matches) = matches.subcommand_matches("+reply-all") { - handle_reply(doc, matches, true).await?; + handle_reply(doc, matches, true, policy).await?; return Ok(true); } if let Some(matches) = matches.subcommand_matches("+forward") { - handle_forward(doc, matches).await?; + handle_forward(doc, matches, policy).await?; return Ok(true); } if let Some(matches) = matches.subcommand_matches("+triage") { - handle_triage(matches).await?; + handle_triage(doc, matches, policy).await?; return Ok(true); } if let Some(matches) = matches.subcommand_matches("+watch") { - handle_watch(matches, sanitize_config).await?; + handle_watch(doc, matches, policy).await?; return Ok(true); } diff --git a/src/helpers/gmail/reply.rs b/src/helpers/gmail/reply.rs index a9ce8dd1..e9f13c0a 100644 --- a/src/helpers/gmail/reply.rs +++ b/src/helpers/gmail/reply.rs @@ -19,6 +19,7 @@ pub(super) async fn handle_reply( doc: &crate::discovery::RestDescription, matches: &ArgMatches, reply_all: bool, + policy: &crate::helpers::modelarmor::ExecutionPolicy, ) -> Result<(), GwsError> { let config = parse_reply_args(matches)?; let dry_run = matches.get_flag("dry-run"); @@ -100,7 +101,7 @@ pub(super) async fn handle_reply( let raw = create_reply_raw_message(&envelope, &original); let auth_token = token.as_ref().map(|(t, _)| t.as_str()); - super::send_raw_email(doc, matches, &raw, Some(&original.thread_id), auth_token).await + super::send_raw_email(doc, matches, &raw, Some(&original.thread_id), auth_token, policy).await } // --- Data structures --- diff --git a/src/helpers/gmail/send.rs b/src/helpers/gmail/send.rs index 6dcdf68c..0113f2e2 100644 --- a/src/helpers/gmail/send.rs +++ b/src/helpers/gmail/send.rs @@ -3,6 +3,7 @@ use super::*; pub(super) async fn handle_send( doc: &crate::discovery::RestDescription, matches: &ArgMatches, + policy: &crate::helpers::modelarmor::ExecutionPolicy, ) -> Result<(), GwsError> { let config = parse_send_args(matches); @@ -17,7 +18,7 @@ pub(super) async fn handle_send( } .build(&config.body); - super::send_raw_email(doc, matches, &raw, None, None).await + super::send_raw_email(doc, matches, &raw, None, None, policy).await } pub(super) struct SendConfig { diff --git a/src/helpers/gmail/triage.rs b/src/helpers/gmail/triage.rs index ec23bfba..253a1afc 100644 --- a/src/helpers/gmail/triage.rs +++ b/src/helpers/gmail/triage.rs @@ -21,7 +21,11 @@ use super::*; /// Handle the `+triage` subcommand. -pub async fn handle_triage(matches: &ArgMatches) -> Result<(), GwsError> { +pub async fn handle_triage( + _doc: &crate::discovery::RestDescription, + matches: &ArgMatches, + _policy: &crate::helpers::modelarmor::ExecutionPolicy, +) -> Result<(), GwsError> { let max: u32 = matches .get_one::("max") .and_then(|s| s.parse().ok()) diff --git a/src/helpers/gmail/watch.rs b/src/helpers/gmail/watch.rs index 15bc9889..68919be4 100644 --- a/src/helpers/gmail/watch.rs +++ b/src/helpers/gmail/watch.rs @@ -6,8 +6,9 @@ const GMAIL_API_BASE: &str = "https://gmail.googleapis.com/gmail/v1"; /// Handles the `+watch` command — Gmail push notifications via Pub/Sub. pub(super) async fn handle_watch( + _doc: &crate::discovery::RestDescription, matches: &ArgMatches, - sanitize_config: &crate::helpers::modelarmor::SanitizeConfig, + policy: &crate::helpers::modelarmor::ExecutionPolicy, ) -> Result<(), GwsError> { let config = parse_watch_args(matches)?; @@ -205,7 +206,7 @@ pub(super) async fn handle_watch( client: &client, pubsub_token_provider: &pubsub_token_provider, gmail_token_provider: &gmail_token_provider, - sanitize_config, + policy, pubsub_api_base: PUBSUB_API_BASE, gmail_api_base: GMAIL_API_BASE, }; @@ -314,7 +315,7 @@ async fn watch_pull_loop( *last_history_id, &config.format, config.output_dir.as_ref(), - runtime.sanitize_config, + runtime.policy, runtime.gmail_api_base, ) .await?; @@ -399,7 +400,7 @@ async fn fetch_and_output_messages( start_history_id: u64, msg_format: &str, output_dir: Option<&std::path::PathBuf>, - sanitize_config: &crate::helpers::modelarmor::SanitizeConfig, + policy: &crate::helpers::modelarmor::ExecutionPolicy, gmail_api_base: &str, ) -> Result<(), GwsError> { let gmail_token = gmail_token_provider @@ -436,14 +437,14 @@ async fn fetch_and_output_messages( if let Ok(resp) = msg_resp { if let Ok(mut full_msg) = resp.json::().await { // Apply sanitization if configured - if let Some(ref template) = sanitize_config.template { + if let Some(ref template) = policy.template { let text_to_check = serde_json::to_string(&full_msg).unwrap_or_default(); match crate::helpers::modelarmor::sanitize_text(template, &text_to_check).await { Ok(result) => { if let Some(sanitized_msg) = apply_sanitization_result( full_msg, - sanitize_config, + policy, &result, &msg_id, ) { @@ -485,12 +486,12 @@ async fn fetch_and_output_messages( fn apply_sanitization_result( mut full_msg: Value, - sanitize_config: &crate::helpers::modelarmor::SanitizeConfig, + policy: &crate::helpers::modelarmor::ExecutionPolicy, result: &crate::helpers::modelarmor::SanitizationResult, msg_id: &str, ) -> Option { if result.filter_match_state == "MATCH_FOUND" { - match sanitize_config.mode { + match policy.mode { crate::helpers::modelarmor::SanitizeMode::Block => { eprintln!( "\x1b[31m[BLOCKED]\x1b[0m Message {msg_id} blocked by Model Armor (match found)" @@ -551,7 +552,7 @@ struct WatchRuntime<'a> { client: &'a reqwest::Client, pubsub_token_provider: &'a dyn auth::AccessTokenProvider, gmail_token_provider: &'a dyn auth::AccessTokenProvider, - sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig, + policy: &'a crate::helpers::modelarmor::ExecutionPolicy, pubsub_api_base: &'a str, gmail_api_base: &'a str, } diff --git a/src/helpers/mod.rs b/src/helpers/mod.rs index 72d31272..ed75d126 100644 --- a/src/helpers/mod.rs +++ b/src/helpers/mod.rs @@ -45,7 +45,7 @@ pub trait Helper: Send + Sync { &'a self, doc: &'a crate::discovery::RestDescription, matches: &'a ArgMatches, - sanitize_config: &'a modelarmor::SanitizeConfig, + policy: &'a modelarmor::ExecutionPolicy, ) -> Pin> + Send + 'a>>; /// If true, only helper commands are shown (discovery-generated commands are suppressed). diff --git a/src/helpers/modelarmor.rs b/src/helpers/modelarmor.rs index 8ac9fc1c..30f1d096 100644 --- a/src/helpers/modelarmor.rs +++ b/src/helpers/modelarmor.rs @@ -46,21 +46,25 @@ pub enum SanitizeMode { Block, } -/// Configuration for Model Armor sanitization, threaded through the CLI. +/// Configuration for execution policies, including Model Armor sanitization +/// and Gmail draft-only mode. #[derive(Debug, Clone)] -pub struct SanitizeConfig { +pub struct ExecutionPolicy { pub template: Option, pub mode: SanitizeMode, + pub draft_only: bool, } -impl Default for SanitizeConfig { - /// Provides default values for `SanitizeConfig`. +impl Default for ExecutionPolicy { + /// Provides default values for `ExecutionPolicy`. /// - /// By default, no template is set (sanitization disabled) and the mode is `Warn`. + /// By default, no template is set (sanitization disabled), the mode is `Warn`, + /// and `draft_only` is false. fn default() -> Self { Self { template: None, mode: SanitizeMode::Warn, + draft_only: false, } } } @@ -223,7 +227,7 @@ TIPS: &'a self, _doc: &'a RestDescription, matches: &'a ArgMatches, - _sanitize_config: &'a SanitizeConfig, + _policy: &'a ExecutionPolicy, ) -> Pin> + Send + 'a>> { Box::pin(async move { if let Some(sub) = matches.subcommand_matches("+sanitize-prompt") { diff --git a/src/helpers/script.rs b/src/helpers/script.rs index b0ad3497..af09a896 100644 --- a/src/helpers/script.rs +++ b/src/helpers/script.rs @@ -67,7 +67,7 @@ TIPS: &'a self, doc: &'a crate::discovery::RestDescription, matches: &'a ArgMatches, - _sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig, + policy: &'a crate::helpers::modelarmor::ExecutionPolicy, ) -> Pin> + Send + 'a>> { Box::pin(async move { if let Some(matches) = matches.subcommand_matches("+push") { @@ -125,8 +125,7 @@ TIPS: None, matches.get_flag("dry-run"), &executor::PaginationConfig::default(), - None, - &crate::helpers::modelarmor::SanitizeMode::Warn, + policy, &crate::formatter::OutputFormat::default(), false, ) diff --git a/src/helpers/sheets.rs b/src/helpers/sheets.rs index 76f36ab2..a7bb7d77 100644 --- a/src/helpers/sheets.rs +++ b/src/helpers/sheets.rs @@ -98,7 +98,7 @@ TIPS: &'a self, doc: &'a crate::discovery::RestDescription, matches: &'a ArgMatches, - _sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig, + _policy: &'a crate::helpers::modelarmor::ExecutionPolicy, ) -> Pin> + Send + 'a>> { Box::pin(async move { if let Some(matches) = matches.subcommand_matches("+append") { @@ -139,8 +139,7 @@ TIPS: None, matches.get_flag("dry-run"), &pagination, - None, - &crate::helpers::modelarmor::SanitizeMode::Warn, + _policy, &crate::formatter::OutputFormat::default(), false, ) @@ -182,8 +181,7 @@ TIPS: None, matches.get_flag("dry-run"), &executor::PaginationConfig::default(), - None, - &crate::helpers::modelarmor::SanitizeMode::Warn, + _policy, &crate::formatter::OutputFormat::default(), false, ) diff --git a/src/helpers/workflows.rs b/src/helpers/workflows.rs index 48d21784..20b2cd13 100644 --- a/src/helpers/workflows.rs +++ b/src/helpers/workflows.rs @@ -43,7 +43,7 @@ impl Helper for WorkflowHelper { &'a self, _doc: &'a crate::discovery::RestDescription, matches: &'a ArgMatches, - _sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig, + _policy: &'a crate::helpers::modelarmor::ExecutionPolicy, ) -> Pin> + Send + 'a>> { Box::pin(async move { if let Some(m) = matches.subcommand_matches("+standup-report") { diff --git a/src/main.rs b/src/main.rs index bd72c642..99666708 100644 --- a/src/main.rs +++ b/src/main.rs @@ -197,17 +197,45 @@ async fn run() -> Result<(), GwsError> { .map(|v| helpers::modelarmor::SanitizeMode::from_str(&v)) .unwrap_or(helpers::modelarmor::SanitizeMode::Warn); - let sanitize_config = parse_sanitize_config(sanitize_template, &sanitize_mode)?; + let draft_only = matches.get_flag("draft-only"); + + let policy = helpers::modelarmor::ExecutionPolicy { + template: sanitize_template, + mode: sanitize_mode, + draft_only, + }; // Check if a helper wants to handle this command if let Some(helper) = helpers::get_helper(&doc.name) { - if helper.handle(&doc, &matches, &sanitize_config).await? { + if helper.handle(&doc, &matches, &policy).await? { return Ok(()); } } // Walk the subcommand tree to find the target method - let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; + let (method, matched_args, method_path) = resolve_method_from_matches(&doc, &matches)?; + + // Gmail-specific safety policy: block sending if draft-only mode is active. + // We check this here to trigger BEFORE authentication for raw commands. + let dry_run = matched_args.get_flag("dry-run"); + if draft_only && !dry_run && api_name == "gmail" { + let is_send = if let Some(ref id) = method.id { + id == "gmail.users.messages.send" || id == "gmail.users.drafts.send" + } else { + // Fallback to Discovery path if ID is missing (suggested by previous review for robustness) + method_path.len() == 3 + && method_path[0] == "users" + && (method_path[1] == "messages" || method_path[1] == "drafts") + && method_path[2] == "send" + }; + + if is_send { + return Err(GwsError::Validation( + "Gmail send operation blocked by --draft-only policy. Use --dry-run for oversight." + .to_string(), + )); + } + } let params_json = matched_args.get_one::("params").map(|s| s.as_str()); let body_json = matched_args @@ -242,19 +270,15 @@ async fn run() -> Result<(), GwsError> { Ok(t) => (Some(t), executor::AuthMethod::OAuth), Err(e) => { // If credentials were found but failed (e.g. decryption error, invalid token), - // propagate the error instead of silently falling back to unauthenticated. - // Only fall back to None if no credentials exist at all. - let err_msg = format!("{e:#}"); - // NB: matches the bail!() message in auth::load_credentials_inner - if err_msg.starts_with("No credentials found") { - (None, executor::AuthMethod::None) - } else { - return Err(GwsError::Auth(format!("Authentication failed: {err_msg}"))); + // and we're not in dry-run mode, we should fail for real. + // If dry-run is on, we'll try to proceed without auth. + if !dry_run { + return Err(GwsError::Auth(format!("Authentication failed: {e}"))); } + (None, executor::AuthMethod::None) } }; - // Execute executor::execute_method( &doc, method, @@ -267,8 +291,7 @@ async fn run() -> Result<(), GwsError> { upload_content_type, dry_run, &pagination, - sanitize_config.template.as_deref(), - &sanitize_config.mode, + &policy, &output_format, false, ) @@ -347,27 +370,18 @@ pub fn filter_args_for_subcommand(args: &[String], service_name: &str) -> Vec, - mode: &helpers::modelarmor::SanitizeMode, -) -> Result { - Ok(helpers::modelarmor::SanitizeConfig { - template, - mode: mode.clone(), - }) -} /// Recursively walks clap ArgMatches to find the leaf method and its matches. fn resolve_method_from_matches<'a>( doc: &'a discovery::RestDescription, matches: &'a clap::ArgMatches, -) -> Result<(&'a discovery::RestMethod, &'a clap::ArgMatches), GwsError> { +) -> Result<(&'a discovery::RestMethod, &'a clap::ArgMatches, Vec), GwsError> { // Walk the subcommand chain - let mut path: Vec<&str> = Vec::new(); + let mut path: Vec = Vec::new(); let mut current_matches = matches; while let Some((sub_name, sub_matches)) = current_matches.subcommand() { - path.push(sub_name); + path.push(sub_name.to_string()); current_matches = sub_matches; } @@ -379,7 +393,7 @@ fn resolve_method_from_matches<'a>( // path looks like ["files", "list"] or ["files", "permissions", "list"] // Walk the Discovery Document resources to find the method - let resource_name = path[0]; + let resource_name = &path[0]; let resource = doc .resources .get(resource_name) @@ -388,7 +402,7 @@ fn resolve_method_from_matches<'a>( let mut current_resource = resource; // Navigate sub-resources (everything except the last element, which is the method) - for &name in &path[1..path.len() - 1] { + for name in &path[1..path.len() - 1] { // Check if this is a sub-resource if let Some(sub) = current_resource.resources.get(name) { current_resource = sub; @@ -400,11 +414,11 @@ fn resolve_method_from_matches<'a>( } // The last element is the method name - let method_name = path[path.len() - 1]; + let method_name = &path[path.len() - 1]; // Check if this is a method on the current resource if let Some(method) = current_resource.methods.get(method_name) { - return Ok((method, current_matches)); + return Ok((method, current_matches, path)); } // Maybe it's a resource that has methods — need one more subcommand @@ -561,24 +575,6 @@ mod tests { assert_eq!(config.page_delay_ms, 500); } - #[test] - fn test_parse_sanitize_config_valid() { - let config = parse_sanitize_config( - Some("tpl".to_string()), - &helpers::modelarmor::SanitizeMode::Warn, - ) - .unwrap(); - assert_eq!(config.template.as_deref(), Some("tpl")); - } - - #[test] - fn test_parse_sanitize_config_no_template() { - let config = - parse_sanitize_config(None, &helpers::modelarmor::SanitizeMode::Block).unwrap(); - assert!(config.template.is_none()); - assert_eq!(config.mode, helpers::modelarmor::SanitizeMode::Block); - } - #[test] fn test_is_version_flag() { assert!(is_version_flag("--version")); @@ -622,8 +618,9 @@ mod tests { .subcommand(clap::Command::new("files").subcommand(clap::Command::new("list"))); let matches = cmd.get_matches_from(vec!["gws", "files", "list"]); - let (method, _) = resolve_method_from_matches(&doc, &matches).unwrap(); + let (method, _, method_path) = resolve_method_from_matches(&doc, &matches).unwrap(); assert_eq!(method.id.as_deref(), Some("drive.files.list")); + assert_eq!(method_path, vec!["files", "list"]); } #[test] @@ -655,8 +652,9 @@ mod tests { )); let matches = cmd.get_matches_from(vec!["gws", "files", "permissions", "get"]); - let (method, _) = resolve_method_from_matches(&doc, &matches).unwrap(); + let (method, _, method_path) = resolve_method_from_matches(&doc, &matches).unwrap(); assert_eq!(method.id.as_deref(), Some("drive.files.permissions.get")); + assert_eq!(method_path, vec!["files", "permissions", "get"]); } #[test] From baff8ee217b1baa2eb2956db45be8ed6075e7c0c Mon Sep 17 00:00:00 2001 From: dumko2001 Date: Mon, 16 Mar 2026 14:45:25 +0530 Subject: [PATCH 2/3] feat(gmail): centralize draft-only safety logic and fix auth regression --- src/executor.rs | 14 -------------- src/helpers/gmail/mod.rs | 14 ++++++++++++++ src/main.rs | 25 +++++++++---------------- 3 files changed, 23 insertions(+), 30 deletions(-) diff --git a/src/executor.rs b/src/executor.rs index c68f8b1e..dc12026e 100644 --- a/src/executor.rs +++ b/src/executor.rs @@ -382,20 +382,6 @@ pub async fn execute_method( ) -> Result, GwsError> { let input = parse_and_validate_inputs(doc, method, params_json, body_json, upload_path)?; - // Gmail-specific safety policy: block sending if draft-only mode is active - if policy.draft_only && !dry_run && doc.name == "gmail" { - let is_send = if let Some(ref id) = method.id { - id == "gmail.users.messages.send" || id == "gmail.users.drafts.send" - } else { - // Fallback to Discovery path if ID is missing. - // Standard Gmail send path: users/{userId}/messages/send - method.path.contains("messages/send") || method.path.contains("drafts/send") - }; - - if is_send { - return Err(GwsError::Validation("Gmail draft-only mode is active. Sending mail is blocked (preparing a draft is still allowed).".to_string())); - } - } if dry_run { let dry_run_info = json!({ diff --git a/src/helpers/gmail/mod.rs b/src/helpers/gmail/mod.rs index d3e935be..366625ce 100644 --- a/src/helpers/gmail/mod.rs +++ b/src/helpers/gmail/mod.rs @@ -632,6 +632,20 @@ pub(super) fn build_raw_send_body(raw_message: &str, thread_id: Option<&str>) -> Value::Object(body) } +/// Returns true if the method is a Gmail send operation. +pub fn is_send_method(method: &crate::discovery::RestMethod, path: &[String]) -> bool { + if let Some(ref id) = method.id { + id == "gmail.users.messages.send" || id == "gmail.users.drafts.send" + } else { + // Fallback to Discovery path if ID is missing. + // Standard Gmail send path: users/{userId}/messages/send + path.len() == 3 + && path[0] == "users" + && (path[1] == "messages" || path[1] == "drafts") + && path[2] == "send" + } +} + pub(super) async fn send_raw_email( doc: &crate::discovery::RestDescription, matches: &ArgMatches, diff --git a/src/main.rs b/src/main.rs index 99666708..699e7d81 100644 --- a/src/main.rs +++ b/src/main.rs @@ -219,17 +219,7 @@ async fn run() -> Result<(), GwsError> { // We check this here to trigger BEFORE authentication for raw commands. let dry_run = matched_args.get_flag("dry-run"); if draft_only && !dry_run && api_name == "gmail" { - let is_send = if let Some(ref id) = method.id { - id == "gmail.users.messages.send" || id == "gmail.users.drafts.send" - } else { - // Fallback to Discovery path if ID is missing (suggested by previous review for robustness) - method_path.len() == 3 - && method_path[0] == "users" - && (method_path[1] == "messages" || method_path[1] == "drafts") - && method_path[2] == "send" - }; - - if is_send { + if helpers::gmail::is_send_method(method, &method_path) { return Err(GwsError::Validation( "Gmail send operation blocked by --draft-only policy. Use --dry-run for oversight." .to_string(), @@ -270,12 +260,15 @@ async fn run() -> Result<(), GwsError> { Ok(t) => (Some(t), executor::AuthMethod::OAuth), Err(e) => { // If credentials were found but failed (e.g. decryption error, invalid token), - // and we're not in dry-run mode, we should fail for real. - // If dry-run is on, we'll try to proceed without auth. - if !dry_run { - return Err(GwsError::Auth(format!("Authentication failed: {e}"))); + // propagate the error instead of silently falling back to unauthenticated. + // Only fall back to None if no credentials exist at all. + let err_msg = format!("{e:#}"); + // NB: matches the bail!() message in auth::load_credentials_inner + if err_msg.starts_with("No credentials found") { + (None, executor::AuthMethod::None) + } else { + return Err(GwsError::Auth(format!("Authentication failed: {err_msg}"))); } - (None, executor::AuthMethod::None) } }; From 255bde7f2d2ff2f19d3e6af10b4cdb7d28696121 Mon Sep 17 00:00:00 2001 From: dumko2001 Date: Mon, 16 Mar 2026 14:51:15 +0530 Subject: [PATCH 3/3] chore: fix typo and polish PR review comments --- src/helpers/gmail/mod.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/helpers/gmail/mod.rs b/src/helpers/gmail/mod.rs index 366625ce..e4034bf9 100644 --- a/src/helpers/gmail/mod.rs +++ b/src/helpers/gmail/mod.rs @@ -31,7 +31,6 @@ pub(super) use crate::executor; pub(super) use anyhow::Context; pub(super) use base64::{engine::general_purpose::URL_SAFE, Engine as _}; pub(super) use clap::{Arg, ArgAction, ArgMatches, Command}; -pub(super) pub(super) use serde_json::{json, Value}; use std::future::Future; use std::pin::Pin;