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#[cfg(test)]
11const DEFAULT_GWS_TIMEOUT_SECS: u64 = 30;
12const MAX_OUTPUT_BYTES: usize = 1_048_576;
14
15use zeroclaw_config::schema::DEFAULT_GWS_SERVICES;
16
17pub 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)] rate_limit_per_minute: u32,
30 timeout_secs: u64,
31 audit_log: bool,
32}
33
34impl GoogleWorkspaceTool {
35 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 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 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 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 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 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 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 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 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 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 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 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 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 if let Some(ref creds) = self.credentials_path {
428 cmd.env("GOOGLE_APPLICATION_CREDENTIALS", creds);
429 }
430
431 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 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 #[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 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 assert!(tool.is_operation_allowed("gmail", "users", Some("drafts"), "create"));
860 assert!(tool.is_operation_allowed("gmail", "users", Some("drafts"), "update"));
861 assert!(!tool.is_operation_allowed("gmail", "users", Some("drafts"), "send"));
863 assert!(!tool.is_operation_allowed("gmail", "users", Some("messages"), "list"));
865 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 assert!(!tool.is_operation_allowed("drive", "files", None, "delete"));
891 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 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 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 #[test]
976 fn cmd_args_3_segment_shape_drive() {
977 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 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 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 #[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 assert!(
1034 error.contains("gmail/users/messages/send"),
1035 "expected full 4-segment path in error, got: {error}"
1036 );
1037 }
1038
1039 #[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(), 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 assert!(tool.is_operation_allowed("gmail", "users", Some("drafts"), "create"));
1061 assert!(!tool.is_operation_allowed("gmail", "users", Some(" drafts "), "create"));
1062 }
1063
1064 #[test]
1067 fn pagination_page_limit_alone_appends_limit_without_page_all() {
1068 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"); }
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}