Skip to main content

zeroclaw_tools/
google_workspace.rs

1use async_trait::async_trait;
2use serde_json::json;
3use std::sync::Arc;
4use std::time::Duration;
5use zeroclaw_api::tool::{Tool, ToolResult};
6use zeroclaw_config::policy::SecurityPolicy;
7use zeroclaw_config::schema::GoogleWorkspaceAllowedOperation;
8
9/// Default `gws` command execution time before kill (overridden by config).
10#[cfg(test)]
11const DEFAULT_GWS_TIMEOUT_SECS: u64 = 30;
12/// Maximum output size in bytes (1MB).
13const MAX_OUTPUT_BYTES: usize = 1_048_576;
14
15use zeroclaw_config::schema::DEFAULT_GWS_SERVICES;
16
17/// Google Workspace CLI (`gws`) integration tool.
18///
19/// Wraps the `gws` CLI binary to give the agent structured access to
20/// Google Workspace services (Drive, Gmail, Calendar, Sheets, etc.).
21/// Requires `gws` to be installed and authenticated (`gws auth login`).
22pub struct GoogleWorkspaceTool {
23    security: Arc<SecurityPolicy>,
24    allowed_services: Vec<String>,
25    allowed_operations: Vec<GoogleWorkspaceAllowedOperation>,
26    credentials_path: Option<String>,
27    default_account: Option<String>,
28    #[allow(dead_code)] // Config field for future rate-limiting
29    rate_limit_per_minute: u32,
30    timeout_secs: u64,
31    audit_log: bool,
32}
33
34impl GoogleWorkspaceTool {
35    /// Create a new `GoogleWorkspaceTool`.
36    ///
37    /// If `allowed_services` is empty, the default service set is used.
38    pub fn new(
39        security: Arc<SecurityPolicy>,
40        allowed_services: Vec<String>,
41        allowed_operations: Vec<GoogleWorkspaceAllowedOperation>,
42        credentials_path: Option<String>,
43        default_account: Option<String>,
44        rate_limit_per_minute: u32,
45        timeout_secs: u64,
46        audit_log: bool,
47    ) -> Self {
48        let services = if allowed_services.is_empty() {
49            DEFAULT_GWS_SERVICES
50                .iter()
51                .map(|s| (*s).to_string())
52                .collect()
53        } else {
54            allowed_services
55                .into_iter()
56                .map(|s| s.trim().to_string())
57                .collect()
58        };
59        // Normalize stored operation fields at construction time so runtime
60        // comparisons can use plain equality without repeated .trim() calls.
61        let operations = allowed_operations
62            .into_iter()
63            .map(|op| GoogleWorkspaceAllowedOperation {
64                service: op.service.trim().to_string(),
65                resource: op.resource.trim().to_string(),
66                sub_resource: op.sub_resource.as_deref().map(|s| s.trim().to_string()),
67                methods: op.methods.iter().map(|m| m.trim().to_string()).collect(),
68            })
69            .collect();
70        Self {
71            security,
72            allowed_services: services,
73            allowed_operations: operations,
74            credentials_path,
75            default_account,
76            rate_limit_per_minute,
77            timeout_secs,
78            audit_log,
79        }
80    }
81
82    /// Build the positional `gws` arguments: `[service, resource, (sub_resource,)? method]`.
83    fn positional_cmd_args(
84        service: &str,
85        resource: &str,
86        sub_resource: Option<&str>,
87        method: &str,
88    ) -> Vec<String> {
89        let mut args = vec![service.to_string(), resource.to_string()];
90        if let Some(sub) = sub_resource {
91            args.push(sub.to_string());
92        }
93        args.push(method.to_string());
94        args
95    }
96
97    /// Build the `--page-all` and `--page-limit` flags from validated pagination inputs.
98    /// `page_limit` alone (without `page_all`) caps page count; both together fetch all pages
99    /// up to the limit.
100    fn build_pagination_args(page_all: bool, page_limit: Option<u64>) -> Vec<String> {
101        let mut args = Vec::new();
102        if page_all {
103            args.push("--page-all".into());
104        }
105        if page_all || page_limit.is_some() {
106            args.push("--page-limit".into());
107            args.push(page_limit.unwrap_or(10).to_string());
108        }
109        args
110    }
111
112    fn is_operation_allowed(
113        &self,
114        service: &str,
115        resource: &str,
116        sub_resource: Option<&str>,
117        method: &str,
118    ) -> bool {
119        if self.allowed_operations.is_empty() {
120            return true;
121        }
122        self.allowed_operations.iter().any(|operation| {
123            operation.service == service
124                && operation.resource == resource
125                && operation.sub_resource.as_deref() == sub_resource
126                && operation.methods.iter().any(|allowed| allowed == method)
127        })
128    }
129}
130
131#[async_trait]
132impl Tool for GoogleWorkspaceTool {
133    fn name(&self) -> &str {
134        "google_workspace"
135    }
136
137    fn description(&self) -> &str {
138        "Interact with Google Workspace services (Drive, Gmail, Calendar, Sheets, Docs, etc.) \
139         via the gws CLI. Requires gws to be installed and authenticated. \
140         IMPORTANT: Gmail commands are 4-segment and REQUIRE sub_resource. \
141         To list Gmail messages, use service=gmail, resource=users, sub_resource=messages, method=list \
142         (this becomes `gws gmail users messages list`). \
143         Without sub_resource, Gmail calls will fail. \
144         Drive, Calendar, and Sheets are 3-segment and do NOT use sub_resource."
145    }
146
147    fn parameters_schema(&self) -> serde_json::Value {
148        json!({
149            "type": "object",
150            "properties": {
151                "service": {
152                    "type": "string",
153                    "description": "Google Workspace service (e.g. drive, gmail, calendar, sheets, docs, slides, tasks, people, chat, classroom, forms, keep, meet, events)"
154                },
155                "resource": {
156                    "type": "string",
157                    "description": "Top-level resource. For Gmail this is always 'users'. For Drive use 'files'. For Calendar use 'events' or 'calendars'. For Sheets use 'spreadsheets'."
158                },
159                "method": {
160                    "type": "string",
161                    "description": "Method to call on the resource (e.g. list, get, create, update, delete)"
162                },
163                "sub_resource": {
164                    "type": "string",
165                    "description": "Sub-resource for 4-segment gws commands. REQUIRED for Gmail: use 'messages', 'threads', 'drafts', or 'labels' (e.g. gmail/users/messages/list). Omit for 3-segment services like Drive, Calendar, and Sheets."
166                },
167                "params": {
168                    "type": "object",
169                    "description": "URL/query parameters as key-value pairs (passed as --params JSON). For Gmail, ALWAYS include `userId: \"me\"` to refer to the authenticated user (e.g. {\"userId\":\"me\",\"maxResults\":10}). For Calendar events.list, include `calendarId: \"primary\"`."
170                },
171                "body": {
172                    "type": "object",
173                    "description": "Request body for POST/PATCH/PUT operations (passed as --json JSON)"
174                },
175                "format": {
176                    "type": "string",
177                    "enum": ["json", "table", "yaml", "csv"],
178                    "description": "Output format (default: json)"
179                },
180                "page_all": {
181                    "type": "boolean",
182                    "description": "Auto-paginate through all results"
183                },
184                "page_limit": {
185                    "type": "integer",
186                    "description": "Max pages to fetch when using page_all (default: 10)"
187                }
188            },
189            "required": ["service", "resource", "method"]
190        })
191    }
192
193    /// Execute a Google Workspace CLI command with input validation and security enforcement.
194    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
195        let service = args
196            .get("service")
197            .and_then(|v| v.as_str())
198            .ok_or_else(|| {
199                ::zeroclaw_log::record!(
200                    WARN,
201                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
202                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
203                        .with_attrs(::serde_json::json!({"param": "service"})),
204                    "google_workspace: missing service parameter"
205                );
206                anyhow::Error::msg("Missing 'service' parameter")
207            })?;
208        let resource = args
209            .get("resource")
210            .and_then(|v| v.as_str())
211            .ok_or_else(|| {
212                ::zeroclaw_log::record!(
213                    WARN,
214                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
215                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
216                        .with_attrs(::serde_json::json!({"param": "resource"})),
217                    "google_workspace: missing resource parameter"
218                );
219                anyhow::Error::msg("Missing 'resource' parameter")
220            })?;
221        let method = args.get("method").and_then(|v| v.as_str()).ok_or_else(|| {
222            ::zeroclaw_log::record!(
223                WARN,
224                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
225                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
226                    .with_attrs(::serde_json::json!({"param": "method"})),
227                "google_workspace: missing method parameter"
228            );
229            anyhow::Error::msg("Missing 'method' parameter")
230        })?;
231
232        // Extract and validate sub_resource early so the allowlist check can account for it.
233        let sub_resource: Option<&str> = if let Some(sub_resource_value) = args.get("sub_resource")
234        {
235            let s = match sub_resource_value.as_str() {
236                Some(s) => s,
237                None => {
238                    return Ok(ToolResult {
239                        success: false,
240                        output: String::new(),
241                        error: Some("'sub_resource' must be a string".into()),
242                    });
243                }
244            };
245            if !s
246                .chars()
247                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
248            {
249                return Ok(ToolResult {
250                    success: false,
251                    output: String::new(),
252                    error: Some(
253                        "Invalid characters in 'sub_resource': only lowercase alphanumeric, underscore, and hyphen are allowed"
254                            .into(),
255                    ),
256                });
257            }
258            Some(s)
259        } else {
260            None
261        };
262
263        // Security checks
264        if self.security.is_rate_limited() {
265            return Ok(ToolResult {
266                success: false,
267                output: String::new(),
268                error: Some("Rate limit exceeded: too many actions in the last hour".into()),
269            });
270        }
271
272        // Validate service is in the allowlist
273        if !self.allowed_services.iter().any(|s| s == service) {
274            return Ok(ToolResult {
275                success: false,
276                output: String::new(),
277                error: Some(format!(
278                    "Service '{service}' is not in the allowed services list. \
279                     Allowed: {}",
280                    self.allowed_services.join(", ")
281                )),
282            });
283        }
284
285        if !self.is_operation_allowed(service, resource, sub_resource, method) {
286            let op_path = match sub_resource {
287                Some(sub) => format!("{service}/{resource}/{sub}/{method}"),
288                None => format!("{service}/{resource}/{method}"),
289            };
290            return Ok(ToolResult {
291                success: false,
292                output: String::new(),
293                error: Some(format!(
294                    "Operation '{op_path}' is not in the allowed operations list"
295                )),
296            });
297        }
298
299        // Validate inputs contain no shell metacharacters
300        for (label, value) in [
301            ("service", service),
302            ("resource", resource),
303            ("method", method),
304        ] {
305            if !value
306                .chars()
307                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
308            {
309                return Ok(ToolResult {
310                    success: false,
311                    output: String::new(),
312                    error: Some(format!(
313                        "Invalid characters in '{label}': only lowercase alphanumeric, underscore, and hyphen are allowed"
314                    )),
315                });
316            }
317        }
318
319        // Build the gws command — validate all optional fields before consuming budget
320        let mut cmd_args = Self::positional_cmd_args(service, resource, sub_resource, method);
321
322        if let Some(params) = args.get("params") {
323            if !params.is_object() {
324                return Ok(ToolResult {
325                    success: false,
326                    output: String::new(),
327                    error: Some("'params' must be an object".into()),
328                });
329            }
330            cmd_args.push("--params".into());
331            cmd_args.push(params.to_string());
332        }
333
334        if let Some(body) = args.get("body") {
335            if !body.is_object() {
336                return Ok(ToolResult {
337                    success: false,
338                    output: String::new(),
339                    error: Some("'body' must be an object".into()),
340                });
341            }
342            cmd_args.push("--json".into());
343            cmd_args.push(body.to_string());
344        }
345
346        if let Some(format_value) = args.get("format") {
347            let format = match format_value.as_str() {
348                Some(s) => s,
349                None => {
350                    return Ok(ToolResult {
351                        success: false,
352                        output: String::new(),
353                        error: Some("'format' must be a string".into()),
354                    });
355                }
356            };
357            match format {
358                "json" | "table" | "yaml" | "csv" => {
359                    cmd_args.push("--format".into());
360                    cmd_args.push(format.to_string());
361                }
362                _ => {
363                    return Ok(ToolResult {
364                        success: false,
365                        output: String::new(),
366                        error: Some(format!(
367                            "Invalid format '{format}': must be json, table, yaml, or csv"
368                        )),
369                    });
370                }
371            }
372        }
373
374        let page_all = match args.get("page_all") {
375            Some(v) => match v.as_bool() {
376                Some(b) => b,
377                None => {
378                    return Ok(ToolResult {
379                        success: false,
380                        output: String::new(),
381                        error: Some("'page_all' must be a boolean".into()),
382                    });
383                }
384            },
385            None => false,
386        };
387        let page_limit = match args.get("page_limit") {
388            Some(v) => match v.as_u64() {
389                Some(n) => Some(n),
390                None => {
391                    return Ok(ToolResult {
392                        success: false,
393                        output: String::new(),
394                        error: Some("'page_limit' must be a non-negative integer".into()),
395                    });
396                }
397            },
398            None => None,
399        };
400        cmd_args.extend(Self::build_pagination_args(page_all, page_limit));
401
402        // Charge action budget only after all validation passes
403        if !self.security.record_action() {
404            return Ok(ToolResult {
405                success: false,
406                output: String::new(),
407                error: Some("Rate limit exceeded: action budget exhausted".into()),
408            });
409        }
410
411        // Resolve `gws` via PATH so Windows `.cmd` shims (e.g. npm-installed
412        // `gws.cmd`) are picked up — `Command::new` on Windows does not append
413        // PATHEXT itself. Falls back to bare "gws" so the not-found error path
414        // below still fires when the binary is genuinely missing.
415        let gws_path: std::path::PathBuf = which::which("gws").unwrap_or_else(|_| "gws".into());
416        let mut cmd = tokio::process::Command::new(gws_path);
417        cmd.args(&cmd_args);
418        cmd.env_clear();
419        // gws needs PATH to find itself and HOME/APPDATA for credential storage
420        for key in &["PATH", "HOME", "APPDATA", "USERPROFILE", "LANG", "TERM"] {
421            if let Ok(val) = std::env::var(key) {
422                cmd.env(key, val);
423            }
424        }
425
426        // Apply credential path if configured
427        if let Some(ref creds) = self.credentials_path {
428            cmd.env("GOOGLE_APPLICATION_CREDENTIALS", creds);
429        }
430
431        // Apply default account if configured
432        if let Some(ref account) = self.default_account {
433            cmd.args(["--account", account]);
434        }
435
436        if self.audit_log {
437            ::zeroclaw_log::record!(INFO, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"tool": "google_workspace", "service": service, "resource": resource, "sub_resource": sub_resource.unwrap_or(""), "method": method})), "gws audit: executing API call");
438        }
439
440        let result =
441            tokio::time::timeout(Duration::from_secs(self.timeout_secs), cmd.output()).await;
442
443        match result {
444            Ok(Ok(output)) => {
445                let mut stdout = String::from_utf8_lossy(&output.stdout).to_string();
446                let mut stderr = String::from_utf8_lossy(&output.stderr).to_string();
447
448                if stdout.len() > MAX_OUTPUT_BYTES {
449                    // Find a valid char boundary at or before MAX_OUTPUT_BYTES
450                    let mut boundary = MAX_OUTPUT_BYTES;
451                    while boundary > 0 && !stdout.is_char_boundary(boundary) {
452                        boundary -= 1;
453                    }
454                    stdout.truncate(boundary);
455                    stdout.push_str("\n... [output truncated at 1MB]");
456                }
457                if stderr.len() > MAX_OUTPUT_BYTES {
458                    let mut boundary = MAX_OUTPUT_BYTES;
459                    while boundary > 0 && !stderr.is_char_boundary(boundary) {
460                        boundary -= 1;
461                    }
462                    stderr.truncate(boundary);
463                    stderr.push_str("\n... [stderr truncated at 1MB]");
464                }
465
466                Ok(ToolResult {
467                    success: output.status.success(),
468                    output: stdout,
469                    error: if stderr.is_empty() {
470                        None
471                    } else {
472                        Some(stderr)
473                    },
474                })
475            }
476            Ok(Err(e)) => Ok(ToolResult {
477                success: false,
478                output: String::new(),
479                error: Some(format!(
480                    "Failed to execute gws: {e}. Is gws installed? Run: npm install -g @googleworkspace/cli"
481                )),
482            }),
483            Err(_) => Ok(ToolResult {
484                success: false,
485                output: String::new(),
486                error: Some(format!(
487                    "gws command timed out after {}s and was killed",
488                    self.timeout_secs
489                )),
490            }),
491        }
492    }
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498    use zeroclaw_config::autonomy::AutonomyLevel;
499    use zeroclaw_config::policy::SecurityPolicy;
500
501    fn test_security() -> Arc<SecurityPolicy> {
502        Arc::new(SecurityPolicy {
503            autonomy: AutonomyLevel::Full,
504            workspace_dir: std::env::temp_dir(),
505            ..SecurityPolicy::default()
506        })
507    }
508
509    // Regression for #6410: PATH resolution must produce a usable PathBuf
510    // even when `gws` is not installed, so the executor can still emit the
511    // documented "Failed to execute gws" error rather than panicking.
512    #[test]
513    fn gws_path_resolution_falls_back_when_not_on_path() {
514        let resolved: std::path::PathBuf =
515            which::which("definitely-not-a-real-binary-zc6410").unwrap_or_else(|_| "gws".into());
516        assert_eq!(resolved.as_os_str(), "gws");
517    }
518
519    #[test]
520    fn tool_name() {
521        let tool =
522            GoogleWorkspaceTool::new(test_security(), vec![], vec![], None, None, 60, 30, false);
523        assert_eq!(tool.name(), "google_workspace");
524    }
525
526    #[test]
527    fn tool_description_non_empty() {
528        let tool =
529            GoogleWorkspaceTool::new(test_security(), vec![], vec![], None, None, 60, 30, false);
530        assert!(!tool.description().is_empty());
531    }
532
533    #[test]
534    fn tool_schema_has_required_fields() {
535        let tool =
536            GoogleWorkspaceTool::new(test_security(), vec![], vec![], None, None, 60, 30, false);
537        let schema = tool.parameters_schema();
538        assert!(schema["properties"]["service"].is_object());
539        assert!(schema["properties"]["resource"].is_object());
540        assert!(schema["properties"]["method"].is_object());
541        let required = schema["required"]
542            .as_array()
543            .expect("required should be an array");
544        assert!(required.contains(&json!("service")));
545        assert!(required.contains(&json!("resource")));
546        assert!(required.contains(&json!("method")));
547    }
548
549    #[test]
550    fn default_allowed_services_populated() {
551        let tool =
552            GoogleWorkspaceTool::new(test_security(), vec![], vec![], None, None, 60, 30, false);
553        assert!(!tool.allowed_services.is_empty());
554        assert!(tool.allowed_services.contains(&"drive".to_string()));
555        assert!(tool.allowed_services.contains(&"gmail".to_string()));
556        assert!(tool.allowed_services.contains(&"calendar".to_string()));
557    }
558
559    #[test]
560    fn custom_allowed_services_override_defaults() {
561        let tool = GoogleWorkspaceTool::new(
562            test_security(),
563            vec!["drive".into(), "sheets".into()],
564            vec![],
565            None,
566            None,
567            60,
568            30,
569            false,
570        );
571        assert_eq!(tool.allowed_services.len(), 2);
572        assert!(tool.allowed_services.contains(&"drive".to_string()));
573        assert!(tool.allowed_services.contains(&"sheets".to_string()));
574        assert!(!tool.allowed_services.contains(&"gmail".to_string()));
575    }
576
577    #[tokio::test]
578    async fn rejects_disallowed_service() {
579        let tool = GoogleWorkspaceTool::new(
580            test_security(),
581            vec!["drive".into()],
582            vec![],
583            None,
584            None,
585            60,
586            30,
587            false,
588        );
589        let result = tool
590            .execute(json!({
591                "service": "gmail",
592                "resource": "users",
593                "method": "list"
594            }))
595            .await
596            .expect("disallowed service should return a result");
597        assert!(!result.success);
598        assert!(
599            result
600                .error
601                .as_deref()
602                .unwrap_or("")
603                .contains("not in the allowed")
604        );
605    }
606
607    #[tokio::test]
608    async fn rejects_shell_injection_in_service() {
609        let tool = GoogleWorkspaceTool::new(
610            test_security(),
611            vec!["drive; rm -rf /".into()],
612            vec![],
613            None,
614            None,
615            60,
616            30,
617            false,
618        );
619        let result = tool
620            .execute(json!({
621                "service": "drive; rm -rf /",
622                "resource": "files",
623                "method": "list"
624            }))
625            .await
626            .expect("shell injection should return a result");
627        assert!(!result.success);
628        assert!(
629            result
630                .error
631                .as_deref()
632                .unwrap_or("")
633                .contains("Invalid characters")
634        );
635    }
636
637    #[tokio::test]
638    async fn rejects_shell_injection_in_resource() {
639        let tool =
640            GoogleWorkspaceTool::new(test_security(), vec![], vec![], None, None, 60, 30, false);
641        let result = tool
642            .execute(json!({
643                "service": "drive",
644                "resource": "files$(whoami)",
645                "method": "list"
646            }))
647            .await
648            .expect("shell injection should return a result");
649        assert!(!result.success);
650        assert!(
651            result
652                .error
653                .as_deref()
654                .unwrap_or("")
655                .contains("Invalid characters")
656        );
657    }
658
659    #[tokio::test]
660    async fn rejects_invalid_format() {
661        let tool =
662            GoogleWorkspaceTool::new(test_security(), vec![], vec![], None, None, 60, 30, false);
663        let result = tool
664            .execute(json!({
665                "service": "drive",
666                "resource": "files",
667                "method": "list",
668                "format": "xml"
669            }))
670            .await
671            .expect("invalid format should return a result");
672        assert!(!result.success);
673        assert!(
674            result
675                .error
676                .as_deref()
677                .unwrap_or("")
678                .contains("Invalid format")
679        );
680    }
681
682    #[tokio::test]
683    async fn rejects_wrong_type_params() {
684        let tool =
685            GoogleWorkspaceTool::new(test_security(), vec![], vec![], None, None, 60, 30, false);
686        let result = tool
687            .execute(json!({
688                "service": "drive",
689                "resource": "files",
690                "method": "list",
691                "params": "not_an_object"
692            }))
693            .await
694            .expect("wrong type params should return a result");
695        assert!(!result.success);
696        assert!(
697            result
698                .error
699                .as_deref()
700                .unwrap_or("")
701                .contains("'params' must be an object")
702        );
703    }
704
705    #[tokio::test]
706    async fn rejects_wrong_type_body() {
707        let tool =
708            GoogleWorkspaceTool::new(test_security(), vec![], vec![], None, None, 60, 30, false);
709        let result = tool
710            .execute(json!({
711                "service": "drive",
712                "resource": "files",
713                "method": "create",
714                "body": "not_an_object"
715            }))
716            .await
717            .expect("wrong type body should return a result");
718        assert!(!result.success);
719        assert!(
720            result
721                .error
722                .as_deref()
723                .unwrap_or("")
724                .contains("'body' must be an object")
725        );
726    }
727
728    #[tokio::test]
729    async fn rejects_wrong_type_page_all() {
730        let tool =
731            GoogleWorkspaceTool::new(test_security(), vec![], vec![], None, None, 60, 30, false);
732        let result = tool
733            .execute(json!({
734                "service": "drive",
735                "resource": "files",
736                "method": "list",
737                "page_all": "yes"
738            }))
739            .await
740            .expect("wrong type page_all should return a result");
741        assert!(!result.success);
742        assert!(
743            result
744                .error
745                .as_deref()
746                .unwrap_or("")
747                .contains("'page_all' must be a boolean")
748        );
749    }
750
751    #[tokio::test]
752    async fn rejects_wrong_type_page_limit() {
753        let tool =
754            GoogleWorkspaceTool::new(test_security(), vec![], vec![], None, None, 60, 30, false);
755        let result = tool
756            .execute(json!({
757                "service": "drive",
758                "resource": "files",
759                "method": "list",
760                "page_limit": "ten"
761            }))
762            .await
763            .expect("wrong type page_limit should return a result");
764        assert!(!result.success);
765        assert!(
766            result
767                .error
768                .as_deref()
769                .unwrap_or("")
770                .contains("'page_limit' must be a non-negative integer")
771        );
772    }
773
774    #[tokio::test]
775    async fn rejects_wrong_type_sub_resource() {
776        let tool =
777            GoogleWorkspaceTool::new(test_security(), vec![], vec![], None, None, 60, 30, false);
778        let result = tool
779            .execute(json!({
780                "service": "drive",
781                "resource": "files",
782                "method": "list",
783                "sub_resource": 123
784            }))
785            .await
786            .expect("wrong type sub_resource should return a result");
787        assert!(!result.success);
788        assert!(
789            result
790                .error
791                .as_deref()
792                .unwrap_or("")
793                .contains("'sub_resource' must be a string")
794        );
795    }
796
797    #[tokio::test]
798    async fn missing_required_param_returns_error() {
799        let tool =
800            GoogleWorkspaceTool::new(test_security(), vec![], vec![], None, None, 60, 30, false);
801        let result = tool.execute(json!({"service": "drive"})).await;
802        assert!(result.is_err());
803    }
804
805    #[tokio::test]
806    async fn rate_limited_returns_error() {
807        let security = Arc::new(SecurityPolicy {
808            autonomy: AutonomyLevel::Full,
809            max_actions_per_hour: 0,
810            workspace_dir: std::env::temp_dir(),
811            ..SecurityPolicy::default()
812        });
813        let tool = GoogleWorkspaceTool::new(security, vec![], vec![], None, None, 60, 30, false);
814        let result = tool
815            .execute(json!({
816                "service": "drive",
817                "resource": "files",
818                "method": "list"
819            }))
820            .await
821            .expect("rate-limited should return a result");
822        assert!(!result.success);
823        assert!(result.error.as_deref().unwrap_or("").contains("Rate limit"));
824    }
825
826    #[test]
827    fn gws_timeout_is_reasonable() {
828        assert_eq!(DEFAULT_GWS_TIMEOUT_SECS, 30);
829    }
830
831    #[test]
832    fn operation_allowlist_defaults_to_allow_all() {
833        let tool =
834            GoogleWorkspaceTool::new(test_security(), vec![], vec![], None, None, 60, 30, false);
835        // Empty allowlist: everything passes regardless of sub_resource
836        assert!(tool.is_operation_allowed("gmail", "users", Some("messages"), "send"));
837        assert!(tool.is_operation_allowed("drive", "files", None, "list"));
838    }
839
840    #[test]
841    fn operation_allowlist_matches_gmail_sub_resource_shape() {
842        let tool = GoogleWorkspaceTool::new(
843            test_security(),
844            vec!["gmail".into()],
845            vec![GoogleWorkspaceAllowedOperation {
846                service: "gmail".into(),
847                resource: "users".into(),
848                sub_resource: Some("drafts".into()),
849                methods: vec!["create".into(), "update".into()],
850            }],
851            None,
852            None,
853            60,
854            30,
855            false,
856        );
857
858        // Exact match: allowed
859        assert!(tool.is_operation_allowed("gmail", "users", Some("drafts"), "create"));
860        assert!(tool.is_operation_allowed("gmail", "users", Some("drafts"), "update"));
861        // Send not in methods: denied
862        assert!(!tool.is_operation_allowed("gmail", "users", Some("drafts"), "send"));
863        // Different sub_resource: denied
864        assert!(!tool.is_operation_allowed("gmail", "users", Some("messages"), "list"));
865        // No sub_resource when entry requires one: denied
866        assert!(!tool.is_operation_allowed("gmail", "users", None, "create"));
867    }
868
869    #[test]
870    fn operation_allowlist_matches_drive_3_segment_shape() {
871        let tool = GoogleWorkspaceTool::new(
872            test_security(),
873            vec!["drive".into()],
874            vec![GoogleWorkspaceAllowedOperation {
875                service: "drive".into(),
876                resource: "files".into(),
877                sub_resource: None,
878                methods: vec!["list".into(), "get".into()],
879            }],
880            None,
881            None,
882            60,
883            30,
884            false,
885        );
886
887        assert!(tool.is_operation_allowed("drive", "files", None, "list"));
888        assert!(tool.is_operation_allowed("drive", "files", None, "get"));
889        // Delete not in methods: denied
890        assert!(!tool.is_operation_allowed("drive", "files", None, "delete"));
891        // Entry has no sub_resource; call with sub_resource must not match
892        assert!(!tool.is_operation_allowed("drive", "files", Some("permissions"), "list"));
893    }
894
895    #[tokio::test]
896    async fn rejects_disallowed_operation() {
897        let tool = GoogleWorkspaceTool::new(
898            test_security(),
899            vec!["gmail".into()],
900            vec![GoogleWorkspaceAllowedOperation {
901                service: "gmail".into(),
902                resource: "users".into(),
903                sub_resource: Some("drafts".into()),
904                methods: vec!["create".into()],
905            }],
906            None,
907            None,
908            60,
909            30,
910            false,
911        );
912
913        // send is not in the allowed methods list
914        let result = tool
915            .execute(json!({
916                "service": "gmail",
917                "resource": "users",
918                "sub_resource": "drafts",
919                "method": "send"
920            }))
921            .await
922            .expect("disallowed operation should return a result");
923
924        assert!(!result.success);
925        assert!(
926            result
927                .error
928                .as_deref()
929                .unwrap_or("")
930                .contains("allowed operations list")
931        );
932    }
933
934    #[tokio::test]
935    async fn rejects_operation_with_unlisted_sub_resource() {
936        let tool = GoogleWorkspaceTool::new(
937            test_security(),
938            vec!["gmail".into()],
939            vec![GoogleWorkspaceAllowedOperation {
940                service: "gmail".into(),
941                resource: "users".into(),
942                sub_resource: Some("drafts".into()),
943                methods: vec!["create".into()],
944            }],
945            None,
946            None,
947            60,
948            30,
949            false,
950        );
951
952        // messages is not in the allowlist (only drafts is)
953        let result = tool
954            .execute(json!({
955                "service": "gmail",
956                "resource": "users",
957                "sub_resource": "messages",
958                "method": "send"
959            }))
960            .await
961            .expect("unlisted sub_resource should return a result");
962
963        assert!(!result.success);
964        assert!(
965            result
966                .error
967                .as_deref()
968                .unwrap_or("")
969                .contains("allowed operations list")
970        );
971    }
972
973    // ── cmd_args ordering ────────────────────────────────────
974
975    #[test]
976    fn cmd_args_3_segment_shape_drive() {
977        // Drive uses gws <service> <resource> <method> — no sub_resource.
978        let args = GoogleWorkspaceTool::positional_cmd_args("drive", "files", None, "list");
979        assert_eq!(args, vec!["drive", "files", "list"]);
980    }
981
982    #[test]
983    fn cmd_args_4_segment_shape_gmail() {
984        // Gmail uses gws <service> <resource> <sub_resource> <method>.
985        let args =
986            GoogleWorkspaceTool::positional_cmd_args("gmail", "users", Some("messages"), "list");
987        assert_eq!(args, vec!["gmail", "users", "messages", "list"]);
988    }
989
990    #[test]
991    fn cmd_args_sub_resource_precedes_method() {
992        // sub_resource must come before method in the positional args.
993        let args =
994            GoogleWorkspaceTool::positional_cmd_args("gmail", "users", Some("drafts"), "create");
995        let sub_idx = args.iter().position(|a| a == "drafts").unwrap();
996        let method_idx = args.iter().position(|a| a == "create").unwrap();
997        assert!(sub_idx < method_idx, "sub_resource must precede method");
998    }
999
1000    // ── denial error message ─────────────────────────────────
1001
1002    #[tokio::test]
1003    async fn denial_error_includes_sub_resource_when_present() {
1004        let tool = GoogleWorkspaceTool::new(
1005            test_security(),
1006            vec!["gmail".into()],
1007            vec![GoogleWorkspaceAllowedOperation {
1008                service: "gmail".into(),
1009                resource: "users".into(),
1010                sub_resource: Some("drafts".into()),
1011                methods: vec!["create".into()],
1012            }],
1013            None,
1014            None,
1015            60,
1016            30,
1017            false,
1018        );
1019
1020        let result = tool
1021            .execute(json!({
1022                "service": "gmail",
1023                "resource": "users",
1024                "sub_resource": "messages",
1025                "method": "send"
1026            }))
1027            .await
1028            .expect("denied operation should return a result");
1029
1030        let error = result.error.as_deref().unwrap_or("");
1031        // Error must include sub_resource so the operator can distinguish
1032        // gmail/users/messages/send from gmail/users/drafts/send.
1033        assert!(
1034            error.contains("gmail/users/messages/send"),
1035            "expected full 4-segment path in error, got: {error}"
1036        );
1037    }
1038
1039    // ── whitespace normalization ─────────────────────────────
1040
1041    #[test]
1042    fn allowed_operations_config_values_trimmed_at_construction() {
1043        let tool = GoogleWorkspaceTool::new(
1044            test_security(),
1045            vec!["gmail".into()],
1046            vec![GoogleWorkspaceAllowedOperation {
1047                service: " gmail ".into(), // leading/trailing whitespace
1048                resource: " users ".into(),
1049                sub_resource: Some(" drafts ".into()),
1050                methods: vec![" create ".into()],
1051            }],
1052            None,
1053            None,
1054            60,
1055            30,
1056            false,
1057        );
1058
1059        // After construction, stored values are trimmed and plain equality works.
1060        assert!(tool.is_operation_allowed("gmail", "users", Some("drafts"), "create"));
1061        assert!(!tool.is_operation_allowed("gmail", "users", Some(" drafts "), "create"));
1062    }
1063
1064    // ── page_limit / page_all flag building ─────────────────
1065
1066    #[test]
1067    fn pagination_page_limit_alone_appends_limit_without_page_all() {
1068        // page_limit without page_all caps page count without requesting all pages.
1069        let flags = GoogleWorkspaceTool::build_pagination_args(false, Some(5));
1070        assert!(flags.contains(&"--page-limit".to_string()));
1071        assert!(!flags.contains(&"--page-all".to_string()));
1072        let limit_idx = flags.iter().position(|f| f == "--page-limit").unwrap();
1073        assert_eq!(flags[limit_idx + 1], "5");
1074    }
1075
1076    #[test]
1077    fn pagination_page_all_without_limit_uses_default() {
1078        let flags = GoogleWorkspaceTool::build_pagination_args(true, None);
1079        assert!(flags.contains(&"--page-all".to_string()));
1080        assert!(flags.contains(&"--page-limit".to_string()));
1081        let limit_idx = flags.iter().position(|f| f == "--page-limit").unwrap();
1082        assert_eq!(flags[limit_idx + 1], "10"); // default cap
1083    }
1084
1085    #[test]
1086    fn pagination_page_all_with_limit_appends_both() {
1087        let flags = GoogleWorkspaceTool::build_pagination_args(true, Some(20));
1088        assert!(flags.contains(&"--page-all".to_string()));
1089        let limit_idx = flags.iter().position(|f| f == "--page-limit").unwrap();
1090        assert_eq!(flags[limit_idx + 1], "20");
1091    }
1092
1093    #[test]
1094    fn pagination_neither_appends_nothing() {
1095        let flags = GoogleWorkspaceTool::build_pagination_args(false, None);
1096        assert!(flags.is_empty());
1097    }
1098}