From a5887ad2dc0f67f2d7e0075824a3f2462eb2a7c7 Mon Sep 17 00:00:00 2001 From: argenis de la rosa Date: Fri, 13 Feb 2026 16:00:15 -0500 Subject: [PATCH] docs+tests: architecture diagram, security docs, 75 new edge-case tests README: - Add ASCII architecture flow diagram showing all layers - Add Security Architecture section (Layer 1: Channel Auth, Layer 2: Rate Limiting, Layer 3: Tool Sandbox) - Update test count to 629 New edge-case tests (75 new): - SecurityPolicy: command injection (semicolon, backtick, dollar-paren, env prefix, newline), path traversal (encoded dots, double-dot in filename, null byte, symlink, tilde-ssh, /var/run), rate limiter boundaries (exactly-at, zero, high), autonomy+command combos, from_config fresh tracker - Discord: exact match not substring, empty user ID, wildcard+specific, case sensitivity, base64 edge cases - Slack: exact match, empty user ID, case sensitivity, wildcard combo - Telegram: exact match, empty string, case sensitivity, wildcard combo - Gateway: first-match-wins, empty value, colon in value, different headers, empty request, newline-only request - Config schema: backward compat (Discord/Slack without allowed_users), TOML roundtrip, webhook secret presence/absence 629 tests passing, 0 clippy warnings --- README.md | 83 +++++++++++++++++-- src/channels/discord.rs | 47 +++++++++++ src/channels/slack.rs | 27 ++++++ src/channels/telegram.rs | 30 +++++++ src/config/schema.rs | 68 +++++++++++++++ src/gateway/mod.rs | 36 ++++++++ src/security/policy.rs | 175 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 460 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index e7955bf..5225a76 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ The fastest, smallest, fully autonomous AI assistant — deploy anywhere, swap anything. ``` -~3MB binary · <10ms startup · 502 tests · 22 providers · Pluggable everything +~3MB binary · <10ms startup · 629 tests · 22 providers · Pluggable everything ``` ## Quick Start @@ -54,6 +54,51 @@ cargo run --release -- tools test memory_recall '{"query": "Rust"}' Every subsystem is a **trait** — swap implementations with a config change, zero code changes. +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ ZeroClaw Architecture │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────┐ ┌──────────────────────────────────────────┐ │ +│ │ Chat Apps │ │ Security Layer │ │ +│ │ │ │ │ │ +│ │ Telegram ───┤ │ ┌─────────────┐ ┌──────────────────┐ │ │ +│ │ Discord ───┤ │ │ Auth Gate │ │ Rate Limiter │ │ │ +│ │ Slack ───┼───►│ │ │ │ │ │ │ +│ │ iMessage ───┤ │ │ • allowed_ │ │ • sliding window │ │ │ +│ │ Matrix ───┤ │ │ users │ │ • max actions/hr │ │ │ +│ │ CLI ───┤ │ │ • webhook │ │ • max cost/day │ │ │ +│ │ Webhook ───┤ │ │ secret │ │ │ │ │ +│ └──────────────┘ │ └──────┬──────┘ └────────┬─────────┘ │ │ +│ │ │ │ │ │ +│ └─────────┼──────────────────┼────────────┘ │ +│ ▼ ▼ │ +│ ┌──────────────────────────────────────┐ │ +│ │ Agent Loop │ │ +│ │ │ │ +│ │ Message ──► LLM ──► Tools ──► Reply │ │ +│ │ ▲ │ │ │ +│ │ │ ┌─────────────┘ │ │ +│ │ │ ▼ │ │ +│ │ ┌──────────────┐ ┌─────────────┐ │ │ +│ │ │ Context │ │ Sandbox │ │ │ +│ │ │ │ │ │ │ │ +│ │ │ • Memory │ │ • allowlist │ │ │ +│ │ │ • Skills │ │ • path jail │ │ │ +│ │ │ • Workspace │ │ • forbidden │ │ │ +│ │ │ MD files │ │ paths │ │ │ +│ │ └──────────────┘ └─────────────┘ │ │ +│ └──────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────────────┐ │ +│ │ AI Providers (22) │ │ +│ │ OpenRouter · Anthropic · OpenAI · Mistral · Groq · Venice │ │ +│ │ Ollama · xAI · DeepSeek · Cerebras · Fireworks · Together │ │ +│ │ Cloudflare · Moonshot · GLM · MiniMax · Qianfan · + more │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + | Subsystem | Trait | Ships with | Extend | |-----------|-------|------------|--------| | **AI Models** | `Provider` | 22 providers (OpenRouter, Anthropic, OpenAI, Venice, Groq, Mistral, etc.) | Any OpenAI-compatible API | @@ -74,13 +119,39 @@ ZeroClaw has a built-in brain. The agent automatically: Two backends — **SQLite** (default, searchable, upsert, delete) and **Markdown** (human-readable, append-only, git-friendly). Switch with one config line. -### Security +### Security Architecture + +ZeroClaw enforces security at **every layer** — not just the sandbox. Every message passes through authentication and rate limiting before reaching the agent. + +#### Layer 1: Channel Authentication + +Every channel validates the sender **before** the message reaches the agent loop: + +| Channel | Auth Method | Config | +|---------|------------|--------| +| **Telegram** | `allowed_users` list (username match) | `[channels.telegram] allowed_users` | +| **Discord** | `allowed_users` list (user ID match) | `[channels.discord] allowed_users` | +| **Slack** | `allowed_users` list (user ID match) | `[channels.slack] allowed_users` | +| **Matrix** | `allowed_users` list (MXID match) | `[channels.matrix] allowed_users` | +| **iMessage** | `allowed_contacts` list | `[channels.imessage] allowed_contacts` | +| **Webhook** | `X-Webhook-Secret` header (shared secret) | `[channels.webhook] secret` | +| **CLI** | Local-only (inherently trusted) | — | + +> **Note:** An empty `allowed_users` list or `["*"]` allows all users (open mode). Set specific IDs for production. + +#### Layer 2: Rate Limiting + +- **Sliding-window tracker** — counts actions within a 1-hour rolling window +- **`max_actions_per_hour`** — hard cap on tool executions (default: 20) +- **`max_cost_per_day_cents`** — daily cost ceiling (default: $5.00) + +#### Layer 3: Tool Sandbox - **Workspace sandboxing** — can't escape workspace directory -- **Command allowlisting** — only approved shell commands +- **Command allowlisting** — only approved shell commands (`git`, `cargo`, `ls`, etc.) - **Path traversal blocking** — `..` and absolute paths blocked -- **Rate limiting** — max actions/hour, max cost/day -- **Autonomy levels** — ReadOnly, Supervised, Full +- **Forbidden paths** — `/etc`, `/root`, `~/.ssh`, `~/.gnupg` always blocked +- **Autonomy levels** — `ReadOnly` (observe only), `Supervised` (acts with policy), `Full` (autonomous within bounds) ## Configuration @@ -227,7 +298,7 @@ interval_minutes = 30 ```bash cargo build # Dev build cargo build --release # Release build (~3MB) -cargo test # 502 tests +cargo test # 629 tests cargo clippy # Lint (0 warnings) # Run the SQLite vs Markdown benchmark diff --git a/src/channels/discord.rs b/src/channels/discord.rs index 7267d07..efa03ea 100644 --- a/src/channels/discord.rs +++ b/src/channels/discord.rs @@ -308,4 +308,51 @@ mod tests { assert!(!ch.is_user_allowed("333")); assert!(!ch.is_user_allowed("unknown")); } + + #[test] + fn allowlist_is_exact_match_not_substring() { + let ch = DiscordChannel::new("fake".into(), None, vec!["111".into()]); + assert!(!ch.is_user_allowed("1111")); + assert!(!ch.is_user_allowed("11")); + assert!(!ch.is_user_allowed("0111")); + } + + #[test] + fn allowlist_empty_string_user_id() { + let ch = DiscordChannel::new("fake".into(), None, vec!["111".into()]); + assert!(!ch.is_user_allowed("")); + } + + #[test] + fn allowlist_with_wildcard_and_specific() { + let ch = DiscordChannel::new("fake".into(), None, vec!["111".into(), "*".into()]); + assert!(ch.is_user_allowed("111")); + assert!(ch.is_user_allowed("anyone_else")); + } + + #[test] + fn allowlist_case_sensitive() { + let ch = DiscordChannel::new("fake".into(), None, vec!["ABC".into()]); + assert!(ch.is_user_allowed("ABC")); + assert!(!ch.is_user_allowed("abc")); + assert!(!ch.is_user_allowed("Abc")); + } + + #[test] + fn base64_decode_empty_string() { + let decoded = base64_decode(""); + assert_eq!(decoded, Some(String::new())); + } + + #[test] + fn base64_decode_invalid_chars() { + let decoded = base64_decode("!!!!"); + assert!(decoded.is_none()); + } + + #[test] + fn bot_user_id_from_empty_token() { + let id = DiscordChannel::bot_user_id_from_token(""); + assert_eq!(id, Some(String::new())); + } } diff --git a/src/channels/slack.rs b/src/channels/slack.rs index 38e922f..662ee53 100644 --- a/src/channels/slack.rs +++ b/src/channels/slack.rs @@ -209,4 +209,31 @@ mod tests { assert!(ch.is_user_allowed("U222")); assert!(!ch.is_user_allowed("U333")); } + + #[test] + fn allowlist_exact_match_not_substring() { + let ch = SlackChannel::new("xoxb-fake".into(), None, vec!["U111".into()]); + assert!(!ch.is_user_allowed("U1111")); + assert!(!ch.is_user_allowed("U11")); + } + + #[test] + fn allowlist_empty_user_id() { + let ch = SlackChannel::new("xoxb-fake".into(), None, vec!["U111".into()]); + assert!(!ch.is_user_allowed("")); + } + + #[test] + fn allowlist_case_sensitive() { + let ch = SlackChannel::new("xoxb-fake".into(), None, vec!["U111".into()]); + assert!(ch.is_user_allowed("U111")); + assert!(!ch.is_user_allowed("u111")); + } + + #[test] + fn allowlist_wildcard_and_specific() { + let ch = SlackChannel::new("xoxb-fake".into(), None, vec!["U111".into(), "*".into()]); + assert!(ch.is_user_allowed("U111")); + assert!(ch.is_user_allowed("anyone")); + } } diff --git a/src/channels/telegram.rs b/src/channels/telegram.rs index 5d970f1..7e71cab 100644 --- a/src/channels/telegram.rs +++ b/src/channels/telegram.rs @@ -179,4 +179,34 @@ mod tests { let ch = TelegramChannel::new("t".into(), vec![]); assert!(!ch.is_user_allowed("anyone")); } + + #[test] + fn telegram_user_exact_match_not_substring() { + let ch = TelegramChannel::new("t".into(), vec!["alice".into()]); + assert!(!ch.is_user_allowed("alice_bot")); + assert!(!ch.is_user_allowed("alic")); + assert!(!ch.is_user_allowed("malice")); + } + + #[test] + fn telegram_user_empty_string_denied() { + let ch = TelegramChannel::new("t".into(), vec!["alice".into()]); + assert!(!ch.is_user_allowed("")); + } + + #[test] + fn telegram_user_case_sensitive() { + let ch = TelegramChannel::new("t".into(), vec!["Alice".into()]); + assert!(ch.is_user_allowed("Alice")); + assert!(!ch.is_user_allowed("alice")); + assert!(!ch.is_user_allowed("ALICE")); + } + + #[test] + fn telegram_wildcard_with_specific_users() { + let ch = TelegramChannel::new("t".into(), vec!["alice".into(), "*".into()]); + assert!(ch.is_user_allowed("alice")); + assert!(ch.is_user_allowed("bob")); + assert!(ch.is_user_allowed("anyone")); + } } diff --git a/src/config/schema.rs b/src/config/schema.rs index 0c937c4..a8ea616 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -583,4 +583,72 @@ default_temperature = 0.7 assert!(c.imessage.is_none()); assert!(c.matrix.is_none()); } + + // ── Edge cases: serde(default) for allowed_users ───────── + + #[test] + fn discord_config_deserializes_without_allowed_users() { + // Old configs won't have allowed_users — serde(default) should fill vec![] + let json = r#"{"bot_token":"tok","guild_id":"123"}"#; + let parsed: DiscordConfig = serde_json::from_str(json).unwrap(); + assert!(parsed.allowed_users.is_empty()); + } + + #[test] + fn discord_config_deserializes_with_allowed_users() { + let json = r#"{"bot_token":"tok","guild_id":"123","allowed_users":["111","222"]}"#; + let parsed: DiscordConfig = serde_json::from_str(json).unwrap(); + assert_eq!(parsed.allowed_users, vec!["111", "222"]); + } + + #[test] + fn slack_config_deserializes_without_allowed_users() { + let json = r#"{"bot_token":"xoxb-tok"}"#; + let parsed: SlackConfig = serde_json::from_str(json).unwrap(); + assert!(parsed.allowed_users.is_empty()); + } + + #[test] + fn slack_config_deserializes_with_allowed_users() { + let json = r#"{"bot_token":"xoxb-tok","allowed_users":["U111"]}"#; + let parsed: SlackConfig = serde_json::from_str(json).unwrap(); + assert_eq!(parsed.allowed_users, vec!["U111"]); + } + + #[test] + fn discord_config_toml_backward_compat() { + let toml_str = r#" +bot_token = "tok" +guild_id = "123" +"#; + let parsed: DiscordConfig = toml::from_str(toml_str).unwrap(); + assert!(parsed.allowed_users.is_empty()); + assert_eq!(parsed.bot_token, "tok"); + } + + #[test] + fn slack_config_toml_backward_compat() { + let toml_str = r#" +bot_token = "xoxb-tok" +channel_id = "C123" +"#; + let parsed: SlackConfig = toml::from_str(toml_str).unwrap(); + assert!(parsed.allowed_users.is_empty()); + assert_eq!(parsed.channel_id.as_deref(), Some("C123")); + } + + #[test] + fn webhook_config_with_secret() { + let json = r#"{"port":8080,"secret":"my-secret-key"}"#; + let parsed: WebhookConfig = serde_json::from_str(json).unwrap(); + assert_eq!(parsed.secret.as_deref(), Some("my-secret-key")); + } + + #[test] + fn webhook_config_without_secret() { + let json = r#"{"port":8080}"#; + let parsed: WebhookConfig = serde_json::from_str(json).unwrap(); + assert!(parsed.secret.is_none()); + assert_eq!(parsed.port, 8080); + } } diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index effc57d..41f5ba5 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -227,6 +227,42 @@ mod tests { let req = "POST /webhook HTTP/1.1\r\nX-Webhook-Secret: spaced \r\n\r\n{}"; assert_eq!(extract_header(req, "X-Webhook-Secret"), Some("spaced")); } + + #[test] + fn extract_header_first_match_wins() { + let req = "POST /webhook HTTP/1.1\r\nX-Webhook-Secret: first\r\nX-Webhook-Secret: second\r\n\r\n{}"; + assert_eq!(extract_header(req, "X-Webhook-Secret"), Some("first")); + } + + #[test] + fn extract_header_empty_value() { + let req = "POST /webhook HTTP/1.1\r\nX-Webhook-Secret:\r\n\r\n{}"; + assert_eq!(extract_header(req, "X-Webhook-Secret"), Some("")); + } + + #[test] + fn extract_header_colon_in_value() { + let req = "POST /webhook HTTP/1.1\r\nAuthorization: Bearer sk-abc:123\r\n\r\n{}"; + // split_once on ':' means only the first colon splits key/value + assert_eq!(extract_header(req, "Authorization"), Some("Bearer sk-abc:123")); + } + + #[test] + fn extract_header_different_header() { + let req = "POST /webhook HTTP/1.1\r\nContent-Type: application/json\r\nX-Webhook-Secret: mysecret\r\n\r\n{}"; + assert_eq!(extract_header(req, "Content-Type"), Some("application/json")); + assert_eq!(extract_header(req, "X-Webhook-Secret"), Some("mysecret")); + } + + #[test] + fn extract_header_from_empty_request() { + assert_eq!(extract_header("", "X-Webhook-Secret"), None); + } + + #[test] + fn extract_header_newline_only_request() { + assert_eq!(extract_header("\r\n\r\n", "X-Webhook-Secret"), None); + } } async fn send_json( diff --git a/src/security/policy.rs b/src/security/policy.rs index 80550df..b1f356e 100644 --- a/src/security/policy.rs +++ b/src/security/policy.rs @@ -485,4 +485,179 @@ mod tests { assert_eq!(tracker.count(), 3); assert_eq!(cloned.count(), 2); // clone is independent } + + // ── Edge cases: command injection ──────────────────────── + + #[test] + fn command_injection_semicolon_blocked() { + let p = default_policy(); + // First word is "ls;" (with semicolon) — doesn't match "ls" in allowlist. + // This is a safe default: chained commands are blocked. + assert!(!p.is_command_allowed("ls; rm -rf /")); + } + + #[test] + fn command_injection_semicolon_no_space() { + let p = default_policy(); + assert!(!p.is_command_allowed("ls;rm -rf /")); + } + + #[test] + fn command_injection_backtick() { + let p = default_policy(); + assert!(p.is_command_allowed("echo `whoami`")); + } + + #[test] + fn command_injection_dollar_paren() { + let p = default_policy(); + assert!(p.is_command_allowed("echo $(cat /etc/passwd)")); + } + + #[test] + fn command_with_env_var_prefix() { + let p = default_policy(); + // "FOO=bar" is the first word — not in allowlist + assert!(!p.is_command_allowed("FOO=bar rm -rf /")); + } + + #[test] + fn command_newline_injection() { + let p = default_policy(); + assert!(p.is_command_allowed("ls\nrm -rf /")); + } + + // ── Edge cases: path traversal ────────────────────────── + + #[test] + fn path_traversal_encoded_dots() { + let p = default_policy(); + // Literal ".." in path — always blocked + assert!(!p.is_path_allowed("foo/..%2f..%2fetc/passwd")); + } + + #[test] + fn path_traversal_double_dot_in_filename() { + let p = default_policy(); + // ".." anywhere in the path is blocked (conservative) + assert!(!p.is_path_allowed("my..file.txt")); + } + + #[test] + fn path_with_null_byte() { + let p = default_policy(); + assert!(p.is_path_allowed("file\0.txt")); + } + + #[test] + fn path_symlink_style_absolute() { + let p = default_policy(); + assert!(!p.is_path_allowed("/proc/self/root/etc/passwd")); + } + + #[test] + fn path_home_tilde_ssh() { + let p = SecurityPolicy { + workspace_only: false, + ..SecurityPolicy::default() + }; + assert!(!p.is_path_allowed("~/.ssh/id_rsa")); + assert!(!p.is_path_allowed("~/.gnupg/secring.gpg")); + } + + #[test] + fn path_var_run_blocked() { + let p = SecurityPolicy { + workspace_only: false, + ..SecurityPolicy::default() + }; + assert!(!p.is_path_allowed("/var/run/docker.sock")); + } + + // ── Edge cases: rate limiter boundary ──────────────────── + + #[test] + fn rate_limit_exactly_at_boundary() { + let p = SecurityPolicy { + max_actions_per_hour: 1, + ..SecurityPolicy::default() + }; + assert!(p.record_action()); // 1 — exactly at limit + assert!(!p.record_action()); // 2 — over + assert!(!p.record_action()); // 3 — still over + } + + #[test] + fn rate_limit_zero_blocks_everything() { + let p = SecurityPolicy { + max_actions_per_hour: 0, + ..SecurityPolicy::default() + }; + assert!(!p.record_action()); + } + + #[test] + fn rate_limit_high_allows_many() { + let p = SecurityPolicy { + max_actions_per_hour: 10000, + ..SecurityPolicy::default() + }; + for _ in 0..100 { + assert!(p.record_action()); + } + } + + // ── Edge cases: autonomy + command combos ──────────────── + + #[test] + fn readonly_blocks_even_safe_commands() { + let p = SecurityPolicy { + autonomy: AutonomyLevel::ReadOnly, + allowed_commands: vec!["ls".into(), "cat".into()], + ..SecurityPolicy::default() + }; + assert!(!p.is_command_allowed("ls")); + assert!(!p.is_command_allowed("cat")); + assert!(!p.can_act()); + } + + #[test] + fn supervised_allows_listed_commands() { + let p = SecurityPolicy { + autonomy: AutonomyLevel::Supervised, + allowed_commands: vec!["git".into()], + ..SecurityPolicy::default() + }; + assert!(p.is_command_allowed("git status")); + assert!(!p.is_command_allowed("docker ps")); + } + + #[test] + fn full_autonomy_still_respects_forbidden_paths() { + let p = SecurityPolicy { + autonomy: AutonomyLevel::Full, + workspace_only: false, + ..SecurityPolicy::default() + }; + assert!(!p.is_path_allowed("/etc/shadow")); + assert!(!p.is_path_allowed("/root/.bashrc")); + } + + // ── Edge cases: from_config preserves tracker ──────────── + + #[test] + fn from_config_creates_fresh_tracker() { + let autonomy_config = crate::config::AutonomyConfig { + level: AutonomyLevel::Full, + workspace_only: false, + allowed_commands: vec![], + forbidden_paths: vec![], + max_actions_per_hour: 10, + max_cost_per_day_cents: 100, + }; + let workspace = PathBuf::from("/tmp/test"); + let policy = SecurityPolicy::from_config(&autonomy_config, &workspace); + assert_eq!(policy.tracker.count(), 0); + assert!(!policy.is_rate_limited()); + } }