feat: add Composio tool provider + encrypted secret store + wizard integration

- src/tools/composio.rs: ComposioTool implementing Tool trait
  - list/execute/connect actions via Composio API (1000+ OAuth apps)
  - 60s timeout, proper error handling, JSON schema for LLM
  - 12 tests covering schema, validation, serde, error paths

- src/security/secrets.rs: SecretStore for encrypted credential storage
  - XOR cipher with random 32-byte key stored in ~/.zeroclaw/.secret_key
  - enc: prefix for encrypted values, plaintext passthrough (backward compat)
  - Key file created with 0600 permissions (Unix)
  - 16 tests: roundtrip, unicode, long secrets, corrupt hex, permissions

- src/config/schema.rs: ComposioConfig + SecretsConfig structs
  - Composio: enabled (default: false), api_key, entity_id
  - Secrets: encrypt (default: true)
  - Both with serde(default) for backward compatibility
  - 8 new config tests

- src/onboard/wizard.rs: new Step 5 'Tool Mode & Security'
  - Sovereign (local only) vs Composio (managed OAuth) selection
  - Encrypted secret storage toggle (default: on)
  - 7-step wizard (was 6)

- src/tools/mod.rs: all_tools() now accepts optional composio_key
- src/agent/loop_.rs: wires Composio key from config into tool registry
- README.md: Composio integration + encrypted secrets documentation

1017 tests, 0 clippy warnings, cargo fmt clean.
This commit is contained in:
argenis de la rosa 2026-02-14 02:41:29 -05:00
parent 976c5bbf3c
commit f8befafe4d
9 changed files with 1087 additions and 24 deletions

View file

@ -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 | | **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 | | **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 | | **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 | | **Observability** | `Observer` | Noop, Log, Multi | Prometheus, OTel |
| **Runtime** | `RuntimeAdapter` | Native (Mac/Linux/Pi) | Docker, WASM | | **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 | | **Tunnel** | `Tunnel` | None, Cloudflare, Tailscale, ngrok, Custom | Any tunnel binary |
| **Heartbeat** | Engine | HEARTBEAT.md periodic tasks | — | | **Heartbeat** | Engine | HEARTBEAT.md periodic tasks | — |
| **Skills** | Loader | TOML manifests + SKILL.md instructions | Community skill packs | | **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. 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 ## Configuration
Config: `~/.zeroclaw/config.toml` (created by `onboard`) Config: `~/.zeroclaw/config.toml` (created by `onboard`)
@ -263,6 +306,13 @@ interval_minutes = 30
[tunnel] [tunnel]
provider = "none" # "none", "cloudflare", "tailscale", "ngrok", "custom" 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 ## 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` | Start webhook server (default: `127.0.0.1:8080`) |
| `gateway --port 0` | Random port mode | | `gateway --port 0` | Random port mode |
| `status -v` | Show full system status | | `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 <name> <json>` | Test a tool directly | | `tools test <name> <json>` | Test a tool directly |
| `integrations list` | List all 50+ integrations | | `integrations list` | List all 50+ integrations |
@ -419,13 +469,14 @@ src/
├── runtime/ # RuntimeAdapter trait + Native ├── runtime/ # RuntimeAdapter trait + Native
│ ├── traits.rs │ ├── traits.rs
│ └── native.rs │ └── native.rs
├── security/ # Security policy + gateway pairing ├── security/ # Security policy + gateway pairing + secrets
│ ├── policy.rs # SecurityPolicy, path validation, rate limiting │ ├── policy.rs # SecurityPolicy, path validation, rate limiting
│ ├── pairing.rs # PairingGuard, OTP, bearer tokens │ ├── pairing.rs # PairingGuard, OTP, bearer tokens
│ ├── secrets.rs # Encrypted secret store (XOR + local key file)
│ └── mod.rs │ └── mod.rs
├── skills/ # Skill loader (TOML manifests) ├── skills/ # Skill loader (TOML manifests)
│ └── mod.rs │ └── mod.rs
├── tools/ # Tool trait + 6 tools ├── tools/ # Tool trait + 7 tools
│ ├── traits.rs # Tool trait definition │ ├── traits.rs # Tool trait definition
│ ├── shell.rs # Shell command execution │ ├── shell.rs # Shell command execution
│ ├── file_read.rs # Sandboxed file reading │ ├── file_read.rs # Sandboxed file reading
@ -433,6 +484,7 @@ src/
│ ├── memory_store.rs # Store to memory │ ├── memory_store.rs # Store to memory
│ ├── memory_recall.rs # Search memory │ ├── memory_recall.rs # Search memory
│ ├── memory_forget.rs # Delete from memory │ ├── memory_forget.rs # Delete from memory
│ ├── composio.rs # Composio managed OAuth tools (optional)
│ └── mod.rs # Registry │ └── mod.rs # Registry
└── tunnel/ # Tunnel trait + 5 implementations └── tunnel/ # Tunnel trait + 5 implementations
├── none.rs # Local-only (default) ├── none.rs # Local-only (default)

View file

@ -54,7 +54,12 @@ pub async fn run(
tracing::info!(backend = mem.name(), "Memory initialized"); tracing::info!(backend = mem.name(), "Memory initialized");
// ── Tools (including memory tools) ──────────────────────────── // ── 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 ───────────────────────────────────────── // ── Resolve provider ─────────────────────────────────────────
let provider_name = provider_override let provider_name = provider_override

View file

@ -1,7 +1,7 @@
pub mod schema; pub mod schema;
pub use schema::{ pub use schema::{
AutonomyConfig, ChannelsConfig, Config, DiscordConfig, GatewayConfig, HeartbeatConfig, AutonomyConfig, ChannelsConfig, ComposioConfig, Config, DiscordConfig, GatewayConfig,
IMessageConfig, MatrixConfig, MemoryConfig, ObservabilityConfig, RuntimeConfig, SlackConfig, HeartbeatConfig, IMessageConfig, MatrixConfig, MemoryConfig, ObservabilityConfig,
TelegramConfig, TunnelConfig, WebhookConfig, RuntimeConfig, SecretsConfig, SlackConfig, TelegramConfig, TunnelConfig, WebhookConfig,
}; };

View file

@ -39,6 +39,12 @@ pub struct Config {
#[serde(default)] #[serde(default)]
pub gateway: GatewayConfig, pub gateway: GatewayConfig,
#[serde(default)]
pub composio: ComposioConfig,
#[serde(default)]
pub secrets: SecretsConfig,
} }
// ── Gateway security ───────────────────────────────────────────── // ── 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<String>,
/// 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 ─────────────────────────────────────────────────── // ── Memory ───────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -403,6 +453,8 @@ impl Default for Config {
memory: MemoryConfig::default(), memory: MemoryConfig::default(),
tunnel: TunnelConfig::default(), tunnel: TunnelConfig::default(),
gateway: GatewayConfig::default(), gateway: GatewayConfig::default(),
composio: ComposioConfig::default(),
secrets: SecretsConfig::default(),
} }
} }
} }
@ -542,6 +594,8 @@ mod tests {
memory: MemoryConfig::default(), memory: MemoryConfig::default(),
tunnel: TunnelConfig::default(), tunnel: TunnelConfig::default(),
gateway: GatewayConfig::default(), gateway: GatewayConfig::default(),
composio: ComposioConfig::default(),
secrets: SecretsConfig::default(),
}; };
let toml_str = toml::to_string_pretty(&config).unwrap(); let toml_str = toml::to_string_pretty(&config).unwrap();
@ -603,6 +657,8 @@ default_temperature = 0.7
memory: MemoryConfig::default(), memory: MemoryConfig::default(),
tunnel: TunnelConfig::default(), tunnel: TunnelConfig::default(),
gateway: GatewayConfig::default(), gateway: GatewayConfig::default(),
composio: ComposioConfig::default(),
secrets: SecretsConfig::default(),
}; };
config.save().unwrap(); config.save().unwrap();
@ -913,4 +969,96 @@ default_temperature = 0.7
"Must block ~/.ssh" "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);
}
} }

View file

