Skip to main content

construct/tools/
google_workspace.rs

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