Skip to content

Commit 3ec9848

Browse files
committed
feat(gmail): implement robust draft-only mode with centralized policy enforcement
1 parent 1308786 commit 3ec9848

File tree

22 files changed

+227
-110
lines changed

22 files changed

+227
-110
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@googleworkspace/cli": patch
3+
---
4+
5+
Implement robust Gmail draft-only mode with centralized policy enforcement in the executor layer to prevent bypasses.

Cargo.lock

Lines changed: 75 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ path = "src/main.rs"
3434
[dependencies]
3535
aes-gcm = "0.10"
3636
anyhow = "1"
37-
clap = { version = "4", features = ["derive", "string"] }
37+
clap = { version = "4", features = ["derive", "string", "env"] }
3838
dirs = "5"
3939
dotenvy = "0.15"
4040
hostname = "0.4"
@@ -80,5 +80,6 @@ inherits = "release"
8080
lto = "thin"
8181

8282
[dev-dependencies]
83+
assert_cmd = "2"
8384
serial_test = "3.4.0"
8485
tempfile = "3"

src/commands.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,15 @@ pub fn build_cli(doc: &RestDescription) -> Command {
3333
.value_name("TEMPLATE")
3434
.global(true),
3535
)
36+
.arg(
37+
clap::Arg::new("draft-only")
38+
.long("draft-only")
39+
.help("Gmail draft-only mode: block sending and strictly allow only draft creation/updates. Also reads GOOGLE_WORKSPACE_GMAIL_DRAFT_ONLY env var.")
40+
.action(clap::ArgAction::SetTrue)
41+
.env("GOOGLE_WORKSPACE_GMAIL_DRAFT_ONLY")
42+
.global(true),
43+
)
44+
3645
.arg(
3746
clap::Arg::new("dry-run")
3847
.long("dry-run")

src/executor.rs

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -217,8 +217,7 @@ async fn build_http_request(
217217
async fn handle_json_response(
218218
body_text: &str,
219219
pagination: &PaginationConfig,
220-
sanitize_template: Option<&str>,
221-
sanitize_mode: &crate::helpers::modelarmor::SanitizeMode,
220+
policy: &crate::helpers::modelarmor::ExecutionPolicy,
222221
output_format: &crate::formatter::OutputFormat,
223222
pages_fetched: &mut u32,
224223
page_token: &mut Option<String>,
@@ -229,7 +228,7 @@ async fn handle_json_response(
229228
*pages_fetched += 1;
230229

231230
// Run Model Armor sanitization if --sanitize is enabled
232-
if let Some(template) = sanitize_template {
231+
if let Some(ref template) = policy.template {
233232
let text_to_check = serde_json::to_string(&json_val).unwrap_or_default();
234233
match crate::helpers::modelarmor::sanitize_text(template, &text_to_check).await {
235234
Ok(result) => {
@@ -238,7 +237,7 @@ async fn handle_json_response(
238237
eprintln!("⚠️ Model Armor: prompt injection detected (filterMatchState: MATCH_FOUND)");
239238
}
240239

241-
if is_match && *sanitize_mode == crate::helpers::modelarmor::SanitizeMode::Block
240+
if is_match && policy.mode == crate::helpers::modelarmor::SanitizeMode::Block
242241
{
243242
let blocked = serde_json::json!({
244243
"error": "Content blocked by Model Armor",
@@ -377,13 +376,27 @@ pub async fn execute_method(
377376
upload_content_type: Option<&str>,
378377
dry_run: bool,
379378
pagination: &PaginationConfig,
380-
sanitize_template: Option<&str>,
381-
sanitize_mode: &crate::helpers::modelarmor::SanitizeMode,
379+
policy: &crate::helpers::modelarmor::ExecutionPolicy,
382380
output_format: &crate::formatter::OutputFormat,
383381
capture_output: bool,
384382
) -> Result<Option<Value>, GwsError> {
385383
let input = parse_and_validate_inputs(doc, method, params_json, body_json, upload_path)?;
386384

385+
// Gmail-specific safety policy: block sending if draft-only mode is active
386+
if policy.draft_only && !dry_run && doc.name == "gmail" {
387+
let is_send = if let Some(ref id) = method.id {
388+
id == "gmail.users.messages.send" || id == "gmail.users.drafts.send"
389+
} else {
390+
// Fallback to Discovery path if ID is missing.
391+
// Standard Gmail send path: users/{userId}/messages/send
392+
method.path.contains("messages/send") || method.path.contains("drafts/send")
393+
};
394+
395+
if is_send {
396+
return Err(GwsError::Validation("Gmail draft-only mode is active. Sending mail is blocked (preparing a draft is still allowed).".to_string()));
397+
}
398+
}
399+
387400
if dry_run {
388401
let dry_run_info = json!({
389402
"dry_run": true,
@@ -470,8 +483,7 @@ pub async fn execute_method(
470483
let should_continue = handle_json_response(
471484
&body_text,
472485
pagination,
473-
sanitize_template,
474-
sanitize_mode,
486+
policy,
475487
output_format,
476488
&mut pages_fetched,
477489
&mut page_token,

src/helpers/calendar.rs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ TIPS:
151151
&'a self,
152152
doc: &'a crate::discovery::RestDescription,
153153
matches: &'a ArgMatches,
154-
_sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig,
154+
_policy: &'a crate::helpers::modelarmor::ExecutionPolicy,
155155
) -> Pin<Box<dyn Future<Output = Result<bool, GwsError>> + Send + 'a>> {
156156
Box::pin(async move {
157157
if let Some(matches) = matches.subcommand_matches("+insert") {
@@ -182,8 +182,7 @@ TIPS:
182182
None,
183183
matches.get_flag("dry-run"),
184184
&executor::PaginationConfig::default(),
185-
None,
186-
&crate::helpers::modelarmor::SanitizeMode::Warn,
185+
_policy,
187186
&crate::formatter::OutputFormat::default(),
188187
false,
189188
)

src/helpers/chat.rs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ TIPS:
6363
&'a self,
6464
doc: &'a crate::discovery::RestDescription,
6565
matches: &'a ArgMatches,
66-
_sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig,
66+
_policy: &'a crate::helpers::modelarmor::ExecutionPolicy,
6767
) -> Pin<Box<dyn Future<Output = Result<bool, GwsError>> + Send + 'a>> {
6868
// We use `Box::pin` to create a pinned future on the heap.
6969
// This is necessary because the `Helper` trait returns a generic `Future`,
@@ -112,8 +112,7 @@ TIPS:
112112
None,
113113
matches.get_flag("dry-run"),
114114
&pagination,
115-
None,
116-
&crate::helpers::modelarmor::SanitizeMode::Warn,
115+
_policy,
117116
&crate::formatter::OutputFormat::default(),
118117
false,
119118
)

src/helpers/docs.rs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ TIPS:
6363
&'a self,
6464
doc: &'a crate::discovery::RestDescription,
6565
matches: &'a ArgMatches,
66-
_sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig,
66+
_policy: &'a crate::helpers::modelarmor::ExecutionPolicy,
6767
) -> Pin<Box<dyn Future<Output = Result<bool, GwsError>> + Send + 'a>> {
6868
Box::pin(async move {
6969
if let Some(matches) = matches.subcommand_matches("+write") {
@@ -102,8 +102,7 @@ TIPS:
102102
None,
103103
matches.get_flag("dry-run"),
104104
&pagination,
105-
None,
106-
&crate::helpers::modelarmor::SanitizeMode::Warn,
105+
_policy,
107106
&crate::formatter::OutputFormat::default(),
108107
false,
109108
)

src/helpers/drive.rs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ TIPS:
7070
&'a self,
7171
doc: &'a crate::discovery::RestDescription,
7272
matches: &'a ArgMatches,
73-
_sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig,
73+
_policy: &'a crate::helpers::modelarmor::ExecutionPolicy,
7474
) -> Pin<Box<dyn Future<Output = Result<bool, GwsError>> + Send + 'a>> {
7575
Box::pin(async move {
7676
if let Some(matches) = matches.subcommand_matches("+upload") {
@@ -113,8 +113,7 @@ TIPS:
113113
None,
114114
matches.get_flag("dry-run"),
115115
&executor::PaginationConfig::default(),
116-
None,
117-
&crate::helpers::modelarmor::SanitizeMode::Warn,
116+
_policy,
118117
&crate::formatter::OutputFormat::default(),
119118
false,
120119
)

src/helpers/events/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ TIPS:
174174
&'a self,
175175
doc: &'a crate::discovery::RestDescription,
176176
matches: &'a ArgMatches,
177-
_sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig,
177+
_policy: &'a crate::helpers::modelarmor::ExecutionPolicy,
178178
) -> Pin<Box<dyn Future<Output = Result<bool, GwsError>> + Send + 'a>> {
179179
Box::pin(async move {
180180
if let Some(sub_matches) = matches.subcommand_matches("+subscribe") {

0 commit comments

Comments
 (0)