@ -1,7 +1,7 @@
use crate::config::{ use crate::config::{
AutonomyConfig, ChannelsConfig, Config, DiscordConfig, HeartbeatConfig, IMessageConfig, AutonomyConfig, ChannelsConfig, ComposioConfig, Config, DiscordConfig, HeartbeatConfig,
MatrixConfig, MemoryConfig, ObservabilityConfig, RuntimeConfig, SlackConfig, TelegramConfig, IMessageConfig, MatrixConfig, MemoryConfig, ObservabilityConfig, RuntimeConfig, SecretsConfig,
WebhookConfig, SlackConfig, TelegramConfig, WebhookConfig,
}; };
use crate::security::AutonomyLevel; use crate::security::AutonomyLevel;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
@ -55,22 +55,25 @@ pub fn run_wizard() -> Result<Config> {
); );
println!(); println!();
print_step(1, 6, "Workspace Setup"); print_step(1, 7, "Workspace Setup");
let (workspace_dir, config_path) = setup_workspace()?; 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()?; 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()?; 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()?; 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()?; let project_ctx = setup_project_context()?;
print_step(6, 6, "Workspace Files"); print_step(7, 7, "Workspace Files");
scaffold_workspace(&workspace_dir, &project_ctx)?; scaffold_workspace(&workspace_dir, &project_ctx)?;
// ── Build config ── // ── Build config ──
@ -98,6 +101,8 @@ pub fn run_wizard() -> Result<Config> {
memory: MemoryConfig::default(), // SQLite + auto-save by default memory: MemoryConfig::default(), // SQLite + auto-save by default
tunnel: tunnel_config, tunnel: tunnel_config,
gateway: crate::config::GatewayConfig::default(), gateway: crate::config::GatewayConfig::default(),
composio: composio_config,
secrets: secrets_config,
}; };
println!( 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<ProjectContext> { fn setup_project_context() -> Result<ProjectContext> {
print_bullet("Let's personalize your agent. You can always update these later."); print_bullet("Let's personalize your agent. You can always update these later.");

View file

@ -1,6 +1,9 @@
pub mod pairing; pub mod pairing;
pub mod policy; pub mod policy;
pub mod secrets;
#[allow(unused_imports)] #[allow(unused_imports)]
pub use pairing::PairingGuard; pub use pairing::PairingGuard;
pub use policy::{AutonomyLevel, SecurityPolicy}; pub use policy::{AutonomyLevel, SecurityPolicy};
#[allow(unused_imports)]
pub use secrets::SecretStore;

338
src/security/secrets.rs Normal file
View file

@ -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<String> {
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<String> {
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<Vec<u8>> {
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<u8> {
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<u8> {
// 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<Vec<u8>> {
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)"
);
}
}

403
src/tools/composio.rs Normal file
View file

@ -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<Vec<ComposioAction>> {
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<serde_json::Value> {
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<String> {
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<ToolResult> {
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<String> = 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<ComposioAction>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComposioAction {
pub name: String,
#[serde(rename = "appName")]
pub app_name: Option<String>,
pub description: Option<String>,
#[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());
}
}

View file

@ -1,3 +1,4 @@
pub mod composio;
pub mod file_read; pub mod file_read;
pub mod file_write; pub mod file_write;
pub mod memory_forget; pub mod memory_forget;
@ -6,6 +7,7 @@ pub mod memory_store;
pub mod shell; pub mod shell;
pub mod traits; pub mod traits;
pub use composio::ComposioTool;
pub use file_read::FileReadTool; pub use file_read::FileReadTool;
pub use file_write::FileWriteTool; pub use file_write::FileWriteTool;
pub use memory_forget::MemoryForgetTool; pub use memory_forget::MemoryForgetTool;
@ -31,16 +33,28 @@ pub fn default_tools(security: Arc<SecurityPolicy>) -> Vec<Box<dyn Tool>> {
] ]
} }
/// Create full tool registry including memory tools /// Create full tool registry including memory tools and optional Composio
pub fn all_tools(security: Arc<SecurityPolicy>, memory: Arc<dyn Memory>) -> Vec<Box<dyn Tool>> { pub fn all_tools(
vec![ security: Arc<SecurityPolicy>,
memory: Arc<dyn Memory>,
composio_key: Option<&str>,
) -> Vec<Box<dyn Tool>> {
let mut tools: Vec<Box<dyn Tool>> = vec![
Box::new(ShellTool::new(security.clone())), Box::new(ShellTool::new(security.clone())),
Box::new(FileReadTool::new(security.clone())), Box::new(FileReadTool::new(security.clone())),
Box::new(FileWriteTool::new(security)), Box::new(FileWriteTool::new(security)),
Box::new(MemoryStoreTool::new(memory.clone())), Box::new(MemoryStoreTool::new(memory.clone())),
Box::new(MemoryRecallTool::new(memory.clone())), Box::new(MemoryRecallTool::new(memory.clone())),
Box::new(MemoryForgetTool::new(memory)), 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<()> { 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.workspace_dir,
config.api_key.as_deref(), 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 { match command {
super::ToolCommands::List => { super::ToolCommands::List => {