Compare commits

...

5 Commits

Author SHA1 Message Date
argenis de la rosa
603aa88f3c Merge remote-tracking branch 'origin/master' into feat/google-workspace-cli
# Conflicts:
#	src/config/mod.rs
#	src/config/schema.rs
#	src/onboard/wizard.rs
#	src/tools/cli_discovery.rs
#	src/tools/mod.rs
2026-03-17 00:05:04 -04:00
Giulio V
b4f3e4f37b fix(tools): define missing GWS_TIMEOUT_SECS constant 2026-03-15 15:58:24 +01:00
Giulio V
6b1fe960e3 feat(google-workspace): expand config with auth, rate limits, and audit settings 2026-03-15 15:58:24 +01:00
Giulio V
7a4ea4cbe9 style: fix cargo fmt + clippy violations 2026-03-15 15:58:24 +01:00
Giulio V
c1e05069ea feat(tools): add Google Workspace CLI (gws) integration
Adds GoogleWorkspaceTool for interacting with Google Drive, Sheets,
Gmail, Calendar, Docs, and other Workspace services via CLI.

- Config-gated (google_workspace.enabled)
- Service allowlist for restricted access
- Requires shell access for CLI delegation
- Input validation against shell injection
- Wrong-type rejection for all optional parameters
- Config validation for allowed_services (empty, duplicate, malformed)
- Registered in integrations registry and CLI discovery

Closes #2986
2026-03-15 15:57:21 +01:00
7 changed files with 882 additions and 7 deletions

View File

