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
This commit is contained in:
parent
542bb80743
commit
a5887ad2dc
7 changed files with 460 additions and 6 deletions
83
README.md
83
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
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue