Compare commits
5 Commits
master
...
feat/googl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
603aa88f3c | ||
|
|
b4f3e4f37b | ||
|
|
6b1fe960e3 | ||
|
|
7a4ea4cbe9 | ||
|
|
c1e05069ea |
@ -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,
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
716
src/tools/google_workspace.rs
Normal file
716
src/tools/google_workspace.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user