@ -11,12 +11,13 @@ pub use schema::{
ComposioConfig, Config, ConversationalAiConfig, CostConfig, CronConfig, DataRetentionConfig,
DelegateAgentConfig, DiscordConfig, DockerRuntimeConfig, EdgeTtsConfig, ElevenLabsTtsConfig,
EmbeddingRouteConfig, EstopConfig, FeishuConfig, GatewayConfig, GoogleTtsConfig,
HardwareConfig, HardwareTransport, HeartbeatConfig, HooksConfig, HttpRequestConfig,
IMessageConfig, IdentityConfig, LarkConfig, MatrixConfig, McpConfig, McpServerConfig,
McpTransport, MemoryConfig, Microsoft365Config, ModelRouteConfig, MultimodalConfig,
NextcloudTalkConfig, NodeTransportConfig, NodesConfig, NotionConfig, ObservabilityConfig,
OpenAiTtsConfig, OpenVpnTunnelConfig, OtpConfig, OtpMethod, PeripheralBoardConfig,
PeripheralsConfig, ProjectIntelConfig, ProxyConfig, ProxyScope, QdrantConfig,
GoogleWorkspaceConfig, HardwareConfig, HardwareTransport, HeartbeatConfig, HooksConfig,
HttpRequestConfig, IMessageConfig, IdentityConfig, LarkConfig, MatrixConfig, McpConfig,
McpServerConfig, McpTransport, MemoryConfig, Microsoft365Config, ModelRouteConfig,
MultimodalConfig, NextcloudTalkConfig, NodeTransportConfig, NodesConfig, NotionConfig,
ObservabilityConfig, OpenAiTtsConfig, OpenVpnTunnelConfig, OtpConfig, OtpMethod,
PeripheralBoardConfig, PeripheralsConfig, ProjectIntelConfig, ProxyConfig, ProxyScope,
QdrantConfig,
QueryClassificationConfig, ReliabilityConfig, ResourceLimitsConfig, RuntimeConfig,
SandboxBackend, SandboxConfig, SchedulerConfig, SecretsConfig, SecurityConfig,
SecurityOpsConfig, SkillsConfig, SkillsPromptInjectionMode, SlackConfig, StorageConfig,

View File

@ -260,6 +260,10 @@ pub struct Config {
#[serde(default)]
pub web_search: WebSearchConfig,
/// Google Workspace CLI (`gws`) tool configuration (`[google_workspace]`).
#[serde(default)]
pub google_workspace: GoogleWorkspaceConfig,
/// Project delivery intelligence configuration (`[project_intel]`).
#[serde(default)]
pub project_intel: ProjectIntelConfig,
@ -1839,6 +1843,94 @@ impl Default for WebSearchConfig {
}
}
// ── Google Workspace ─────────────────────────────────────────────
/// Google Workspace CLI (`gws`) tool configuration (`[google_workspace]` section).
///
/// ## Defaults
/// - `enabled`: `false` (tool is not registered unless explicitly opted-in).
/// - `allowed_services`: empty vector, which grants access to the full default
/// service set: `drive`, `sheets`, `gmail`, `calendar`, `docs`, `slides`,
/// `tasks`, `people`, `chat`, `classroom`, `forms`, `keep`, `meet`, `events`.
/// - `credentials_path`: `None` (uses default `gws` credential discovery).
/// - `default_account`: `None` (uses the `gws` active account).
/// - `rate_limit_per_minute`: `60`.
/// - `timeout_secs`: `30`.
/// - `audit_log`: `false`.
/// - `credentials_path`: `None` (uses default `gws` credential discovery).
/// - `default_account`: `None` (uses the `gws` active account).
/// - `rate_limit_per_minute`: `60`.
/// - `timeout_secs`: `30`.
/// - `audit_log`: `false`.
///
/// ## Compatibility
/// Configs that omit the `[google_workspace]` section entirely are treated as
/// `GoogleWorkspaceConfig::default()` (disabled, all defaults allowed). Adding
/// the section is purely opt-in and does not affect other config sections.
///
/// ## Rollback / Migration
/// To revert, remove the `[google_workspace]` section from the config file (or
/// set `enabled = false`). No data migration is required; the tool simply stops
/// being registered.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct GoogleWorkspaceConfig {
/// Enable the `google_workspace` tool. Default: `false`.
#[serde(default)]
pub enabled: bool,
/// Restrict which Google Workspace services the agent can access.
///
/// When empty (the default), the full default service set is allowed (see
/// struct-level docs). When non-empty, only the listed service IDs are
/// permitted. Each entry must be non-empty, lowercase alphanumeric with
/// optional underscores/hyphens, and unique.
#[serde(default)]
pub allowed_services: Vec<String>,
/// Path to service account JSON or OAuth client credentials file.
///
/// When `None`, the tool relies on the default `gws` credential discovery
/// (`gws auth login`). Set this to point at a service-account key or an
/// OAuth client-secrets JSON for headless / CI environments.
#[serde(default)]
pub credentials_path: Option<String>,
/// Default Google account email to pass to `gws --account`.
///
/// When `None`, the currently active `gws` account is used.
#[serde(default)]
pub default_account: Option<String>,
/// Maximum number of `gws` API calls allowed per minute. Default: `60`.
#[serde(default = "default_gws_rate_limit")]
pub rate_limit_per_minute: u32,
/// Command execution timeout in seconds. Default: `30`.
#[serde(default = "default_gws_timeout_secs")]
pub timeout_secs: u64,
/// Enable audit logging of every `gws` invocation (service, resource,
/// method, timestamp). Default: `false`.
#[serde(default)]
pub audit_log: bool,
}
fn default_gws_rate_limit() -> u32 {
60
}
fn default_gws_timeout_secs() -> u64 {
30
}
impl Default for GoogleWorkspaceConfig {
fn default() -> Self {
Self {
enabled: false,
allowed_services: Vec::new(),
credentials_path: None,
default_account: None,
rate_limit_per_minute: default_gws_rate_limit(),
timeout_secs: default_gws_timeout_secs(),
audit_log: false,
}
}
}
// ── Project Intelligence ────────────────────────────────────────
/// Project delivery intelligence configuration (`[project_intel]` section).
@ -5263,6 +5355,7 @@ impl Default for Config {
multimodal: MultimodalConfig::default(),
web_fetch: WebFetchConfig::default(),
web_search: WebSearchConfig::default(),
google_workspace: GoogleWorkspaceConfig::default(),
project_intel: ProjectIntelConfig::default(),
proxy: ProxyConfig::default(),
identity: IdentityConfig::default(),
@ -6421,6 +6514,28 @@ impl Config {
validate_mcp_config(&self.mcp)?;
}
// Google Workspace allowed_services validation
let mut seen_gws_services = std::collections::HashSet::new();
for (i, service) in self.google_workspace.allowed_services.iter().enumerate() {
let normalized = service.trim();
if normalized.is_empty() {
anyhow::bail!("google_workspace.allowed_services[{i}] must not be empty");
}
if !normalized
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
{
anyhow::bail!(
"google_workspace.allowed_services[{i}] contains invalid characters: {normalized}"
);
}
if !seen_gws_services.insert(normalized.to_string()) {
anyhow::bail!(
"google_workspace.allowed_services contains duplicate entry: {normalized}"
);
}
}
// Project intelligence
if self.project_intel.enabled {
let lang = &self.project_intel.default_language;
@ -6473,7 +6588,6 @@ impl Config {
if let Err(msg) = self.security.nevis.validate() {
anyhow::bail!("security.nevis: {msg}");
}
Ok(())
}
@ -7567,6 +7681,7 @@ default_temperature = 0.7
multimodal: MultimodalConfig::default(),
web_fetch: WebFetchConfig::default(),
web_search: WebSearchConfig::default(),
google_workspace: GoogleWorkspaceConfig::default(),
project_intel: ProjectIntelConfig::default(),
proxy: ProxyConfig::default(),
agent: AgentConfig::default(),
@ -7870,6 +7985,7 @@ tool_dispatcher = "xml"
multimodal: MultimodalConfig::default(),
web_fetch: WebFetchConfig::default(),
web_search: WebSearchConfig::default(),
google_workspace: GoogleWorkspaceConfig::default(),
project_intel: ProjectIntelConfig::default(),
proxy: ProxyConfig::default(),
agent: AgentConfig::default(),

View File

@ -509,6 +509,18 @@ pub fn all_integrations() -> Vec<IntegrationEntry> {
},
},
// ── Productivity ────────────────────────────────────────
IntegrationEntry {
name: "Google Workspace",
description: "Drive, Gmail, Calendar, Sheets, Docs via gws CLI",
category: IntegrationCategory::Productivity,
status_fn: |c| {
if c.google_workspace.enabled {
IntegrationStatus::Active
} else {
IntegrationStatus::Available
}
},
},
IntegrationEntry {
name: "GitHub",
description: "Code, issues, PRs",

View File

@ -172,6 +172,7 @@ pub async fn run_wizard(force: bool) -> Result<Config> {
multimodal: crate::config::MultimodalConfig::default(),
web_fetch: crate::config::WebFetchConfig::default(),
web_search: crate::config::WebSearchConfig::default(),
google_workspace: crate::config::GoogleWorkspaceConfig::default(),
project_intel: crate::config::ProjectIntelConfig::default(),
proxy: crate::config::ProxyConfig::default(),
identity: crate::config::IdentityConfig::default(),
@ -542,6 +543,7 @@ async fn run_quick_setup_with_home(
multimodal: crate::config::MultimodalConfig::default(),
web_fetch: crate::config::WebFetchConfig::default(),
web_search: crate::config::WebSearchConfig::default(),
google_workspace: crate::config::GoogleWorkspaceConfig::default(),
project_intel: crate::config::ProjectIntelConfig::default(),
proxy: crate::config::ProxyConfig::default(),
identity: crate::config::IdentityConfig::default(),

View File

@ -12,6 +12,7 @@ pub enum CliCategory {
Container,
Build,
Cloud,
Productivity,
AiAgent,
}
@ -24,6 +25,7 @@ impl std::fmt::Display for CliCategory {
Self::Container => write!(f, "Container"),
Self::Build => write!(f, "Build"),
Self::Cloud => write!(f, "Cloud"),
Self::Productivity => write!(f, "Productivity"),
Self::AiAgent => write!(f, "AI Agent"),
}
}
@ -106,6 +108,11 @@ const KNOWN_CLIS: &[KnownCli] = &[
version_args: &["--version"],
category: CliCategory::Language,
},
KnownCli {
name: "gws",
version_args: &["--version"],
category: CliCategory::Productivity,
},
KnownCli {
name: "claude",
version_args: &["--version"],
@ -252,6 +259,7 @@ mod tests {
assert_eq!(CliCategory::Container.to_string(), "Container");
assert_eq!(CliCategory::Build.to_string(), "Build");
assert_eq!(CliCategory::Cloud.to_string(), "Cloud");
assert_eq!(CliCategory::Productivity.to_string(), "Productivity");
assert_eq!(CliCategory::AiAgent.to_string(), "AI Agent");
}
}

View File

@ -0,0 +1,716 @@
use super::traits::{Tool, ToolResult};
use crate::security::SecurityPolicy;
use async_trait::async_trait;
use serde_json::json;
use std::sync::Arc;
use std::time::Duration;
/// Default `gws` command execution time before kill (overridden by config).
const DEFAULT_GWS_TIMEOUT_SECS: u64 = 30;
/// Maximum output size in bytes (1MB).
const MAX_OUTPUT_BYTES: usize = 1_048_576;
/// Allowed Google Workspace services that gws can target.
const DEFAULT_ALLOWED_SERVICES: &[&str] = &[
"drive",
"sheets",
"gmail",
"calendar",
"docs",
"slides",
"tasks",
"people",
"chat",
"classroom",
"forms",
"keep",
"meet",
"events",
];
/// Google Workspace CLI (`gws`) integration tool.
///
/// Wraps the `gws` CLI binary to give the agent structured access to
/// Google Workspace services (Drive, Gmail, Calendar, Sheets, etc.).
/// Requires `gws` to be installed and authenticated (`gws auth login`).
pub struct GoogleWorkspaceTool {
security: Arc<SecurityPolicy>,
allowed_services: Vec<String>,
credentials_path: Option<String>,
default_account: Option<String>,
rate_limit_per_minute: u32,
timeout_secs: u64,
audit_log: bool,
}
impl GoogleWorkspaceTool {
/// Create a new `GoogleWorkspaceTool`.
///
/// If `allowed_services` is empty, the default service set is used.
pub fn new(
security: Arc<SecurityPolicy>,
allowed_services: Vec<String>,
credentials_path: Option<String>,
default_account: Option<String>,
rate_limit_per_minute: u32,
timeout_secs: u64,
audit_log: bool,
) -> Self {
let services = if allowed_services.is_empty() {
DEFAULT_ALLOWED_SERVICES
.iter()
.map(|s| (*s).to_string())
.collect()
} else {
allowed_services
};
Self {
security,
allowed_services: services,
credentials_path,
default_account,
rate_limit_per_minute,
timeout_secs,
audit_log,
}
}
}
#[async_trait]
impl Tool for GoogleWorkspaceTool {
fn name(&self) -> &str {
"google_workspace"
}
fn description(&self) -> &str {
"Interact with Google Workspace services (Drive, Gmail, Calendar, Sheets, Docs, etc.) \
via the gws CLI. Requires gws to be installed and authenticated."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"service": {
"type": "string",
"description": "Google Workspace service (e.g. drive, gmail, calendar, sheets, docs, slides, tasks, people, chat, forms, keep, meet)"
},
"resource": {
"type": "string",
"description": "Service resource (e.g. files, messages, events, spreadsheets)"
},
"method": {
"type": "string",
"description": "Method to call on the resource (e.g. list, get, create, update, delete)"
},
"sub_resource": {
"type": "string",
"description": "Optional sub-resource for nested operations"
},
"params": {
"type": "object",
"description": "URL/query parameters as key-value pairs (passed as --params JSON)"
},
"body": {
"type": "object",
"description": "Request body for POST/PATCH/PUT operations (passed as --json JSON)"
},
"format": {
"type": "string",
"enum": ["json", "table", "yaml", "csv"],
"description": "Output format (default: json)"
},
"page_all": {
"type": "boolean",
"description": "Auto-paginate through all results"
},
"page_limit": {
"type": "integer",
"description": "Max pages to fetch when using page_all (default: 10)"
}
},
"required": ["service", "resource", "method"]
})
}
/// Execute a Google Workspace CLI command with input validation and security enforcement.
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
let service = args
.get("service")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'service' parameter"))?;
let resource = args
.get("resource")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'resource' parameter"))?;
let method = args
.get("method")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'method' parameter"))?;
// Security checks
if self.security.is_rate_limited() {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some("Rate limit exceeded: too many actions in the last hour".into()),
});
}
// Validate service is in the allowlist
if !self.allowed_services.iter().any(|s| s == service) {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!(
"Service '{service}' is not in the allowed services list. \
Allowed: {}",
self.allowed_services.join(", ")
)),
});
}
// Validate inputs contain no shell metacharacters
for (label, value) in [
("service", service),
("resource", resource),
("method", method),
] {
if !value
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!(
"Invalid characters in '{label}': only alphanumeric, underscore, and hyphen are allowed"
)),
});
}
}
// Build the gws command — validate all optional fields before consuming budget
let mut cmd_args: Vec<String> = vec![service.to_string(), resource.to_string()];
if let Some(sub_resource_value) = args.get("sub_resource") {
let sub_resource = match sub_resource_value.as_str() {
Some(s) => s,
None => {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some("'sub_resource' must be a string".into()),
})
}
};
if !sub_resource
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(
"Invalid characters in 'sub_resource': only alphanumeric, underscore, and hyphen are allowed"
.into(),
),
});
}
cmd_args.push(sub_resource.to_string());
}
cmd_args.push(method.to_string());
if let Some(params) = args.get("params") {
if !params.is_object() {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some("'params' must be an object".into()),
});
}
cmd_args.push("--params".into());
cmd_args.push(params.to_string());
}
if let Some(body) = args.get("body") {
if !body.is_object() {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some("'body' must be an object".into()),
});
}
cmd_args.push("--json".into());
cmd_args.push(body.to_string());
}
if let Some(format_value) = args.get("format") {
let format = match format_value.as_str() {
Some(s) => s,
None => {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some("'format' must be a string".into()),
})
}
};
match format {
"json" | "table" | "yaml" | "csv" => {
cmd_args.push("--format".into());
cmd_args.push(format.to_string());
}
_ => {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!(
"Invalid format '{format}': must be json, table, yaml, or csv"
)),
});
}
}
}
let page_all = match args.get("page_all") {
Some(v) => match v.as_bool() {
Some(b) => b,
None => {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some("'page_all' must be a boolean".into()),
})
}
},
None => false,
};
if page_all {
cmd_args.push("--page-all".into());
}
let page_limit = match args.get("page_limit") {
Some(v) => match v.as_u64() {
Some(n) => Some(n),
None => {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some("'page_limit' must be a non-negative integer".into()),
})
}
},
None => None,
};
if page_all || page_limit.is_some() {
cmd_args.push("--page-limit".into());
cmd_args.push(page_limit.unwrap_or(10).to_string());
}
// Charge action budget only after all validation passes
if !self.security.record_action() {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some("Rate limit exceeded: action budget exhausted".into()),
});
}
let mut cmd = tokio::process::Command::new("gws");
cmd.args(&cmd_args);
cmd.env_clear();
// gws needs PATH to find itself and HOME/APPDATA for credential storage
for key in &["PATH", "HOME", "APPDATA", "USERPROFILE", "LANG", "TERM"] {
if let Ok(val) = std::env::var(key) {
cmd.env(key, val);
}
}
// Apply credential path if configured
if let Some(ref creds) = self.credentials_path {
cmd.env("GOOGLE_APPLICATION_CREDENTIALS", creds);
}
// Apply default account if configured
if let Some(ref account) = self.default_account {
cmd.args(["--account", account]);
}
if self.audit_log {
tracing::info!(
tool = "google_workspace",
service = service,
resource = resource,
method = method,
"gws audit: executing API call"
);
}
// Apply credential path if configured
if let Some(ref creds) = self.credentials_path {
cmd.env("GOOGLE_APPLICATION_CREDENTIALS", creds);
}
// Apply default account if configured
if let Some(ref account) = self.default_account {
cmd.args(["--account", account]);
}
if self.audit_log {
tracing::info!(
tool = "google_workspace",
service = service,
resource = resource,
method = method,
"gws audit: executing API call"
);
}
let result =
tokio::time::timeout(Duration::from_secs(self.timeout_secs), cmd.output()).await;
match result {
Ok(Ok(output)) => {
let mut stdout = String::from_utf8_lossy(&output.stdout).to_string();
let mut stderr = String::from_utf8_lossy(&output.stderr).to_string();
if stdout.len() > MAX_OUTPUT_BYTES {
// Find a valid char boundary at or before MAX_OUTPUT_BYTES
let mut boundary = MAX_OUTPUT_BYTES;
while boundary > 0 && !stdout.is_char_boundary(boundary) {
boundary -= 1;
}
stdout.truncate(boundary);
stdout.push_str("\n... [output truncated at 1MB]");
}
if stderr.len() > MAX_OUTPUT_BYTES {
let mut boundary = MAX_OUTPUT_BYTES;
while boundary > 0 && !stderr.is_char_boundary(boundary) {
boundary -= 1;
}
stderr.truncate(boundary);
stderr.push_str("\n... [stderr truncated at 1MB]");
}
Ok(ToolResult {
success: output.status.success(),
output: stdout,
error: if stderr.is_empty() {
None
} else {
Some(stderr)
},
})
}
Ok(Err(e)) => Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!(
"Failed to execute gws: {e}. Is gws installed? Run: npm install -g @googleworkspace/cli"
)),
}),
Err(_) => Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!(
"gws command timed out after {}s and was killed", self.timeout_secs
)),
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::security::{AutonomyLevel, SecurityPolicy};
fn test_security() -> Arc<SecurityPolicy> {
Arc::new(SecurityPolicy {
autonomy: AutonomyLevel::Full,
workspace_dir: std::env::temp_dir(),
..SecurityPolicy::default()
})
}
#[test]
fn tool_name() {
let tool = GoogleWorkspaceTool::new(test_security(), vec![], None, None, 60, 30, false);
assert_eq!(tool.name(), "google_workspace");
}
#[test]
fn tool_description_non_empty() {
let tool = GoogleWorkspaceTool::new(test_security(), vec![], None, None, 60, 30, false);
assert!(!tool.description().is_empty());
}
#[test]
fn tool_schema_has_required_fields() {
let tool = GoogleWorkspaceTool::new(test_security(), vec![], None, None, 60, 30, false);
let schema = tool.parameters_schema();
assert!(schema["properties"]["service"].is_object());
assert!(schema["properties"]["resource"].is_object());
assert!(schema["properties"]["method"].is_object());
let required = schema["required"]
.as_array()
.expect("required should be an array");
assert!(required.contains(&json!("service")));
assert!(required.contains(&json!("resource")));
assert!(required.contains(&json!("method")));
}
#[test]
fn default_allowed_services_populated() {
let tool = GoogleWorkspaceTool::new(test_security(), vec![], None, None, 60, 30, false);
assert!(!tool.allowed_services.is_empty());
assert!(tool.allowed_services.contains(&"drive".to_string()));
assert!(tool.allowed_services.contains(&"gmail".to_string()));
assert!(tool.allowed_services.contains(&"calendar".to_string()));
}
#[test]
fn custom_allowed_services_override_defaults() {
let tool = GoogleWorkspaceTool::new(
test_security(),
vec!["drive".into(), "sheets".into()],
None,
None,
60,
30,
false,
);
assert_eq!(tool.allowed_services.len(), 2);
assert!(tool.allowed_services.contains(&"drive".to_string()));
assert!(tool.allowed_services.contains(&"sheets".to_string()));
assert!(!tool.allowed_services.contains(&"gmail".to_string()));
}
#[tokio::test]
async fn rejects_disallowed_service() {
let tool = GoogleWorkspaceTool::new(
test_security(),
vec!["drive".into()],
None,
None,
60,
30,
false,
);
let result = tool
.execute(json!({
"service": "gmail",
"resource": "users",
"method": "list"
}))
.await
.expect("disallowed service should return a result");
assert!(!result.success);
assert!(result
.error
.as_deref()
.unwrap_or("")
.contains("not in the allowed"));
}
#[tokio::test]
async fn rejects_shell_injection_in_service() {
let tool = GoogleWorkspaceTool::new(
test_security(),
vec!["drive; rm -rf /".into()],
None,
None,
60,
30,
false,
);
let result = tool
.execute(json!({
"service": "drive; rm -rf /",
"resource": "files",
"method": "list"
}))
.await
.expect("shell injection should return a result");
assert!(!result.success);
assert!(result
.error
.as_deref()
.unwrap_or("")
.contains("Invalid characters"));
}
#[tokio::test]
async fn rejects_shell_injection_in_resource() {
let tool = GoogleWorkspaceTool::new(test_security(), vec![], None, None, 60, 30, false);
let result = tool
.execute(json!({
"service": "drive",
"resource": "files$(whoami)",
"method": "list"
}))
.await
.expect("shell injection should return a result");
assert!(!result.success);
assert!(result
.error
.as_deref()
.unwrap_or("")
.contains("Invalid characters"));
}
#[tokio::test]
async fn rejects_invalid_format() {
let tool = GoogleWorkspaceTool::new(test_security(), vec![], None, None, 60, 30, false);
let result = tool
.execute(json!({
"service": "drive",
"resource": "files",
"method": "list",
"format": "xml"
}))
.await
.expect("invalid format should return a result");
assert!(!result.success);
assert!(result
.error
.as_deref()
.unwrap_or("")
.contains("Invalid format"));
}
#[tokio::test]
async fn rejects_wrong_type_params() {
let tool = GoogleWorkspaceTool::new(test_security(), vec![], None, None, 60, 30, false);
let result = tool
.execute(json!({
"service": "drive",
"resource": "files",
"method": "list",
"params": "not_an_object"
}))
.await
.expect("wrong type params should return a result");
assert!(!result.success);
assert!(result
.error
.as_deref()
.unwrap_or("")
.contains("'params' must be an object"));
}
#[tokio::test]
async fn rejects_wrong_type_body() {
let tool = GoogleWorkspaceTool::new(test_security(), vec![], None, None, 60, 30, false);
let result = tool
.execute(json!({
"service": "drive",
"resource": "files",
"method": "create",
"body": "not_an_object"
}))
.await
.expect("wrong type body should return a result");
assert!(!result.success);
assert!(result
.error
.as_deref()
.unwrap_or("")
.contains("'body' must be an object"));
}
#[tokio::test]
async fn rejects_wrong_type_page_all() {
let tool = GoogleWorkspaceTool::new(test_security(), vec![], None, None, 60, 30, false);
let result = tool
.execute(json!({
"service": "drive",
"resource": "files",
"method": "list",
"page_all": "yes"
}))
.await
.expect("wrong type page_all should return a result");
assert!(!result.success);
assert!(result
.error
.as_deref()
.unwrap_or("")
.contains("'page_all' must be a boolean"));
}
#[tokio::test]
async fn rejects_wrong_type_page_limit() {
let tool = GoogleWorkspaceTool::new(test_security(), vec![], None, None, 60, 30, false);
let result = tool
.execute(json!({
"service": "drive",
"resource": "files",
"method": "list",
"page_limit": "ten"
}))
.await
.expect("wrong type page_limit should return a result");
assert!(!result.success);
assert!(result
.error
.as_deref()
.unwrap_or("")
.contains("'page_limit' must be a non-negative integer"));
}
#[tokio::test]
async fn rejects_wrong_type_sub_resource() {
let tool = GoogleWorkspaceTool::new(test_security(), vec![], None, None, 60, 30, false);
let result = tool
.execute(json!({
"service": "drive",
"resource": "files",
"method": "list",
"sub_resource": 123
}))
.await
.expect("wrong type sub_resource should return a result");
assert!(!result.success);
assert!(result
.error
.as_deref()
.unwrap_or("")
.contains("'sub_resource' must be a string"));
}
#[tokio::test]
async fn missing_required_param_returns_error() {
let tool = GoogleWorkspaceTool::new(test_security(), vec![], None, None, 60, 30, false);
let result = tool.execute(json!({"service": "drive"})).await;
assert!(result.is_err());
}
#[tokio::test]
async fn rate_limited_returns_error() {
let security = Arc::new(SecurityPolicy {
autonomy: AutonomyLevel::Full,
max_actions_per_hour: 0,
workspace_dir: std::env::temp_dir(),
..SecurityPolicy::default()
});
let tool = GoogleWorkspaceTool::new(security, vec![], None, None, 60, 30, false);
let result = tool
.execute(json!({
"service": "drive",
"resource": "files",
"method": "list"
}))
.await
.expect("rate-limited should return a result");
assert!(!result.success);
assert!(result.error.as_deref().unwrap_or("").contains("Rate limit"));
}
#[test]
fn gws_timeout_is_reasonable() {
assert_eq!(DEFAULT_GWS_TIMEOUT_SECS, 30);
}
}

