zeroclaw/src/tools/mod.rs
Nim G bbd2556861
feat(tool): google_workspace operation-level allowlist (#4010)
* feat(config): add google workspace operation allowlists

* docs(superpowers): link google workspace operation inventory sources

* docs(superpowers): verify starter operation examples

* fix(google_workspace): remove duplicate credential/audit blocks, fix trim in allowlist check, add duplicate-methods test

- Remove the duplicated credentials_path, default_account, and audit_log
  blocks that were copy-pasted into execute() — they were idempotent but
  misleading and would double-append --account args on every call.
- Trim stored service/resource/method values in is_operation_allowed() to
  match the trim applied during Config::validate(), preventing a mismatch
  where a config entry with surrounding whitespace would pass validation but
  never match at runtime.
- Add google_workspace_allowed_operations_reject_duplicate_methods_within_entry
  test to cover the duplicate-method validation path that was implemented but
  untested.

* fix(google_workspace): close sub_resource bypass, trim allowed_services at runtime, mark spec implemented

- HIGH: extract and validate sub_resource before the allowlist check;
  is_operation_allowed() now accepts Option<&str> for sub_resource and
  returns false (fail-closed) when allowed_operations is non-empty and
  a sub_resource is present — prevents nested gws calls such as
  `drive/files/permissions/list` from slipping past a 3-segment policy
- MEDIUM: runtime allowed_services check now uses s.trim() == service,
  matching the trim() applied during config validation
- LOW: spec status updated to Implemented; stale "does not currently
  support method-level allowlists" line removed
- Added test: operation_allowlist_rejects_sub_resource_when_operations_configured

* docs(google_workspace): document sub_resource limitation and add config-reference entries

Spec updates (superpowers/specs):
- Semantics section: note that sub_resource calls are denied fail-closed when
  allowed_operations is configured
- Mental model: show both 3-segment and 4-segment gws command shapes; explain
  that 4-segment commands are unsupported with allowed_operations in this version
- Runtime enforcement: correct the validation order to match the implementation
  (sub_resource extracted before allowlist check, budget charged last)
- New section: Sub-Resource Limitation — documents impact, operator workaround,
  and confirms the deny is intentional for this slice
- Follow-On Work: add sub_resource config model extension as item 1

Config reference updates (all three locales):
- Add [google_workspace] section with top-level keys, [[allowed_operations]]
  sub-table, sub-resource limitation note, and TOML example

* fix(docs): add classroom and events to allowed_services list in all config-reference locales

* feat(google_workspace): extend allowed_operations to support sub_resource for 4-segment gws commands

All Gmail operations use gws gmail users <sub_resource> <method>, not the flat
3-segment shape. Without sub_resource support in allowed_operations, Gmail could
not be scoped at all, making the email-assistant use case impossible.

Config model:
- Add optional sub_resource field to GoogleWorkspaceAllowedOperation
- An entry without sub_resource matches 3-segment calls (Drive, Calendar, etc.)
- An entry with sub_resource matches only calls with that exact sub_resource value
- Duplicate detection updated to (service, resource, sub_resource) key

Runtime:
- Remove blanket sub_resource deny; is_operation_allowed now matches on all four
  dimensions including the optional sub_resource

Tests:
- Add operation_allowlist_matches_gmail_sub_resource_shape
- Add operation_allowlist_matches_drive_3_segment_shape
- Add rejects_operation_with_unlisted_sub_resource
- Add google_workspace_allowed_operations_allow_same_resource_different_sub_resource
- Add google_workspace_allowed_operations_reject_invalid_sub_resource_characters
- Add google_workspace_allowed_operations_deserialize_without_sub_resource
- Update all existing tests to use correct gws command shapes

Docs:
- Spec: correct Gmail examples throughout; remove Sub-Resource Limitation section;
  update data model, validation rules, example use case, and follow-on work
- Config-reference (en, vi, zh-CN): add sub_resource field to allowed_operations
  table; update Gmail examples to correct 4-segment shapes

Platform:
- email-assistant SKILL.md: update allowed_operations paths to gmail/users/* shape

* fix(google_workspace): add classroom and events to service parameter schema description

* fix(google_workspace): cross-validate allowed_operations service against allowed_services

When allowed_services is explicitly configured, each allowed_operations entry's
service must appear in that list. An entry that can never match at runtime is a
misconfigured policy: it looks valid but silently produces a narrower scope than
the operator intended. Validation now rejects it with a clear error message.

Scope: only applies when allowed_services is non-empty. When it is empty, the tool
uses a built-in default list defined in the tool layer; the validator cannot
enumerate that list without duplicating the constant, so the cross-check is skipped.

Also:
- Update allowed_operations field doc-comment from 3-part (service, resource, method)
  to 4-part (service, resource, sub_resource, method) model
- Soften Gmail sub_resource "required" language in config-reference (en, vi, zh-CN)
  from a validation requirement to a runtime matching requirement — the validator
  does not and should not hardcode API shape knowledge for individual services
- Add tests: rejects operation service not in allowed_services; skips cross-check
  when allowed_services is empty

* fix(google_workspace): cross-validate allowed_operations.service against effective service set

When allowed_services is empty the validator was silently skipping the
service cross-check, allowing impossible configs like an unlisted service
in allowed_operations to pass validation and only fail at runtime.

Move DEFAULT_GWS_SERVICES from the tool layer (google_workspace.rs) into
schema.rs so the validator can use it unconditionally. When allowed_services
is explicitly set, validate against that set; when empty, fall back to
DEFAULT_GWS_SERVICES. Remove the now-incorrect "skips cross-check when empty"
test and add two replacement tests: one confirming a valid default service
passes, one confirming an unknown service is rejected even with empty
allowed_services.

* fix(google_workspace): update test assertion for new error message wording

* docs(google_workspace): fix stale 3-segment gmail example in TDD plan

* fix(google_workspace): address adversarial review round 4 findings

- Error message for denied operations now includes sub_resource when
  present, so gmail/users/messages/send and gmail/users/drafts/send
  produce distinct, debuggable errors.
- Audit log now records sub_resource, completing the trail for 4-segment
  Gmail operations.
- Normalize (trim) allowed_services and allowed_operations fields at
  construction time in new(). Runtime comparisons now use plain equality
  instead of .trim() on every call, removing the latent defect where a
  future code path could forget to trim and silently fail to match.
- Unify runtime character validation with schema validation: sub_resource
  and service/resource/method checks now both require lowercase alphanumeric
  plus underscore and hyphen, matching the validator's character set.
- Add positional_cmd_args() test helper and tests verifying 3-segment
  (Drive) and 4-segment (Gmail) argument ordering.
- Add test confirming page_limit without page_all passes validation.
- Add test confirming whitespace in config values is normalized at
  construction, not deferred to comparison time.
- Fix spec Runtime Enforcement section to reflect actual code order.

* fix(google_workspace): wire production helpers to close test coverage gaps

- Remove #[cfg(test)] from positional_cmd_args; execute() now calls the
  same function the arg-ordering tests exercise, so a drift in the real
  command-building path is caught by the existing tests.
- Extract build_pagination_args(page_all, page_limit) as a production
  method used by execute(). Replace the brittle page_limit_without_page_all
  test (which relied on environment-specific execution failure wording)
  with four direct assertions on build_pagination_args covering all
  page_all/page_limit combinations.

---------

Co-authored-by: argenis de la rosa <theonlyhennygod@gmail.com>
2026-03-20 11:46:22 -04:00

1107 lines
38 KiB
Rust

//! Tool subsystem for agent-callable capabilities.
//!
//! This module implements the tool execution surface exposed to the LLM during
//! agentic loops. Each tool implements the [`Tool`] trait defined in [`traits`],
//! which requires a name, description, JSON parameter schema, and an async
//! `execute` method returning a structured [`ToolResult`].
//!
//! Tools are assembled into registries by [`default_tools`] (shell, file read/write)
//! and [`all_tools`] (full set including memory, browser, cron, HTTP, delegation,
//! and optional integrations). Security policy enforcement is injected via
//! [`SecurityPolicy`](crate::security::SecurityPolicy) at construction time.
//!
//! # Extension
//!
//! To add a new tool, implement [`Tool`] in a new submodule and register it in
//! [`all_tools_with_runtime`]. See `AGENTS.md` §7.3 for the full change playbook.
pub mod backup_tool;
pub mod browser;
pub mod browser_delegate;
pub mod browser_open;
pub mod calculator;
pub mod cli_discovery;
pub mod cloud_ops;
pub mod cloud_patterns;
pub mod composio;
pub mod content_search;
pub mod cron_add;
pub mod cron_list;
pub mod cron_remove;
pub mod cron_run;
pub mod cron_runs;
pub mod cron_update;
pub mod data_management;
pub mod delegate;
pub mod file_edit;
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")]
pub mod hardware_memory_map;
#[cfg(feature = "hardware")]
pub mod hardware_memory_read;
pub mod http_request;
pub mod image_info;
pub mod jira_tool;
pub mod knowledge_tool;
pub mod linkedin;
pub mod linkedin_client;
pub mod mcp_client;
pub mod mcp_deferred;
pub mod mcp_protocol;
pub mod mcp_tool;
pub mod mcp_transport;
pub mod memory_forget;
pub mod memory_recall;
pub mod memory_store;
pub mod microsoft365;
pub mod model_routing_config;
pub mod model_switch;
pub mod node_tool;
pub mod notion_tool;
pub mod pdf_read;
pub mod project_intel;
pub mod proxy_config;
pub mod pushover;
pub mod read_skill;
pub mod report_templates;
pub mod schedule;
pub mod schema;
pub mod screenshot;
pub mod security_ops;
pub mod shell;
pub mod swarm;
pub mod text_browser;
pub mod tool_search;
pub mod traits;
pub mod web_fetch;
pub mod web_search_tool;
pub mod workspace_tool;
pub use backup_tool::BackupTool;
pub use browser::{BrowserTool, ComputerUseConfig};
#[allow(unused_imports)]
pub use browser_delegate::{BrowserDelegateConfig, BrowserDelegateTool};
pub use browser_open::BrowserOpenTool;
pub use calculator::CalculatorTool;
pub use cloud_ops::CloudOpsTool;
pub use cloud_patterns::CloudPatternsTool;
pub use composio::ComposioTool;
pub use content_search::ContentSearchTool;
pub use cron_add::CronAddTool;
pub use cron_list::CronListTool;
pub use cron_remove::CronRemoveTool;
pub use cron_run::CronRunTool;
pub use cron_runs::CronRunsTool;
pub use cron_update::CronUpdateTool;
pub use data_management::DataManagementTool;
pub use delegate::DelegateTool;
pub use file_edit::FileEditTool;
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")]
pub use hardware_memory_map::HardwareMemoryMapTool;
#[cfg(feature = "hardware")]
pub use hardware_memory_read::HardwareMemoryReadTool;
pub use http_request::HttpRequestTool;
pub use image_info::ImageInfoTool;
pub use jira_tool::JiraTool;
pub use knowledge_tool::KnowledgeTool;
pub use linkedin::LinkedInTool;
pub use mcp_client::McpRegistry;
pub use mcp_deferred::{ActivatedToolSet, DeferredMcpToolSet};
pub use mcp_tool::McpToolWrapper;
pub use memory_forget::MemoryForgetTool;
pub use memory_recall::MemoryRecallTool;
pub use memory_store::MemoryStoreTool;
pub use microsoft365::Microsoft365Tool;
pub use model_routing_config::ModelRoutingConfigTool;
pub use model_switch::ModelSwitchTool;
#[allow(unused_imports)]
pub use node_tool::NodeTool;
pub use notion_tool::NotionTool;
pub use pdf_read::PdfReadTool;
pub use project_intel::ProjectIntelTool;
pub use proxy_config::ProxyConfigTool;
pub use pushover::PushoverTool;
pub use read_skill::ReadSkillTool;
pub use schedule::ScheduleTool;
#[allow(unused_imports)]
pub use schema::{CleaningStrategy, SchemaCleanr};
pub use screenshot::ScreenshotTool;
pub use security_ops::SecurityOpsTool;
pub use shell::ShellTool;
pub use swarm::SwarmTool;
pub use text_browser::TextBrowserTool;
pub use tool_search::ToolSearchTool;
pub use traits::Tool;
#[allow(unused_imports)]
pub use traits::{ToolResult, ToolSpec};
pub use web_fetch::WebFetchTool;
pub use web_search_tool::WebSearchTool;
pub use workspace_tool::WorkspaceTool;
use crate::config::{Config, DelegateAgentConfig};
use crate::memory::Memory;
use crate::runtime::{NativeRuntime, RuntimeAdapter};
use crate::security::{create_sandbox, SecurityPolicy};
use async_trait::async_trait;
use parking_lot::RwLock;
use std::collections::HashMap;
use std::sync::Arc;
/// Shared handle to the delegate tool's parent-tools list.
/// Callers can push additional tools (e.g. MCP wrappers) after construction.
pub type DelegateParentToolsHandle = Arc<RwLock<Vec<Arc<dyn Tool>>>>;
/// Thin wrapper that makes an `Arc<dyn Tool>` usable as `Box<dyn Tool>`.
pub struct ArcToolRef(pub Arc<dyn Tool>);
#[async_trait]
impl Tool for ArcToolRef {
fn name(&self) -> &str {
self.0.name()
}
fn description(&self) -> &str {
self.0.description()
}
fn parameters_schema(&self) -> serde_json::Value {
self.0.parameters_schema()
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
self.0.execute(args).await
}
}
#[derive(Clone)]
struct ArcDelegatingTool {
inner: Arc<dyn Tool>,
}
impl ArcDelegatingTool {
fn boxed(inner: Arc<dyn Tool>) -> Box<dyn Tool> {
Box::new(Self { inner })
}
}
#[async_trait]
impl Tool for ArcDelegatingTool {
fn name(&self) -> &str {
self.inner.name()
}
fn description(&self) -> &str {
self.inner.description()
}
fn parameters_schema(&self) -> serde_json::Value {
self.inner.parameters_schema()
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
self.inner.execute(args).await
}
}
fn boxed_registry_from_arcs(tools: Vec<Arc<dyn Tool>>) -> Vec<Box<dyn Tool>> {
tools.into_iter().map(ArcDelegatingTool::boxed).collect()
}
/// Create the default tool registry
pub fn default_tools(security: Arc<SecurityPolicy>) -> Vec<Box<dyn Tool>> {
default_tools_with_runtime(security, Arc::new(NativeRuntime::new()))
}
/// Create the default tool registry with explicit runtime adapter.
pub fn default_tools_with_runtime(
security: Arc<SecurityPolicy>,
runtime: Arc<dyn RuntimeAdapter>,
) -> Vec<Box<dyn Tool>> {
vec![
Box::new(ShellTool::new(security.clone(), runtime)),
Box::new(FileReadTool::new(security.clone())),
Box::new(FileWriteTool::new(security.clone())),
Box::new(FileEditTool::new(security.clone())),
Box::new(GlobSearchTool::new(security.clone())),
Box::new(ContentSearchTool::new(security)),
]
}
/// Create full tool registry including memory tools and optional Composio
#[allow(clippy::implicit_hasher, clippy::too_many_arguments)]
pub fn all_tools(
config: Arc<Config>,
security: &Arc<SecurityPolicy>,
memory: Arc<dyn Memory>,
composio_key: Option<&str>,
composio_entity_id: Option<&str>,
browser_config: &crate::config::BrowserConfig,
http_config: &crate::config::HttpRequestConfig,
web_fetch_config: &crate::config::WebFetchConfig,
workspace_dir: &std::path::Path,
agents: &HashMap<String, DelegateAgentConfig>,
fallback_api_key: Option<&str>,
root_config: &crate::config::Config,
) -> (Vec<Box<dyn Tool>>, Option<DelegateParentToolsHandle>) {
all_tools_with_runtime(
config,
security,
Arc::new(NativeRuntime::new()),
memory,
composio_key,
composio_entity_id,
browser_config,
http_config,
web_fetch_config,
workspace_dir,
agents,
fallback_api_key,
root_config,
)
}
/// Create full tool registry including memory tools and optional Composio.
#[allow(clippy::implicit_hasher, clippy::too_many_arguments)]
pub fn all_tools_with_runtime(
config: Arc<Config>,
security: &Arc<SecurityPolicy>,
runtime: Arc<dyn RuntimeAdapter>,
memory: Arc<dyn Memory>,
composio_key: Option<&str>,
composio_entity_id: Option<&str>,
browser_config: &crate::config::BrowserConfig,
http_config: &crate::config::HttpRequestConfig,
web_fetch_config: &crate::config::WebFetchConfig,
workspace_dir: &std::path::Path,
agents: &HashMap<String, DelegateAgentConfig>,
fallback_api_key: Option<&str>,
root_config: &crate::config::Config,
) -> (Vec<Box<dyn Tool>>, Option<DelegateParentToolsHandle>) {
let has_shell_access = runtime.has_shell_access();
let sandbox = create_sandbox(&root_config.security);
let mut tool_arcs: Vec<Arc<dyn Tool>> = vec![
Arc::new(ShellTool::new_with_sandbox(
security.clone(),
runtime,
sandbox,
)),
Arc::new(FileReadTool::new(security.clone())),
Arc::new(FileWriteTool::new(security.clone())),
Arc::new(FileEditTool::new(security.clone())),
Arc::new(GlobSearchTool::new(security.clone())),
Arc::new(ContentSearchTool::new(security.clone())),
Arc::new(CronAddTool::new(config.clone(), security.clone())),
Arc::new(CronListTool::new(config.clone())),
Arc::new(CronRemoveTool::new(config.clone(), security.clone())),
Arc::new(CronUpdateTool::new(config.clone(), security.clone())),
Arc::new(CronRunTool::new(config.clone(), security.clone())),
Arc::new(CronRunsTool::new(config.clone())),
Arc::new(MemoryStoreTool::new(memory.clone(), security.clone())),
Arc::new(MemoryRecallTool::new(memory.clone())),
Arc::new(MemoryForgetTool::new(memory, security.clone())),
Arc::new(ScheduleTool::new(security.clone(), root_config.clone())),
Arc::new(ModelRoutingConfigTool::new(
config.clone(),
security.clone(),
)),
Arc::new(ModelSwitchTool::new(security.clone())),
Arc::new(ProxyConfigTool::new(config.clone(), security.clone())),
Arc::new(GitOperationsTool::new(
security.clone(),
workspace_dir.to_path_buf(),
)),
Arc::new(PushoverTool::new(
security.clone(),
workspace_dir.to_path_buf(),
)),
Arc::new(CalculatorTool::new()),
];
if matches!(
root_config.skills.prompt_injection_mode,
crate::config::SkillsPromptInjectionMode::Compact
) {
tool_arcs.push(Arc::new(ReadSkillTool::new(
workspace_dir.to_path_buf(),
root_config.skills.open_skills_enabled,
root_config.skills.open_skills_dir.clone(),
)));
}
if browser_config.enabled {
// Add legacy browser_open tool for simple URL opening
tool_arcs.push(Arc::new(BrowserOpenTool::new(
security.clone(),
browser_config.allowed_domains.clone(),
)));
// Add full browser automation tool (pluggable backend)
tool_arcs.push(Arc::new(BrowserTool::new_with_backend(
security.clone(),
browser_config.allowed_domains.clone(),
browser_config.session_name.clone(),
browser_config.backend.clone(),
browser_config.native_headless,
browser_config.native_webdriver_url.clone(),
browser_config.native_chrome_path.clone(),
ComputerUseConfig {
endpoint: browser_config.computer_use.endpoint.clone(),
api_key: browser_config.computer_use.api_key.clone(),
timeout_ms: browser_config.computer_use.timeout_ms,
allow_remote_endpoint: browser_config.computer_use.allow_remote_endpoint,
window_allowlist: browser_config.computer_use.window_allowlist.clone(),
max_coordinate_x: browser_config.computer_use.max_coordinate_x,
max_coordinate_y: browser_config.computer_use.max_coordinate_y,
},
)));
}
// Browser delegation tool (conditionally registered; requires shell access)
if root_config.browser_delegate.enabled {
if has_shell_access {
tool_arcs.push(Arc::new(BrowserDelegateTool::new(
security.clone(),
root_config.browser_delegate.clone(),
)));
} else {
tracing::warn!(
"browser_delegate: skipped registration because the current runtime does not allow shell access"
);
}
}
if http_config.enabled {
tool_arcs.push(Arc::new(HttpRequestTool::new(
security.clone(),
http_config.allowed_domains.clone(),
http_config.max_response_size,
http_config.timeout_secs,
http_config.allow_private_hosts,
)));
}
if web_fetch_config.enabled {
tool_arcs.push(Arc::new(WebFetchTool::new(
security.clone(),
web_fetch_config.allowed_domains.clone(),
web_fetch_config.blocked_domains.clone(),
web_fetch_config.max_response_size,
web_fetch_config.timeout_secs,
)));
}
// Text browser tool (headless text-based browser rendering)
if root_config.text_browser.enabled {
tool_arcs.push(Arc::new(TextBrowserTool::new(
security.clone(),
root_config.text_browser.preferred_browser.clone(),
root_config.text_browser.timeout_secs,
)));
}
// Web search tool (enabled by default for GLM and other models)
if root_config.web_search.enabled {
tool_arcs.push(Arc::new(WebSearchTool::new_with_config(
root_config.web_search.provider.clone(),
root_config.web_search.brave_api_key.clone(),
root_config.web_search.max_results,
root_config.web_search.timeout_secs,
root_config.config_path.clone(),
root_config.secrets.encrypt,
)));
}
// Notion API tool (conditionally registered)
if root_config.notion.enabled {
let notion_api_key = if root_config.notion.api_key.trim().is_empty() {
std::env::var("NOTION_API_KEY").unwrap_or_default()
} else {
root_config.notion.api_key.trim().to_string()
};
if notion_api_key.trim().is_empty() {
tracing::warn!(
"Notion tool enabled but no API key found (set notion.api_key or NOTION_API_KEY env var)"
);
} else {
tool_arcs.push(Arc::new(NotionTool::new(notion_api_key, security.clone())));
}
}
// Jira integration (config-gated)
if root_config.jira.enabled {
let api_token = if root_config.jira.api_token.trim().is_empty() {
std::env::var("JIRA_API_TOKEN").unwrap_or_default()
} else {
root_config.jira.api_token.trim().to_string()
};
if api_token.trim().is_empty() {
tracing::warn!(
"Jira tool enabled but no API token found (set jira.api_token or JIRA_API_TOKEN env var)"
);
} else if root_config.jira.base_url.trim().is_empty() {
tracing::warn!("Jira tool enabled but jira.base_url is empty — skipping registration");
} else if root_config.jira.email.trim().is_empty() {
tracing::warn!("Jira tool enabled but jira.email is empty — skipping registration");
} else {
tool_arcs.push(Arc::new(JiraTool::new(
root_config.jira.base_url.trim().to_string(),
root_config.jira.email.trim().to_string(),
api_token,
root_config.jira.allowed_actions.clone(),
security.clone(),
root_config.jira.timeout_secs,
)));
}
}
// Project delivery intelligence
if root_config.project_intel.enabled {
tool_arcs.push(Arc::new(ProjectIntelTool::new(
root_config.project_intel.default_language.clone(),
root_config.project_intel.risk_sensitivity.clone(),
)));
}
// MCSS Security Operations
if root_config.security_ops.enabled {
tool_arcs.push(Arc::new(SecurityOpsTool::new(
root_config.security_ops.clone(),
)));
}
// Backup tool (enabled by default)
if root_config.backup.enabled {
tool_arcs.push(Arc::new(BackupTool::new(
workspace_dir.to_path_buf(),
root_config.backup.include_dirs.clone(),
root_config.backup.max_keep,
)));
}
// Data management tool (disabled by default)
if root_config.data_retention.enabled {
tool_arcs.push(Arc::new(DataManagementTool::new(
workspace_dir.to_path_buf(),
root_config.data_retention.retention_days,
)));
}
// Cloud operations advisory tools (read-only analysis)
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()));
}
// 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.allowed_operations.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"
);
}
// PDF extraction (feature-gated at compile time via rag-pdf)
tool_arcs.push(Arc::new(PdfReadTool::new(security.clone())));
// Vision tools are always available
tool_arcs.push(Arc::new(ScreenshotTool::new(security.clone())));
tool_arcs.push(Arc::new(ImageInfoTool::new(security.clone())));
// LinkedIn integration (config-gated)
if root_config.linkedin.enabled {
tool_arcs.push(Arc::new(LinkedInTool::new(
security.clone(),
workspace_dir.to_path_buf(),
root_config.linkedin.api_version.clone(),
root_config.linkedin.content.clone(),
root_config.linkedin.image.clone(),
)));
}
if let Some(key) = composio_key {
if !key.is_empty() {
tool_arcs.push(Arc::new(ComposioTool::new(
key,
composio_entity_id,
security.clone(),
)));
}
}
// Microsoft 365 Graph API integration
if root_config.microsoft365.enabled {
let ms_cfg = &root_config.microsoft365;
let tenant_id = ms_cfg
.tenant_id
.as_deref()
.unwrap_or_default()
.trim()
.to_string();
let client_id = ms_cfg
.client_id
.as_deref()
.unwrap_or_default()
.trim()
.to_string();
if !tenant_id.is_empty() && !client_id.is_empty() {
// Fail fast: client_credentials flow requires a client_secret at registration time.
if ms_cfg.auth_flow.trim() == "client_credentials"
&& ms_cfg
.client_secret
.as_deref()
.map_or(true, |s| s.trim().is_empty())
{
tracing::error!(
"microsoft365: client_credentials auth_flow requires a non-empty client_secret"
);
return (boxed_registry_from_arcs(tool_arcs), None);
}
let resolved = microsoft365::types::Microsoft365ResolvedConfig {
tenant_id,
client_id,
client_secret: ms_cfg.client_secret.clone(),
auth_flow: ms_cfg.auth_flow.clone(),
scopes: ms_cfg.scopes.clone(),
token_cache_encrypted: ms_cfg.token_cache_encrypted,
user_id: ms_cfg.user_id.as_deref().unwrap_or("me").to_string(),
};
// Store token cache in the config directory (next to config.toml),
// not the workspace directory, to keep bearer tokens out of the
// project tree.
let cache_dir = root_config.config_path.parent().unwrap_or(workspace_dir);
match Microsoft365Tool::new(resolved, security.clone(), cache_dir) {
Ok(tool) => tool_arcs.push(Arc::new(tool)),
Err(e) => {
tracing::error!("microsoft365: failed to initialize tool: {e}");
}
}
} else {
tracing::warn!(
"microsoft365: skipped registration because tenant_id or client_id is empty"
);
}
}
// Knowledge graph tool
if root_config.knowledge.enabled {
let db_path_str = root_config.knowledge.db_path.replace(
'~',
&directories::UserDirs::new()
.map(|u| u.home_dir().to_string_lossy().to_string())
.unwrap_or_else(|| ".".to_string()),
);
let db_path = std::path::PathBuf::from(&db_path_str);
match crate::memory::knowledge_graph::KnowledgeGraph::new(
&db_path,
root_config.knowledge.max_nodes,
) {
Ok(graph) => {
tool_arcs.push(Arc::new(KnowledgeTool::new(Arc::new(graph))));
}
Err(e) => {
tracing::warn!("knowledge graph disabled due to init error: {e}");
}
}
}
// Add delegation tool when agents are configured
let delegate_fallback_credential = fallback_api_key.and_then(|value| {
let trimmed_value = value.trim();
(!trimmed_value.is_empty()).then(|| trimmed_value.to_owned())
});
let provider_runtime_options = crate::providers::ProviderRuntimeOptions {
auth_profile_override: None,
provider_api_url: root_config.api_url.clone(),
zeroclaw_dir: root_config
.config_path
.parent()
.map(std::path::PathBuf::from),
secrets_encrypt: root_config.secrets.encrypt,
reasoning_enabled: root_config.runtime.reasoning_enabled,
reasoning_effort: root_config.runtime.reasoning_effort.clone(),
provider_timeout_secs: Some(root_config.provider_timeout_secs),
extra_headers: root_config.extra_headers.clone(),
api_path: root_config.api_path.clone(),
};
let delegate_handle: Option<DelegateParentToolsHandle> = if agents.is_empty() {
None
} else {
let delegate_agents: HashMap<String, DelegateAgentConfig> = agents
.iter()
.map(|(name, cfg)| (name.clone(), cfg.clone()))
.collect();
let parent_tools = Arc::new(RwLock::new(tool_arcs.clone()));
let delegate_tool = DelegateTool::new_with_options(
delegate_agents,
delegate_fallback_credential.clone(),
security.clone(),
provider_runtime_options.clone(),
)
.with_parent_tools(Arc::clone(&parent_tools))
.with_multimodal_config(root_config.multimodal.clone())
.with_delegate_config(root_config.delegate.clone());
tool_arcs.push(Arc::new(delegate_tool));
Some(parent_tools)
};
// Add swarm tool when swarms are configured
if !root_config.swarms.is_empty() {
let swarm_agents: HashMap<String, DelegateAgentConfig> = agents
.iter()
.map(|(name, cfg)| (name.clone(), cfg.clone()))
.collect();
tool_arcs.push(Arc::new(SwarmTool::new(
root_config.swarms.clone(),
swarm_agents,
delegate_fallback_credential,
security.clone(),
provider_runtime_options,
)));
}
// Workspace management tool (conditionally registered when workspace isolation is enabled)
if root_config.workspace.enabled {
let workspaces_dir = if root_config.workspace.workspaces_dir.starts_with("~/") {
let home = directories::UserDirs::new()
.map(|u| u.home_dir().to_path_buf())
.unwrap_or_else(|| std::path::PathBuf::from("."));
home.join(&root_config.workspace.workspaces_dir[2..])
} else {
std::path::PathBuf::from(&root_config.workspace.workspaces_dir)
};
let ws_manager = crate::config::workspace::WorkspaceManager::new(workspaces_dir);
tool_arcs.push(Arc::new(WorkspaceTool::new(
Arc::new(tokio::sync::RwLock::new(ws_manager)),
security.clone(),
)));
}
// ── WASM plugin tools (requires plugins-wasm feature) ──
#[cfg(feature = "plugins-wasm")]
{
let plugin_dir = config.plugins.plugins_dir.clone();
let plugin_path = if plugin_dir.starts_with("~/") {
let home = directories::UserDirs::new()
.map(|u| u.home_dir().to_path_buf())
.unwrap_or_else(|| std::path::PathBuf::from("."));
home.join(&plugin_dir[2..])
} else {
std::path::PathBuf::from(&plugin_dir)
};
if plugin_path.exists() && config.plugins.enabled {
match crate::plugins::host::PluginHost::new(
plugin_path.parent().unwrap_or(&plugin_path),
) {
Ok(host) => {
let tool_manifests = host.tool_plugins();
let count = tool_manifests.len();
for manifest in tool_manifests {
tool_arcs.push(Arc::new(crate::plugins::wasm_tool::WasmTool::new(
manifest.name.clone(),
manifest.description.clone().unwrap_or_default(),
manifest.name.clone(),
"call".to_string(),
serde_json::json!({
"type": "object",
"properties": {
"input": {
"type": "string",
"description": "Input for the plugin"
}
},
"required": ["input"]
}),
)));
}
tracing::info!("Loaded {count} WASM plugin tools");
}
Err(e) => {
tracing::warn!("Failed to load WASM plugins: {e}");
}
}
}
}
(boxed_registry_from_arcs(tool_arcs), delegate_handle)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{BrowserConfig, Config, MemoryConfig};
use tempfile::TempDir;
fn test_config(tmp: &TempDir) -> Config {
Config {
workspace_dir: tmp.path().join("workspace"),
config_path: tmp.path().join("config.toml"),
..Config::default()
}
}
#[test]
fn default_tools_has_expected_count() {
let security = Arc::new(SecurityPolicy::default());
let tools = default_tools(security);
assert_eq!(tools.len(), 6);
}
#[test]
fn all_tools_excludes_browser_when_disabled() {
let tmp = TempDir::new().unwrap();
let security = Arc::new(SecurityPolicy::default());
let mem_cfg = MemoryConfig {
backend: "markdown".into(),
..MemoryConfig::default()
};
let mem: Arc<dyn Memory> =
Arc::from(crate::memory::create_memory(&mem_cfg, tmp.path(), None).unwrap());
let browser = BrowserConfig {
enabled: false,
allowed_domains: vec!["example.com".into()],
session_name: None,
..BrowserConfig::default()
};
let http = crate::config::HttpRequestConfig::default();
let cfg = test_config(&tmp);
let (tools, _) = all_tools(
Arc::new(Config::default()),
&security,
mem,
None,
None,
&browser,
&http,
&crate::config::WebFetchConfig::default(),
tmp.path(),
&HashMap::new(),
None,
&cfg,
);
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert!(!names.contains(&"browser_open"));
assert!(names.contains(&"schedule"));
assert!(names.contains(&"model_routing_config"));
assert!(names.contains(&"pushover"));
assert!(names.contains(&"proxy_config"));
}
#[test]
fn all_tools_includes_browser_when_enabled() {
let tmp = TempDir::new().unwrap();
let security = Arc::new(SecurityPolicy::default());
let mem_cfg = MemoryConfig {
backend: "markdown".into(),
..MemoryConfig::default()
};
let mem: Arc<dyn Memory> =
Arc::from(crate::memory::create_memory(&mem_cfg, tmp.path(), None).unwrap());
let browser = BrowserConfig {
enabled: true,
allowed_domains: vec!["example.com".into()],
session_name: None,
..BrowserConfig::default()
};
let http = crate::config::HttpRequestConfig::default();
let cfg = test_config(&tmp);
let (tools, _) = all_tools(
Arc::new(Config::default()),
&security,
mem,
None,
None,
&browser,
&http,
&crate::config::WebFetchConfig::default(),
tmp.path(),
&HashMap::new(),
None,
&cfg,
);
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert!(names.contains(&"browser_open"));
assert!(names.contains(&"content_search"));
assert!(names.contains(&"model_routing_config"));
assert!(names.contains(&"pushover"));
assert!(names.contains(&"proxy_config"));
}
#[test]
fn default_tools_names() {
let security = Arc::new(SecurityPolicy::default());
let tools = default_tools(security);
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert!(names.contains(&"shell"));
assert!(names.contains(&"file_read"));
assert!(names.contains(&"file_write"));
assert!(names.contains(&"file_edit"));
assert!(names.contains(&"glob_search"));
assert!(names.contains(&"content_search"));
}
#[test]
fn default_tools_all_have_descriptions() {
let security = Arc::new(SecurityPolicy::default());
let tools = default_tools(security);
for tool in &tools {
assert!(
!tool.description().is_empty(),
"Tool {} has empty description",
tool.name()
);
}
}
#[test]
fn default_tools_all_have_schemas() {
let security = Arc::new(SecurityPolicy::default());
let tools = default_tools(security);
for tool in &tools {
let schema = tool.parameters_schema();
assert!(
schema.is_object(),
"Tool {} schema is not an object",
tool.name()
);
assert!(
schema["properties"].is_object(),
"Tool {} schema has no properties",
tool.name()
);
}
}
#[test]
fn tool_spec_generation() {
let security = Arc::new(SecurityPolicy::default());
let tools = default_tools(security);
for tool in &tools {
let spec = tool.spec();
assert_eq!(spec.name, tool.name());
assert_eq!(spec.description, tool.description());
assert!(spec.parameters.is_object());
}
}
#[test]
fn tool_result_serde() {
let result = ToolResult {
success: true,
output: "hello".into(),
error: None,
};
let json = serde_json::to_string(&result).unwrap();
let parsed: ToolResult = serde_json::from_str(&json).unwrap();
assert!(parsed.success);
assert_eq!(parsed.output, "hello");
assert!(parsed.error.is_none());
}
#[test]
fn tool_result_with_error_serde() {
let result = ToolResult {
success: false,
output: String::new(),
error: Some("boom".into()),
};
let json = serde_json::to_string(&result).unwrap();
let parsed: ToolResult = serde_json::from_str(&json).unwrap();
assert!(!parsed.success);
assert_eq!(parsed.error.as_deref(), Some("boom"));
}
#[test]
fn tool_spec_serde() {
let spec = ToolSpec {
name: "test".into(),
description: "A test tool".into(),
parameters: serde_json::json!({"type": "object"}),
};
let json = serde_json::to_string(&spec).unwrap();
let parsed: ToolSpec = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.name, "test");
assert_eq!(parsed.description, "A test tool");
}
#[test]
fn all_tools_includes_delegate_when_agents_configured() {
let tmp = TempDir::new().unwrap();
let security = Arc::new(SecurityPolicy::default());
let mem_cfg = MemoryConfig {
backend: "markdown".into(),
..MemoryConfig::default()
};
let mem: Arc<dyn Memory> =
Arc::from(crate::memory::create_memory(&mem_cfg, tmp.path(), None).unwrap());
let browser = BrowserConfig::default();
let http = crate::config::HttpRequestConfig::default();
let cfg = test_config(&tmp);
let mut agents = HashMap::new();
agents.insert(
"researcher".to_string(),
DelegateAgentConfig {
provider: "ollama".to_string(),
model: "llama3".to_string(),
system_prompt: None,
api_key: None,
temperature: None,
max_depth: 3,
agentic: false,
allowed_tools: Vec::new(),
max_iterations: 10,
timeout_secs: None,
agentic_timeout_secs: None,
},
);
let (tools, _) = all_tools(
Arc::new(Config::default()),
&security,
mem,
None,
None,
&browser,
&http,
&crate::config::WebFetchConfig::default(),
tmp.path(),
&agents,
Some("delegate-test-credential"),
&cfg,
);
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert!(names.contains(&"delegate"));
}
#[test]
fn all_tools_excludes_delegate_when_no_agents() {
let tmp = TempDir::new().unwrap();
let security = Arc::new(SecurityPolicy::default());
let mem_cfg = MemoryConfig {
backend: "markdown".into(),
..MemoryConfig::default()
};
let mem: Arc<dyn Memory> =
Arc::from(crate::memory::create_memory(&mem_cfg, tmp.path(), None).unwrap());
let browser = BrowserConfig::default();
let http = crate::config::HttpRequestConfig::default();
let cfg = test_config(&tmp);
let (tools, _) = all_tools(
Arc::new(Config::default()),
&security,
mem,
None,
None,
&browser,
&http,
&crate::config::WebFetchConfig::default(),
tmp.path(),
&HashMap::new(),
None,
&cfg,
);
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert!(!names.contains(&"delegate"));
}
#[test]
fn all_tools_includes_read_skill_in_compact_mode() {
let tmp = TempDir::new().unwrap();
let security = Arc::new(SecurityPolicy::default());
let mem_cfg = MemoryConfig {
backend: "markdown".into(),
..MemoryConfig::default()
};
let mem: Arc<dyn Memory> =
Arc::from(crate::memory::create_memory(&mem_cfg, tmp.path(), None).unwrap());
let browser = BrowserConfig::default();
let http = crate::config::HttpRequestConfig::default();
let mut cfg = test_config(&tmp);
cfg.skills.prompt_injection_mode = crate::config::SkillsPromptInjectionMode::Compact;
let (tools, _) = all_tools(
Arc::new(cfg.clone()),
&security,
mem,
None,
None,
&browser,
&http,
&crate::config::WebFetchConfig::default(),
tmp.path(),
&HashMap::new(),
None,
&cfg,
);
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert!(names.contains(&"read_skill"));
}
#[test]
fn all_tools_excludes_read_skill_in_full_mode() {
let tmp = TempDir::new().unwrap();
let security = Arc::new(SecurityPolicy::default());
let mem_cfg = MemoryConfig {
backend: "markdown".into(),
..MemoryConfig::default()
};
let mem: Arc<dyn Memory> =
Arc::from(crate::memory::create_memory(&mem_cfg, tmp.path(), None).unwrap());
let browser = BrowserConfig::default();
let http = crate::config::HttpRequestConfig::default();
let mut cfg = test_config(&tmp);
cfg.skills.prompt_injection_mode = crate::config::SkillsPromptInjectionMode::Full;
let (tools, _) = all_tools(
Arc::new(cfg.clone()),
&security,
mem,
None,
None,
&browser,
&http,
&crate::config::WebFetchConfig::default(),
tmp.path(),
&HashMap::new(),
None,
&cfg,
);
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert!(!names.contains(&"read_skill"));
}
}