diff --git a/README.md b/README.md index 9427ff1..8cc31a0 100644 --- a/README.md +++ b/README.md @@ -70,10 +70,10 @@ Every subsystem is a **trait** — swap implementations with a config change, ze | **AI Models** | `Provider` | 22+ providers (OpenRouter, Anthropic, OpenAI, Ollama, Venice, Groq, Mistral, xAI, DeepSeek, Together, Fireworks, Perplexity, Cohere, Bedrock, etc.) | `custom:https://your-api.com` — any OpenAI-compatible API | | **Channels** | `Channel` | CLI, Telegram, Discord, Slack, iMessage, Matrix, Webhook | Any messaging API | | **Memory** | `Memory` | SQLite with hybrid search (FTS5 + vector cosine similarity), Markdown | Any persistence backend | -| **Tools** | `Tool` | shell, file_read, file_write, memory_store, memory_recall, memory_forget | Any capability | +| **Tools** | `Tool` | shell, file_read, file_write, memory_store, memory_recall, memory_forget, composio (optional) | Any capability | | **Observability** | `Observer` | Noop, Log, Multi | Prometheus, OTel | | **Runtime** | `RuntimeAdapter` | Native (Mac/Linux/Pi) | Docker, WASM | -| **Security** | `SecurityPolicy` | Gateway pairing, sandbox, allowlists, rate limits, filesystem scoping | — | +| **Security** | `SecurityPolicy` | Gateway pairing, sandbox, allowlists, rate limits, filesystem scoping, encrypted secrets | — | | **Tunnel** | `Tunnel` | None, Cloudflare, Tailscale, ngrok, Custom | Any tunnel binary | | **Heartbeat** | Engine | HEARTBEAT.md periodic tasks | — | | **Skills** | Loader | TOML manifests + SKILL.md instructions | Community skill packs | @@ -230,6 +230,49 @@ funnel = true # true = public internet, false = tailnet only The tunnel starts automatically with `zeroclaw gateway` and prints the public URL. +## Composio Integration (Optional) + +ZeroClaw can optionally connect to **1000+ apps** via [Composio](https://composio.dev) managed OAuth — Gmail, Notion, GitHub, Slack, Google Calendar, and more. Your core agent stays local; Composio handles OAuth tokens. + +```toml +[composio] +enabled = true +api_key = "enc:a1b2c3..." # encrypted with local key +entity_id = "default" +``` + +The setup wizard asks: **Sovereign (local only)** vs **Composio (managed OAuth)**. + +| Mode | Pros | Cons | +|------|------|------| +| **Sovereign** | Full privacy, no external deps | You manage every API key | +| **Composio** | 1000+ OAuth apps, revocable tokens | Composio API key required | + +Use the `composio` tool from the agent: +``` +> List my Gmail actions +> Execute GMAIL_FETCH_EMAILS +> Connect my Notion account +``` + +## Encrypted Secrets + +API keys in `config.toml` are encrypted by default using a local key file (`~/.zeroclaw/.secret_key`, mode `0600`). + +- **Encrypted values** are prefixed with `enc:` followed by hex-encoded ciphertext +- **Plaintext values** (backward compatible) are used as-is — no prefix +- **Disable** with `secrets.encrypt = false` if you prefer plaintext + +```toml +[secrets] +encrypt = true # default: true — API keys stored encrypted +``` + +This prevents: +- Plaintext API key exposure in config files +- Accidental `git commit` of raw keys +- Casual `grep` / log scraping attacks + ## Configuration Config: `~/.zeroclaw/config.toml` (created by `onboard`) @@ -263,6 +306,13 @@ interval_minutes = 30 [tunnel] provider = "none" # "none", "cloudflare", "tailscale", "ngrok", "custom" + +[composio] +enabled = false # opt-in: managed OAuth tools via Composio +# api_key = "enc:..." # set via onboard wizard or manually + +[secrets] +encrypt = true # encrypt API keys in config.toml ``` ## Gateway API @@ -294,7 +344,7 @@ The actual port is printed on startup and passed to the tunnel system automatica | `gateway` | Start webhook server (default: `127.0.0.1:8080`) | | `gateway --port 0` | Random port mode | | `status -v` | Show full system status | -| `tools list` | List all 6 tools | +| `tools list` | List all 7 tools (6 core + composio if enabled) | | `tools test ` | Test a tool directly | | `integrations list` | List all 50+ integrations | @@ -419,13 +469,14 @@ src/ ├── runtime/ # RuntimeAdapter trait + Native │ ├── traits.rs │ └── native.rs -├── security/ # Security policy + gateway pairing +├── security/ # Security policy + gateway pairing + secrets │ ├── policy.rs # SecurityPolicy, path validation, rate limiting │ ├── pairing.rs # PairingGuard, OTP, bearer tokens +│ ├── secrets.rs # Encrypted secret store (XOR + local key file) │ └── mod.rs ├── skills/ # Skill loader (TOML manifests) │ └── mod.rs -├── tools/ # Tool trait + 6 tools +├── tools/ # Tool trait + 7 tools │ ├── traits.rs # Tool trait definition │ ├── shell.rs # Shell command execution │ ├── file_read.rs # Sandboxed file reading @@ -433,6 +484,7 @@ src/ │ ├── memory_store.rs # Store to memory │ ├── memory_recall.rs # Search memory │ ├── memory_forget.rs # Delete from memory +│ ├── composio.rs # Composio managed OAuth tools (optional) │ └── mod.rs # Registry └── tunnel/ # Tunnel trait + 5 implementations ├── none.rs # Local-only (default) diff --git a/src/agent/loop_.rs b/src/agent/loop_.rs index bdb693d..122f1d9 100644 --- a/src/agent/loop_.rs +++ b/src/agent/loop_.rs @@ -54,7 +54,12 @@ pub async fn run( tracing::info!(backend = mem.name(), "Memory initialized"); // ── Tools (including memory tools) ──────────────────────────── - let _tools = tools::all_tools(security, mem.clone()); + let composio_key = if config.composio.enabled { + config.composio.api_key.as_deref() + } else { + None + }; + let _tools = tools::all_tools(security, mem.clone(), composio_key); // ── Resolve provider ───────────────────────────────────────── let provider_name = provider_override diff --git a/src/config/mod.rs b/src/config/mod.rs index e05864a..58dbafa 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,7 +1,7 @@ pub mod schema; pub use schema::{ - AutonomyConfig, ChannelsConfig, Config, DiscordConfig, GatewayConfig, HeartbeatConfig, - IMessageConfig, MatrixConfig, MemoryConfig, ObservabilityConfig, RuntimeConfig, SlackConfig, - TelegramConfig, TunnelConfig, WebhookConfig, + AutonomyConfig, ChannelsConfig, ComposioConfig, Config, DiscordConfig, GatewayConfig, + HeartbeatConfig, IMessageConfig, MatrixConfig, MemoryConfig, ObservabilityConfig, + RuntimeConfig, SecretsConfig, SlackConfig, TelegramConfig, TunnelConfig, WebhookConfig, }; diff --git a/src/config/schema.rs b/src/config/schema.rs index 2f7fa95..6b57f8e 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -39,6 +39,12 @@ pub struct Config { #[serde(default)] pub gateway: GatewayConfig, + + #[serde(default)] + pub composio: ComposioConfig, + + #[serde(default)] + pub secrets: SecretsConfig, } // ── Gateway security ───────────────────────────────────────────── @@ -70,6 +76,50 @@ impl Default for GatewayConfig { } } +// ── Composio (managed tool surface) ───────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ComposioConfig { + /// Enable Composio integration for 1000+ OAuth tools + #[serde(default)] + pub enabled: bool, + /// Composio API key (stored encrypted when secrets.encrypt = true) + #[serde(default)] + pub api_key: Option, + /// Default entity ID for multi-user setups + #[serde(default = "default_entity_id")] + pub entity_id: String, +} + +fn default_entity_id() -> String { + "default".into() +} + +impl Default for ComposioConfig { + fn default() -> Self { + Self { + enabled: false, + api_key: None, + entity_id: default_entity_id(), + } + } +} + +// ── Secrets (encrypted credential store) ──────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SecretsConfig { + /// Enable encryption for API keys and tokens in config.toml + #[serde(default = "default_true")] + pub encrypt: bool, +} + +impl Default for SecretsConfig { + fn default() -> Self { + Self { encrypt: true } + } +} + // ── Memory ─────────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] @@ -403,6 +453,8 @@ impl Default for Config { memory: MemoryConfig::default(), tunnel: TunnelConfig::default(), gateway: GatewayConfig::default(), + composio: ComposioConfig::default(), + secrets: SecretsConfig::default(), } } } @@ -542,6 +594,8 @@ mod tests { memory: MemoryConfig::default(), tunnel: TunnelConfig::default(), gateway: GatewayConfig::default(), + composio: ComposioConfig::default(), + secrets: SecretsConfig::default(), }; let toml_str = toml::to_string_pretty(&config).unwrap(); @@ -603,6 +657,8 @@ default_temperature = 0.7 memory: MemoryConfig::default(), tunnel: TunnelConfig::default(), gateway: GatewayConfig::default(), + composio: ComposioConfig::default(), + secrets: SecretsConfig::default(), }; config.save().unwrap(); @@ -913,4 +969,96 @@ default_temperature = 0.7 "Must block ~/.ssh" ); } + + // ══════════════════════════════════════════════════════════ + // COMPOSIO CONFIG TESTS + // ══════════════════════════════════════════════════════════ + + #[test] + fn composio_config_default_disabled() { + let c = ComposioConfig::default(); + assert!(!c.enabled, "Composio must be disabled by default"); + assert!(c.api_key.is_none(), "No API key by default"); + assert_eq!(c.entity_id, "default"); + } + + #[test] + fn composio_config_serde_roundtrip() { + let c = ComposioConfig { + enabled: true, + api_key: Some("comp-key-123".into()), + entity_id: "user42".into(), + }; + let toml_str = toml::to_string(&c).unwrap(); + let parsed: ComposioConfig = toml::from_str(&toml_str).unwrap(); + assert!(parsed.enabled); + assert_eq!(parsed.api_key.as_deref(), Some("comp-key-123")); + assert_eq!(parsed.entity_id, "user42"); + } + + #[test] + fn composio_config_backward_compat_missing_section() { + let minimal = r#" +workspace_dir = "/tmp/ws" +config_path = "/tmp/config.toml" +default_temperature = 0.7 +"#; + let parsed: Config = toml::from_str(minimal).unwrap(); + assert!( + !parsed.composio.enabled, + "Missing [composio] must default to disabled" + ); + assert!(parsed.composio.api_key.is_none()); + } + + #[test] + fn composio_config_partial_toml() { + let toml_str = r#" +enabled = true +"#; + let parsed: ComposioConfig = toml::from_str(toml_str).unwrap(); + assert!(parsed.enabled); + assert!(parsed.api_key.is_none()); + assert_eq!(parsed.entity_id, "default"); + } + + // ══════════════════════════════════════════════════════════ + // SECRETS CONFIG TESTS + // ══════════════════════════════════════════════════════════ + + #[test] + fn secrets_config_default_encrypts() { + let s = SecretsConfig::default(); + assert!(s.encrypt, "Encryption must be enabled by default"); + } + + #[test] + fn secrets_config_serde_roundtrip() { + let s = SecretsConfig { encrypt: false }; + let toml_str = toml::to_string(&s).unwrap(); + let parsed: SecretsConfig = toml::from_str(&toml_str).unwrap(); + assert!(!parsed.encrypt); + } + + #[test] + fn secrets_config_backward_compat_missing_section() { + let minimal = r#" +workspace_dir = "/tmp/ws" +config_path = "/tmp/config.toml" +default_temperature = 0.7 +"#; + let parsed: Config = toml::from_str(minimal).unwrap(); + assert!( + parsed.secrets.encrypt, + "Missing [secrets] must default to encrypt=true" + ); + } + + #[test] + fn config_default_has_composio_and_secrets() { + let c = Config::default(); + assert!(!c.composio.enabled); + assert!(c.composio.api_key.is_none()); + assert!(c.secrets.encrypt); + } } diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index 87a0492..2c1f22b 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -1,7 +1,7 @@ use crate::config::{ - AutonomyConfig, ChannelsConfig, Config, DiscordConfig, HeartbeatConfig, IMessageConfig, - MatrixConfig, MemoryConfig, ObservabilityConfig, RuntimeConfig, SlackConfig, TelegramConfig, - WebhookConfig, + AutonomyConfig, ChannelsConfig, ComposioConfig, Config, DiscordConfig, HeartbeatConfig, + IMessageConfig, MatrixConfig, MemoryConfig, ObservabilityConfig, RuntimeConfig, SecretsConfig, + SlackConfig, TelegramConfig, WebhookConfig, }; use crate::security::AutonomyLevel; use anyhow::{Context, Result}; @@ -55,22 +55,25 @@ pub fn run_wizard() -> Result { ); println!(); - print_step(1, 6, "Workspace Setup"); + print_step(1, 7, "Workspace Setup"); let (workspace_dir, config_path) = setup_workspace()?; - print_step(2, 6, "AI Provider & API Key"); + print_step(2, 7, "AI Provider & API Key"); let (provider, api_key, model) = setup_provider()?; - print_step(3, 6, "Channels (How You Talk to ZeroClaw)"); + print_step(3, 7, "Channels (How You Talk to ZeroClaw)"); let channels_config = setup_channels()?; - print_step(4, 6, "Tunnel (Expose to Internet)"); + print_step(4, 7, "Tunnel (Expose to Internet)"); let tunnel_config = setup_tunnel()?; - print_step(5, 6, "Project Context (Personalize Your Agent)"); + print_step(5, 7, "Tool Mode & Security"); + let (composio_config, secrets_config) = setup_tool_mode()?; + + print_step(6, 7, "Project Context (Personalize Your Agent)"); let project_ctx = setup_project_context()?; - print_step(6, 6, "Workspace Files"); + print_step(7, 7, "Workspace Files"); scaffold_workspace(&workspace_dir, &project_ctx)?; // ── Build config ── @@ -98,6 +101,8 @@ pub fn run_wizard() -> Result { memory: MemoryConfig::default(), // SQLite + auto-save by default tunnel: tunnel_config, gateway: crate::config::GatewayConfig::default(), + composio: composio_config, + secrets: secrets_config, }; println!( @@ -533,7 +538,97 @@ fn provider_env_var(name: &str) -> &'static str { } } -// ── Step 4: Project Context ───────────────────────────────────── +// ── Step 5: Tool Mode & Security ──────────────────────────────── + +fn setup_tool_mode() -> Result<(ComposioConfig, SecretsConfig)> { + print_bullet("Choose how ZeroClaw connects to external apps."); + print_bullet("You can always change this later in config.toml."); + println!(); + + let options = vec![ + "Sovereign (local only) — you manage API keys, full privacy (default)", + "Composio (managed OAuth) — 1000+ apps via OAuth, no raw keys shared", + ]; + + let choice = Select::new() + .with_prompt(" Select tool mode") + .items(&options) + .default(0) + .interact()?; + + let composio_config = if choice == 1 { + println!(); + println!( + " {} {}", + style("Composio Setup").white().bold(), + style("— 1000+ OAuth integrations (Gmail, Notion, GitHub, Slack, ...)").dim() + ); + print_bullet("Get your API key at: https://app.composio.dev/settings"); + print_bullet("ZeroClaw uses Composio as a tool — your core agent stays local."); + println!(); + + let api_key: String = Input::new() + .with_prompt(" Composio API key (or Enter to skip)") + .allow_empty(true) + .interact_text()?; + + if api_key.trim().is_empty() { + println!( + " {} Skipped — set composio.api_key in config.toml later", + style("→").dim() + ); + ComposioConfig::default() + } else { + println!( + " {} Composio: {} (1000+ OAuth tools available)", + style("✓").green().bold(), + style("enabled").green() + ); + ComposioConfig { + enabled: true, + api_key: Some(api_key), + ..ComposioConfig::default() + } + } + } else { + println!( + " {} Tool mode: {} — full privacy, you own every key", + style("✓").green().bold(), + style("Sovereign (local only)").green() + ); + ComposioConfig::default() + }; + + // ── Encrypted secrets ── + println!(); + print_bullet("ZeroClaw can encrypt API keys stored in config.toml."); + print_bullet("A local key file protects against plaintext exposure and accidental leaks."); + + let encrypt = Confirm::new() + .with_prompt(" Enable encrypted secret storage?") + .default(true) + .interact()?; + + let secrets_config = SecretsConfig { encrypt }; + + if encrypt { + println!( + " {} Secrets: {} — keys encrypted with local key file", + style("✓").green().bold(), + style("encrypted").green() + ); + } else { + println!( + " {} Secrets: {} — keys stored as plaintext (not recommended)", + style("✓").green().bold(), + style("plaintext").yellow() + ); + } + + Ok((composio_config, secrets_config)) +} + +// ── Step 6: Project Context ───────────────────────────────────── fn setup_project_context() -> Result { print_bullet("Let's personalize your agent. You can always update these later."); diff --git a/src/security/mod.rs b/src/security/mod.rs index a7fc47c..5a85deb 100644 --- a/src/security/mod.rs +++ b/src/security/mod.rs @@ -1,6 +1,9 @@ pub mod pairing; pub mod policy; +pub mod secrets; #[allow(unused_imports)] pub use pairing::PairingGuard; pub use policy::{AutonomyLevel, SecurityPolicy}; +#[allow(unused_imports)] +pub use secrets::SecretStore; diff --git a/src/security/secrets.rs b/src/security/secrets.rs new file mode 100644 index 0000000..ee1d22d --- /dev/null +++ b/src/security/secrets.rs @@ -0,0 +1,338 @@ +// Encrypted secret store — defense-in-depth for API keys and tokens. +// +// Secrets are encrypted using a random key stored in `~/.zeroclaw/.secret_key` +// with restrictive file permissions (0600). The config file stores only +// hex-encoded ciphertext, never plaintext keys. +// +// This prevents: +// - Plaintext exposure in config files +// - Casual `grep` or `git log` leaks +// - Accidental commit of raw API keys +// +// For sovereign users who prefer plaintext, `secrets.encrypt = false` disables this. + +use anyhow::{Context, Result}; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Length of the random encryption key in bytes. +const KEY_LEN: usize = 32; + +/// Manages encrypted storage of secrets (API keys, tokens, etc.) +#[derive(Debug, Clone)] +pub struct SecretStore { + /// Path to the key file (`~/.zeroclaw/.secret_key`) + key_path: PathBuf, + /// Whether encryption is enabled + enabled: bool, +} + +impl SecretStore { + /// Create a new secret store rooted at the given directory. + pub fn new(zeroclaw_dir: &Path, enabled: bool) -> Self { + Self { + key_path: zeroclaw_dir.join(".secret_key"), + enabled, + } + } + + /// Encrypt a plaintext secret. Returns hex-encoded ciphertext prefixed with `enc:`. + /// If encryption is disabled, returns the plaintext as-is. + pub fn encrypt(&self, plaintext: &str) -> Result { + if !self.enabled || plaintext.is_empty() { + return Ok(plaintext.to_string()); + } + + let key = self.load_or_create_key()?; + let ciphertext = xor_cipher(plaintext.as_bytes(), &key); + Ok(format!("enc:{}", hex_encode(&ciphertext))) + } + + /// Decrypt a secret. If the value starts with `enc:`, it's decrypted. + /// Otherwise, it's returned as-is (backward-compatible with plaintext configs). + pub fn decrypt(&self, value: &str) -> Result { + if !value.starts_with("enc:") { + return Ok(value.to_string()); + } + + let hex_str = &value[4..]; // strip "enc:" prefix + let ciphertext = + hex_decode(hex_str).context("Failed to decode encrypted secret (corrupt hex)")?; + let key = self.load_or_create_key()?; + let plaintext_bytes = xor_cipher(&ciphertext, &key); + String::from_utf8(plaintext_bytes) + .context("Decrypted secret is not valid UTF-8 — wrong key or corrupt data") + } + + /// Check if a value is already encrypted. + pub fn is_encrypted(value: &str) -> bool { + value.starts_with("enc:") + } + + /// Load the encryption key from disk, or create one if it doesn't exist. + fn load_or_create_key(&self) -> Result> { + if self.key_path.exists() { + let hex_key = + fs::read_to_string(&self.key_path).context("Failed to read secret key file")?; + hex_decode(hex_key.trim()).context("Secret key file is corrupt") + } else { + let key = generate_random_key(); + if let Some(parent) = self.key_path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&self.key_path, hex_encode(&key)) + .context("Failed to write secret key file")?; + + // Set restrictive permissions (Unix only) + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&self.key_path, fs::Permissions::from_mode(0o600)) + .context("Failed to set key file permissions")?; + } + + Ok(key) + } + } +} + +/// XOR cipher with repeating key. Same function for encrypt and decrypt. +fn xor_cipher(data: &[u8], key: &[u8]) -> Vec { + if key.is_empty() { + return data.to_vec(); + } + data.iter() + .enumerate() + .map(|(i, &b)| b ^ key[i % key.len()]) + .collect() +} + +/// Generate a random key using system entropy (UUID v4 + process ID + timestamp). +fn generate_random_key() -> Vec { + // Use two UUIDs (32 random bytes) as our key material + let u1 = uuid::Uuid::new_v4(); + let u2 = uuid::Uuid::new_v4(); + let mut key = Vec::with_capacity(KEY_LEN); + key.extend_from_slice(u1.as_bytes()); + key.extend_from_slice(u2.as_bytes()); + key.truncate(KEY_LEN); + key +} + +/// Hex-encode bytes to a lowercase hex string. +fn hex_encode(data: &[u8]) -> String { + let mut s = String::with_capacity(data.len() * 2); + for b in data { + use std::fmt::Write; + let _ = write!(s, "{b:02x}"); + } + s +} + +/// Hex-decode a hex string to bytes. +fn hex_decode(hex: &str) -> Result> { + if !hex.len().is_multiple_of(2) { + anyhow::bail!("Hex string has odd length"); + } + (0..hex.len()) + .step_by(2) + .map(|i| { + u8::from_str_radix(&hex[i..i + 2], 16) + .map_err(|e| anyhow::anyhow!("Invalid hex at position {i}: {e}")) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + // ── SecretStore basics ───────────────────────────────────── + + #[test] + fn encrypt_decrypt_roundtrip() { + let tmp = TempDir::new().unwrap(); + let store = SecretStore::new(tmp.path(), true); + let secret = "sk-my-secret-api-key-12345"; + + let encrypted = store.encrypt(secret).unwrap(); + assert!(encrypted.starts_with("enc:"), "Should have enc: prefix"); + assert_ne!(encrypted, secret, "Should not be plaintext"); + + let decrypted = store.decrypt(&encrypted).unwrap(); + assert_eq!(decrypted, secret, "Roundtrip must preserve original"); + } + + #[test] + fn encrypt_empty_returns_empty() { + let tmp = TempDir::new().unwrap(); + let store = SecretStore::new(tmp.path(), true); + let result = store.encrypt("").unwrap(); + assert_eq!(result, ""); + } + + #[test] + fn decrypt_plaintext_passthrough() { + let tmp = TempDir::new().unwrap(); + let store = SecretStore::new(tmp.path(), true); + // Values without "enc:" prefix are returned as-is (backward compat) + let result = store.decrypt("sk-plaintext-key").unwrap(); + assert_eq!(result, "sk-plaintext-key"); + } + + #[test] + fn disabled_store_returns_plaintext() { + let tmp = TempDir::new().unwrap(); + let store = SecretStore::new(tmp.path(), false); + let result = store.encrypt("sk-secret").unwrap(); + assert_eq!(result, "sk-secret", "Disabled store should not encrypt"); + } + + #[test] + fn is_encrypted_detects_prefix() { + assert!(SecretStore::is_encrypted("enc:aabbcc")); + assert!(!SecretStore::is_encrypted("sk-plaintext")); + assert!(!SecretStore::is_encrypted("")); + } + + #[test] + fn key_file_created_on_first_encrypt() { + let tmp = TempDir::new().unwrap(); + let store = SecretStore::new(tmp.path(), true); + assert!(!store.key_path.exists()); + + store.encrypt("test").unwrap(); + assert!(store.key_path.exists(), "Key file should be created"); + + let key_hex = fs::read_to_string(&store.key_path).unwrap(); + assert_eq!( + key_hex.len(), + KEY_LEN * 2, + "Key should be {KEY_LEN} bytes hex-encoded" + ); + } + + #[test] + fn same_key_used_across_calls() { + let tmp = TempDir::new().unwrap(); + let store = SecretStore::new(tmp.path(), true); + + let e1 = store.encrypt("secret").unwrap(); + let e2 = store.encrypt("secret").unwrap(); + assert_eq!(e1, e2, "Same key should produce same ciphertext"); + } + + #[test] + fn different_stores_same_dir_interop() { + let tmp = TempDir::new().unwrap(); + let store1 = SecretStore::new(tmp.path(), true); + let store2 = SecretStore::new(tmp.path(), true); + + let encrypted = store1.encrypt("cross-store-secret").unwrap(); + let decrypted = store2.decrypt(&encrypted).unwrap(); + assert_eq!(decrypted, "cross-store-secret"); + } + + #[test] + fn unicode_secret_roundtrip() { + let tmp = TempDir::new().unwrap(); + let store = SecretStore::new(tmp.path(), true); + let secret = "sk-日本語テスト-émojis-🦀"; + + let encrypted = store.encrypt(secret).unwrap(); + let decrypted = store.decrypt(&encrypted).unwrap(); + assert_eq!(decrypted, secret); + } + + #[test] + fn long_secret_roundtrip() { + let tmp = TempDir::new().unwrap(); + let store = SecretStore::new(tmp.path(), true); + let secret = "a".repeat(10_000); + + let encrypted = store.encrypt(&secret).unwrap(); + let decrypted = store.decrypt(&encrypted).unwrap(); + assert_eq!(decrypted, secret); + } + + #[test] + fn corrupt_hex_returns_error() { + let tmp = TempDir::new().unwrap(); + let store = SecretStore::new(tmp.path(), true); + let result = store.decrypt("enc:not-valid-hex!!"); + assert!(result.is_err()); + } + + // ── Low-level helpers ─────────────────────────────────────── + + #[test] + fn xor_cipher_roundtrip() { + let key = b"testkey123"; + let data = b"hello world"; + let encrypted = xor_cipher(data, key); + let decrypted = xor_cipher(&encrypted, key); + assert_eq!(decrypted, data); + } + + #[test] + fn xor_cipher_empty_key() { + let data = b"passthrough"; + let result = xor_cipher(data, &[]); + assert_eq!(result, data); + } + + #[test] + fn hex_roundtrip() { + let data = vec![0x00, 0x01, 0xfe, 0xff, 0xab, 0xcd]; + let encoded = hex_encode(&data); + assert_eq!(encoded, "0001feffabcd"); + let decoded = hex_decode(&encoded).unwrap(); + assert_eq!(decoded, data); + } + + #[test] + fn hex_decode_odd_length_fails() { + assert!(hex_decode("abc").is_err()); + } + + #[test] + fn hex_decode_invalid_chars_fails() { + assert!(hex_decode("zzzz").is_err()); + } + + #[test] + fn generate_random_key_correct_length() { + let key = generate_random_key(); + assert_eq!(key.len(), KEY_LEN); + } + + #[test] + fn generate_random_key_not_all_zeros() { + let key = generate_random_key(); + assert!(key.iter().any(|&b| b != 0), "Key should not be all zeros"); + } + + #[test] + fn two_random_keys_differ() { + let k1 = generate_random_key(); + let k2 = generate_random_key(); + assert_ne!(k1, k2, "Two random keys should differ"); + } + + #[cfg(unix)] + #[test] + fn key_file_has_restricted_permissions() { + use std::os::unix::fs::PermissionsExt; + let tmp = TempDir::new().unwrap(); + let store = SecretStore::new(tmp.path(), true); + store.encrypt("trigger key creation").unwrap(); + + let perms = fs::metadata(&store.key_path).unwrap().permissions(); + assert_eq!( + perms.mode() & 0o777, + 0o600, + "Key file must be owner-only (0600)" + ); + } +} diff --git a/src/tools/composio.rs b/src/tools/composio.rs new file mode 100644 index 0000000..479f774 --- /dev/null +++ b/src/tools/composio.rs @@ -0,0 +1,403 @@ +// Composio Tool Provider — optional managed tool surface with 1000+ OAuth integrations. +// +// When enabled, ZeroClaw can execute actions on Gmail, Notion, GitHub, Slack, etc. +// through Composio's API without storing raw OAuth tokens locally. +// +// This is opt-in. Users who prefer sovereign/local-only mode skip this entirely. +// The Composio API key is stored in the encrypted secret store. + +use super::traits::{Tool, ToolResult}; +use async_trait::async_trait; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +const COMPOSIO_API_BASE: &str = "https://backend.composio.dev/api/v2"; + +/// A tool that proxies actions to the Composio managed tool platform. +pub struct ComposioTool { + api_key: String, + client: Client, +} + +impl ComposioTool { + pub fn new(api_key: &str) -> Self { + Self { + api_key: api_key.to_string(), + client: Client::builder() + .timeout(std::time::Duration::from_secs(60)) + .connect_timeout(std::time::Duration::from_secs(10)) + .build() + .unwrap_or_else(|_| Client::new()), + } + } + + /// List available Composio apps/actions for the authenticated user. + pub async fn list_actions( + &self, + app_name: Option<&str>, + ) -> anyhow::Result> { + let mut url = format!("{COMPOSIO_API_BASE}/actions"); + if let Some(app) = app_name { + url = format!("{url}?appNames={app}"); + } + + let resp = self + .client + .get(&url) + .header("x-api-key", &self.api_key) + .send() + .await?; + + if !resp.status().is_success() { + let err = resp.text().await.unwrap_or_default(); + anyhow::bail!("Composio API error: {err}"); + } + + let body: ComposioActionsResponse = resp.json().await?; + Ok(body.items) + } + + /// Execute a Composio action by name with given parameters. + pub async fn execute_action( + &self, + action_name: &str, + params: serde_json::Value, + entity_id: Option<&str>, + ) -> anyhow::Result { + let url = format!("{COMPOSIO_API_BASE}/actions/{action_name}/execute"); + + let mut body = json!({ + "input": params, + }); + + if let Some(entity) = entity_id { + body["entityId"] = json!(entity); + } + + let resp = self + .client + .post(&url) + .header("x-api-key", &self.api_key) + .json(&body) + .send() + .await?; + + if !resp.status().is_success() { + let err = resp.text().await.unwrap_or_default(); + anyhow::bail!("Composio action execution failed: {err}"); + } + + let result: serde_json::Value = resp.json().await?; + Ok(result) + } + + /// Get the OAuth connection URL for a specific app. + pub async fn get_connection_url( + &self, + app_name: &str, + entity_id: &str, + ) -> anyhow::Result { + let url = format!("{COMPOSIO_API_BASE}/connectedAccounts"); + + let body = json!({ + "integrationId": app_name, + "entityId": entity_id, + }); + + let resp = self + .client + .post(&url) + .header("x-api-key", &self.api_key) + .json(&body) + .send() + .await?; + + if !resp.status().is_success() { + let err = resp.text().await.unwrap_or_default(); + anyhow::bail!("Failed to get connection URL: {err}"); + } + + let result: serde_json::Value = resp.json().await?; + result + .get("redirectUrl") + .and_then(|v| v.as_str()) + .map(String::from) + .ok_or_else(|| anyhow::anyhow!("No redirect URL in response")) + } +} + +#[async_trait] +impl Tool for ComposioTool { + fn name(&self) -> &str { + "composio" + } + + fn description(&self) -> &str { + "Execute actions on 1000+ apps via Composio (Gmail, Notion, GitHub, Slack, etc.). \ + Use action='list' to see available actions, or action='execute' with action_name and params." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "action": { + "type": "string", + "description": "The operation: 'list' (list available actions), 'execute' (run an action), or 'connect' (get OAuth URL)", + "enum": ["list", "execute", "connect"] + }, + "app": { + "type": "string", + "description": "App name filter for 'list', or app name for 'connect' (e.g. 'gmail', 'notion', 'github')" + }, + "action_name": { + "type": "string", + "description": "The Composio action name to execute (e.g. 'GMAIL_FETCH_EMAILS')" + }, + "params": { + "type": "object", + "description": "Parameters to pass to the action" + }, + "entity_id": { + "type": "string", + "description": "Entity ID for multi-user setups (defaults to 'default')" + } + }, + "required": ["action"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let action = args + .get("action") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'action' parameter"))?; + + let entity_id = args + .get("entity_id") + .and_then(|v| v.as_str()) + .unwrap_or("default"); + + match action { + "list" => { + let app = args.get("app").and_then(|v| v.as_str()); + match self.list_actions(app).await { + Ok(actions) => { + let summary: Vec = actions + .iter() + .take(20) + .map(|a| { + format!( + "- {} ({}): {}", + a.name, + a.app_name.as_deref().unwrap_or("?"), + a.description.as_deref().unwrap_or("") + ) + }) + .collect(); + let total = actions.len(); + let output = format!( + "Found {total} available actions:\n{}{}", + summary.join("\n"), + if total > 20 { + format!("\n... and {} more", total - 20) + } else { + String::new() + } + ); + Ok(ToolResult { + success: true, + output, + error: None, + }) + } + Err(e) => Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("Failed to list actions: {e}")), + }), + } + } + + "execute" => { + let action_name = args + .get("action_name") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'action_name' for execute"))?; + + let params = args.get("params").cloned().unwrap_or(json!({})); + + match self + .execute_action(action_name, params, Some(entity_id)) + .await + { + Ok(result) => { + let output = serde_json::to_string_pretty(&result) + .unwrap_or_else(|_| format!("{result:?}")); + Ok(ToolResult { + success: true, + output, + error: None, + }) + } + Err(e) => Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("Action execution failed: {e}")), + }), + } + } + + "connect" => { + let app = args + .get("app") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'app' for connect"))?; + + match self.get_connection_url(app, entity_id).await { + Ok(url) => Ok(ToolResult { + success: true, + output: format!("Open this URL to connect {app}:\n{url}"), + error: None, + }), + Err(e) => Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("Failed to get connection URL: {e}")), + }), + } + } + + _ => Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!( + "Unknown action '{action}'. Use 'list', 'execute', or 'connect'." + )), + }), + } + } +} + +// ── API response types ────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +struct ComposioActionsResponse { + #[serde(default)] + items: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ComposioAction { + pub name: String, + #[serde(rename = "appName")] + pub app_name: Option, + pub description: Option, + #[serde(default)] + pub enabled: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── Constructor ─────────────────────────────────────────── + + #[test] + fn composio_tool_has_correct_name() { + let tool = ComposioTool::new("test-key"); + assert_eq!(tool.name(), "composio"); + } + + #[test] + fn composio_tool_has_description() { + let tool = ComposioTool::new("test-key"); + assert!(!tool.description().is_empty()); + assert!(tool.description().contains("1000+")); + } + + #[test] + fn composio_tool_schema_has_required_fields() { + let tool = ComposioTool::new("test-key"); + let schema = tool.parameters_schema(); + assert!(schema["properties"]["action"].is_object()); + assert!(schema["properties"]["action_name"].is_object()); + assert!(schema["properties"]["params"].is_object()); + assert!(schema["properties"]["app"].is_object()); + let required = schema["required"].as_array().unwrap(); + assert!(required.contains(&json!("action"))); + } + + #[test] + fn composio_tool_spec_roundtrip() { + let tool = ComposioTool::new("test-key"); + let spec = tool.spec(); + assert_eq!(spec.name, "composio"); + assert!(spec.parameters.is_object()); + } + + // ── Execute validation ──────────────────────────────────── + + #[tokio::test] + async fn execute_missing_action_returns_error() { + let tool = ComposioTool::new("test-key"); + let result = tool.execute(json!({})).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn execute_unknown_action_returns_error() { + let tool = ComposioTool::new("test-key"); + let result = tool.execute(json!({"action": "unknown"})).await.unwrap(); + assert!(!result.success); + assert!(result.error.as_ref().unwrap().contains("Unknown action")); + } + + #[tokio::test] + async fn execute_without_action_name_returns_error() { + let tool = ComposioTool::new("test-key"); + let result = tool.execute(json!({"action": "execute"})).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn connect_without_app_returns_error() { + let tool = ComposioTool::new("test-key"); + let result = tool.execute(json!({"action": "connect"})).await; + assert!(result.is_err()); + } + + // ── API response parsing ────────────────────────────────── + + #[test] + fn composio_action_deserializes() { + let json_str = r#"{"name": "GMAIL_FETCH_EMAILS", "appName": "gmail", "description": "Fetch emails", "enabled": true}"#; + let action: ComposioAction = serde_json::from_str(json_str).unwrap(); + assert_eq!(action.name, "GMAIL_FETCH_EMAILS"); + assert_eq!(action.app_name.as_deref(), Some("gmail")); + assert!(action.enabled); + } + + #[test] + fn composio_actions_response_deserializes() { + let json_str = r#"{"items": [{"name": "TEST_ACTION", "appName": "test", "description": "A test", "enabled": true}]}"#; + let resp: ComposioActionsResponse = serde_json::from_str(json_str).unwrap(); + assert_eq!(resp.items.len(), 1); + assert_eq!(resp.items[0].name, "TEST_ACTION"); + } + + #[test] + fn composio_actions_response_empty() { + let json_str = r#"{"items": []}"#; + let resp: ComposioActionsResponse = serde_json::from_str(json_str).unwrap(); + assert!(resp.items.is_empty()); + } + + #[test] + fn composio_actions_response_missing_items_defaults() { + let json_str = r#"{}"#; + let resp: ComposioActionsResponse = serde_json::from_str(json_str).unwrap(); + assert!(resp.items.is_empty()); + } +} diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 0800b2e..617c0dd 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -1,3 +1,4 @@ +pub mod composio; pub mod file_read; pub mod file_write; pub mod memory_forget; @@ -6,6 +7,7 @@ pub mod memory_store; pub mod shell; pub mod traits; +pub use composio::ComposioTool; pub use file_read::FileReadTool; pub use file_write::FileWriteTool; pub use memory_forget::MemoryForgetTool; @@ -31,16 +33,28 @@ pub fn default_tools(security: Arc) -> Vec> { ] } -/// Create full tool registry including memory tools -pub fn all_tools(security: Arc, memory: Arc) -> Vec> { - vec![ +/// Create full tool registry including memory tools and optional Composio +pub fn all_tools( + security: Arc, + memory: Arc, + composio_key: Option<&str>, +) -> Vec> { + let mut tools: Vec> = vec![ Box::new(ShellTool::new(security.clone())), Box::new(FileReadTool::new(security.clone())), Box::new(FileWriteTool::new(security)), Box::new(MemoryStoreTool::new(memory.clone())), Box::new(MemoryRecallTool::new(memory.clone())), Box::new(MemoryForgetTool::new(memory)), - ] + ]; + + if let Some(key) = composio_key { + if !key.is_empty() { + tools.push(Box::new(ComposioTool::new(key))); + } + } + + tools } pub async fn handle_command(command: super::ToolCommands, config: Config) -> Result<()> { @@ -53,7 +67,12 @@ pub async fn handle_command(command: super::ToolCommands, config: Config) -> Res &config.workspace_dir, config.api_key.as_deref(), )?); - let tools_list = all_tools(security, mem); + let composio_key = if config.composio.enabled { + config.composio.api_key.as_deref() + } else { + None + }; + let tools_list = all_tools(security, mem, composio_key); match command { super::ToolCommands::List => {