View File

@ -37,6 +37,7 @@ pub mod file_read;
pub mod file_write;
pub mod git_operations;
pub mod glob_search;
pub mod google_workspace;
#[cfg(feature = "hardware")]
pub mod hardware_board_info;
#[cfg(feature = "hardware")]
@ -96,6 +97,7 @@ pub use file_read::FileReadTool;
pub use file_write::FileWriteTool;
pub use git_operations::GitOperationsTool;
pub use glob_search::GlobSearchTool;
pub use google_workspace::GoogleWorkspaceTool;
#[cfg(feature = "hardware")]
pub use hardware_board_info::HardwareBoardInfoTool;
#[cfg(feature = "hardware")]
@ -379,6 +381,23 @@ pub fn all_tools_with_runtime(
)));
}
// Google Workspace CLI (gws) integration — requires shell access
if root_config.google_workspace.enabled && has_shell_access {
tool_arcs.push(Arc::new(GoogleWorkspaceTool::new(
security.clone(),
root_config.google_workspace.allowed_services.clone(),
root_config.google_workspace.credentials_path.clone(),
root_config.google_workspace.default_account.clone(),
root_config.google_workspace.rate_limit_per_minute,
root_config.google_workspace.timeout_secs,
root_config.google_workspace.audit_log,
)));
} else if root_config.google_workspace.enabled {
tracing::warn!(
"google_workspace: skipped registration because shell access is unavailable"
);
}
// Notion API tool (conditionally registered)
if root_config.notion.enabled {
let notion_api_key = if root_config.notion.api_key.trim().is_empty() {
@ -431,6 +450,7 @@ pub fn all_tools_with_runtime(
if root_config.cloud_ops.enabled {
tool_arcs.push(Arc::new(CloudOpsTool::new(root_config.cloud_ops.clone())));
tool_arcs.push(Arc::new(CloudPatternsTool::new()));
}
// PDF extraction (feature-gated at compile time via rag-pdf)