From 9a6fa768251722c3393f2978e097179d6679c6a7 Mon Sep 17 00:00:00 2001 From: Alex Gorevski Date: Tue, 17 Feb 2026 16:08:53 -0800 Subject: [PATCH] readd tests, remove markdown files --- src/memory/response_cache.rs | 72 +++++++++++++++++++ src/memory/sqlite.rs | 113 +++++++++++++++++++++++++++++ src/migration.rs | 104 +++++++++++++++++++++++++++ src/observability/otel.rs | 52 ++++++++++++++ src/providers/reliable.rs | 134 +++++++++++++++++++++++++++++++++++ src/runtime/docker.rs | 72 +++++++++++++++++++ src/runtime/wasm.rs | 67 ++++++++++++++++++ src/security/audit.rs | 88 +++++++++++++++++++++++ src/security/bubblewrap.rs | 86 ++++++++++++++++++++++ src/security/docker.rs | 96 +++++++++++++++++++++++++ src/security/firejail.rs | 67 ++++++++++++++++++ src/security/landlock.rs | 27 +++++++ src/security/policy.rs | 108 ++++++++++++++++++++++++++++ src/tools/delegate.rs | 81 +++++++++++++++++++++ src/tools/file_write.rs | 58 +++++++++++++++ src/tools/http_request.rs | 69 ++++++++++++++++++ src/tools/shell.rs | 58 +++++++++++++++ 17 files changed, 1352 insertions(+) diff --git a/src/memory/response_cache.rs b/src/memory/response_cache.rs index 62fae6c..5c64924 100644 --- a/src/memory/response_cache.rs +++ b/src/memory/response_cache.rs @@ -348,4 +348,76 @@ mod tests { let result = cache.get(&key).unwrap(); assert_eq!(result.as_deref(), Some("はい、Rustは素晴らしい")); } + + // ── §4.4 Cache eviction under pressure tests ───────────── + + #[test] + fn lru_eviction_keeps_most_recent() { + let tmp = TempDir::new().unwrap(); + let cache = ResponseCache::new(tmp.path(), 60, 3).unwrap(); + + // Insert 3 entries + for i in 0..3 { + let key = ResponseCache::cache_key("gpt-4", None, &format!("prompt {i}")); + cache + .put(&key, "gpt-4", &format!("response {i}"), 10) + .unwrap(); + } + + // Access entry 0 to make it recently used + let key0 = ResponseCache::cache_key("gpt-4", None, "prompt 0"); + let _ = cache.get(&key0).unwrap(); + + // Insert entry 3 (triggers eviction) + let key3 = ResponseCache::cache_key("gpt-4", None, "prompt 3"); + cache.put(&key3, "gpt-4", "response 3", 10).unwrap(); + + let (count, _, _) = cache.stats().unwrap(); + assert!(count <= 3, "cache must not exceed max_entries"); + + // Entry 0 was recently accessed and should survive + let entry0 = cache.get(&key0).unwrap(); + assert!( + entry0.is_some(), + "recently accessed entry should survive LRU eviction" + ); + } + + #[test] + fn cache_handles_zero_max_entries() { + let tmp = TempDir::new().unwrap(); + let cache = ResponseCache::new(tmp.path(), 60, 0).unwrap(); + + let key = ResponseCache::cache_key("gpt-4", None, "test"); + // Should not panic even with max_entries=0 + cache.put(&key, "gpt-4", "response", 10).unwrap(); + + let (count, _, _) = cache.stats().unwrap(); + assert_eq!(count, 0, "cache with max_entries=0 should evict everything"); + } + + #[test] + fn cache_concurrent_reads_no_panic() { + let tmp = TempDir::new().unwrap(); + let cache = std::sync::Arc::new(ResponseCache::new(tmp.path(), 60, 100).unwrap()); + + let key = ResponseCache::cache_key("gpt-4", None, "concurrent"); + cache.put(&key, "gpt-4", "response", 10).unwrap(); + + let mut handles = Vec::new(); + for _ in 0..10 { + let cache = std::sync::Arc::clone(&cache); + let key = key.clone(); + handles.push(std::thread::spawn(move || { + let _ = cache.get(&key).unwrap(); + })); + } + + for handle in handles { + handle.join().unwrap(); + } + + let (_, hits, _) = cache.stats().unwrap(); + assert_eq!(hits, 10, "all concurrent reads should register as hits"); + } } diff --git a/src/memory/sqlite.rs b/src/memory/sqlite.rs index 31cde20..a7b0a44 100644 --- a/src/memory/sqlite.rs +++ b/src/memory/sqlite.rs @@ -1666,4 +1666,117 @@ mod tests { assert_eq!(results[0].session_id.as_deref(), Some("sess-x")); } } + + // ── §4.1 Concurrent write contention tests ────────────── + + #[tokio::test] + async fn sqlite_concurrent_writes_no_data_loss() { + let (_tmp, mem) = temp_sqlite(); + let mem = std::sync::Arc::new(mem); + + let mut handles = Vec::new(); + for i in 0..10 { + let mem = std::sync::Arc::clone(&mem); + handles.push(tokio::spawn(async move { + mem.store( + &format!("concurrent_key_{i}"), + &format!("value_{i}"), + MemoryCategory::Core, + None, + ) + .await + .unwrap(); + })); + } + + for handle in handles { + handle.await.unwrap(); + } + + let count = mem.count().await.unwrap(); + assert_eq!( + count, 10, + "all 10 concurrent writes must succeed without data loss" + ); + } + + #[tokio::test] + async fn sqlite_concurrent_read_write_no_panic() { + let (_tmp, mem) = temp_sqlite(); + let mem = std::sync::Arc::new(mem); + + // Pre-populate + mem.store("shared_key", "initial", MemoryCategory::Core, None) + .await + .unwrap(); + + let mut handles = Vec::new(); + + // Concurrent reads + for _ in 0..5 { + let mem = std::sync::Arc::clone(&mem); + handles.push(tokio::spawn(async move { + let _ = mem.get("shared_key").await.unwrap(); + })); + } + + // Concurrent writes + for i in 0..5 { + let mem = std::sync::Arc::clone(&mem); + handles.push(tokio::spawn(async move { + mem.store( + &format!("key_{i}"), + &format!("val_{i}"), + MemoryCategory::Core, + None, + ) + .await + .unwrap(); + })); + } + + for handle in handles { + handle.await.unwrap(); + } + + // Should have 6 total entries (1 pre-existing + 5 new) + assert_eq!(mem.count().await.unwrap(), 6); + } + + // ── §4.2 Reindex / corruption recovery tests ──────────── + + #[tokio::test] + async fn sqlite_reindex_preserves_data() { + let (_tmp, mem) = temp_sqlite(); + mem.store("a", "Rust is fast", MemoryCategory::Core, None) + .await + .unwrap(); + mem.store("b", "Python is interpreted", MemoryCategory::Core, None) + .await + .unwrap(); + + mem.reindex().await.unwrap(); + + let count = mem.count().await.unwrap(); + assert_eq!(count, 2, "reindex must preserve all entries"); + + let entry = mem.get("a").await.unwrap(); + assert!(entry.is_some()); + assert_eq!(entry.unwrap().content, "Rust is fast"); + } + + #[tokio::test] + async fn sqlite_reindex_idempotent() { + let (_tmp, mem) = temp_sqlite(); + mem.store("x", "test data", MemoryCategory::Core, None) + .await + .unwrap(); + + // Multiple reindex calls should be safe + mem.reindex().await.unwrap(); + mem.reindex().await.unwrap(); + mem.reindex().await.unwrap(); + + assert_eq!(mem.count().await.unwrap(), 1); + } } diff --git a/src/migration.rs b/src/migration.rs index 8a83262..0dac438 100644 --- a/src/migration.rs +++ b/src/migration.rs @@ -556,4 +556,108 @@ mod tests { .expect("backend=none should be rejected for migration target"); assert!(err.to_string().contains("disables persistence")); } + + // ── §7.1 / §7.2 Config backward compatibility & migration tests ── + + #[test] + fn parse_category_handles_all_variants() { + assert_eq!(parse_category("core"), MemoryCategory::Core); + assert_eq!(parse_category("daily"), MemoryCategory::Daily); + assert_eq!(parse_category("conversation"), MemoryCategory::Conversation); + assert_eq!(parse_category(""), MemoryCategory::Core); + assert_eq!( + parse_category("custom_type"), + MemoryCategory::Custom("custom_type".to_string()) + ); + } + + #[test] + fn parse_category_case_insensitive() { + assert_eq!(parse_category("CORE"), MemoryCategory::Core); + assert_eq!(parse_category("Daily"), MemoryCategory::Daily); + assert_eq!(parse_category("CONVERSATION"), MemoryCategory::Conversation); + } + + #[test] + fn normalize_key_handles_empty_string() { + let key = normalize_key("", 42); + assert_eq!(key, "openclaw_42"); + } + + #[test] + fn normalize_key_trims_whitespace() { + let key = normalize_key(" my_key ", 0); + assert_eq!(key, "my_key"); + } + + #[test] + fn parse_structured_markdown_rejects_empty_key() { + assert!(parse_structured_memory_line("****:value").is_none()); + } + + #[test] + fn parse_structured_markdown_rejects_empty_value() { + assert!(parse_structured_memory_line("**key**:").is_none()); + } + + #[test] + fn parse_structured_markdown_rejects_no_stars() { + assert!(parse_structured_memory_line("key: value").is_none()); + } + + #[tokio::test] + async fn migration_skips_empty_content() { + let dir = TempDir::new().unwrap(); + let db_path = dir.path().join("brain.db"); + let conn = Connection::open(&db_path).unwrap(); + + conn.execute_batch("CREATE TABLE memories (key TEXT, content TEXT, category TEXT);") + .unwrap(); + conn.execute( + "INSERT INTO memories (key, content, category) VALUES (?1, ?2, ?3)", + params!["empty_key", " ", "core"], + ) + .unwrap(); + + let rows = read_openclaw_sqlite_entries(&db_path).unwrap(); + assert_eq!( + rows.len(), + 0, + "entries with empty/whitespace content must be skipped" + ); + } + + #[test] + fn backup_creates_timestamped_directory() { + let tmp = TempDir::new().unwrap(); + let mem_dir = tmp.path().join("memory"); + std::fs::create_dir_all(&mem_dir).unwrap(); + + // Create a brain.db to back up + let db_path = mem_dir.join("brain.db"); + std::fs::write(&db_path, "fake db content").unwrap(); + + let result = backup_target_memory(tmp.path()).unwrap(); + assert!( + result.is_some(), + "backup should be created when files exist" + ); + + let backup_dir = result.unwrap(); + assert!(backup_dir.exists()); + assert!( + backup_dir.to_string_lossy().contains("openclaw-"), + "backup dir must contain openclaw- prefix" + ); + } + + #[test] + fn backup_returns_none_when_no_files() { + let tmp = TempDir::new().unwrap(); + let result = backup_target_memory(tmp.path()).unwrap(); + assert!( + result.is_none(), + "backup should return None when no files to backup" + ); + } } diff --git a/src/observability/otel.rs b/src/observability/otel.rs index 96e368c..9862e23 100644 --- a/src/observability/otel.rs +++ b/src/observability/otel.rs @@ -467,4 +467,56 @@ mod tests { obs.record_event(&ObserverEvent::HeartbeatTick); obs.flush(); } + + // ── §8.2 OTel export failure resilience tests ──────────── + + #[test] + fn otel_records_error_event_without_panic() { + let obs = test_observer(); + // Simulate an error event — should not panic even with unreachable endpoint + obs.record_event(&ObserverEvent::Error { + component: "provider".into(), + message: "connection refused to model endpoint".into(), + }); + } + + #[test] + fn otel_records_llm_failure_without_panic() { + let obs = test_observer(); + obs.record_event(&ObserverEvent::LlmResponse { + provider: "openrouter".into(), + model: "missing-model".into(), + duration: Duration::from_millis(0), + success: false, + error_message: Some("404 Not Found".into()), + }); + } + + #[test] + fn otel_flush_idempotent_with_unreachable_endpoint() { + let obs = test_observer(); + // Multiple flushes should not panic even when endpoint is unreachable + obs.flush(); + obs.flush(); + obs.flush(); + } + + #[test] + fn otel_records_zero_duration_metrics() { + let obs = test_observer(); + obs.record_metric(&ObserverMetric::RequestLatency(Duration::ZERO)); + obs.record_metric(&ObserverMetric::TokensUsed(0)); + obs.record_metric(&ObserverMetric::ActiveSessions(0)); + obs.record_metric(&ObserverMetric::QueueDepth(0)); + } + + #[test] + fn otel_observer_creation_with_valid_endpoint_succeeds() { + // Even though endpoint is unreachable, creation should succeed + let result = OtelObserver::new(Some("http://127.0.0.1:12345"), Some("zeroclaw-test")); + assert!( + result.is_ok(), + "observer creation must succeed even with unreachable endpoint" + ); + } } diff --git a/src/providers/reliable.rs b/src/providers/reliable.rs index 29f1903..50e6a33 100644 --- a/src/providers/reliable.rs +++ b/src/providers/reliable.rs @@ -1012,6 +1012,140 @@ mod tests { assert_eq!(provider.compute_backoff(500, &err), 500); } + // ── §2.1 API auth error (401/403) tests ────────────────── + + #[test] + fn non_retryable_detects_401() { + let err = anyhow::anyhow!("API error (401 Unauthorized): invalid api key"); + assert!( + is_non_retryable(&err), + "401 errors must be detected as non-retryable" + ); + } + + #[test] + fn non_retryable_detects_403() { + let err = anyhow::anyhow!("API error (403 Forbidden): access denied"); + assert!( + is_non_retryable(&err), + "403 errors must be detected as non-retryable" + ); + } + + #[test] + fn non_retryable_detects_404() { + let err = anyhow::anyhow!("API error (404 Not Found): model not found"); + assert!( + is_non_retryable(&err), + "404 errors must be detected as non-retryable" + ); + } + + #[test] + fn non_retryable_does_not_flag_429() { + let err = anyhow::anyhow!("429 Too Many Requests"); + assert!( + !is_non_retryable(&err), + "429 must NOT be treated as non-retryable (it is retryable with backoff)" + ); + } + + #[test] + fn non_retryable_does_not_flag_408() { + let err = anyhow::anyhow!("408 Request Timeout"); + assert!( + !is_non_retryable(&err), + "408 must NOT be treated as non-retryable (it is retryable)" + ); + } + + #[test] + fn non_retryable_does_not_flag_500() { + let err = anyhow::anyhow!("500 Internal Server Error"); + assert!( + !is_non_retryable(&err), + "500 must NOT be treated as non-retryable (server errors are retryable)" + ); + } + + #[test] + fn non_retryable_does_not_flag_502() { + let err = anyhow::anyhow!("502 Bad Gateway"); + assert!( + !is_non_retryable(&err), + "502 must NOT be treated as non-retryable" + ); + } + + // ── §2.2 Rate limit Retry-After edge cases ─────────────── + + #[test] + fn parse_retry_after_zero() { + let err = anyhow::anyhow!("429 Too Many Requests, Retry-After: 0"); + assert_eq!( + parse_retry_after_ms(&err), + Some(0), + "Retry-After: 0 should parse as 0ms" + ); + } + + #[test] + fn parse_retry_after_with_underscore_separator() { + let err = anyhow::anyhow!("rate limited, retry_after: 10"); + assert_eq!( + parse_retry_after_ms(&err), + Some(10_000), + "retry_after with underscore must be parsed" + ); + } + + #[test] + fn parse_retry_after_space_separator() { + let err = anyhow::anyhow!("Retry-After 7"); + assert_eq!( + parse_retry_after_ms(&err), + Some(7000), + "Retry-After with space separator must be parsed" + ); + } + + #[test] + fn rate_limited_false_for_generic_error() { + let err = anyhow::anyhow!("Connection refused"); + assert!( + !is_rate_limited(&err), + "generic errors must not be flagged as rate-limited" + ); + } + + // ── §2.3 Malformed API response error classification ───── + + #[tokio::test] + async fn non_retryable_skips_retries_for_401() { + let calls = Arc::new(AtomicUsize::new(0)); + let provider = ReliableProvider::new( + vec![( + "primary".into(), + Box::new(MockProvider { + calls: Arc::clone(&calls), + fail_until_attempt: usize::MAX, + response: "never", + error: "API error (401 Unauthorized): invalid key", + }), + )], + 5, + 1, + ); + + let result = provider.simple_chat("hello", "test", 0.0).await; + assert!(result.is_err(), "401 should fail without retries"); + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "must not retry on 401 — should be exactly 1 call" + ); + } + // ── Arc Provider impl for test ── #[async_trait] diff --git a/src/runtime/docker.rs b/src/runtime/docker.rs index eaa3d09..cb25e72 100644 --- a/src/runtime/docker.rs +++ b/src/runtime/docker.rs @@ -196,4 +196,76 @@ mod tests { assert!(result.is_err()); } + + // ── §3.3 / §3.4 Docker mount & network isolation tests ── + + #[test] + fn docker_build_shell_command_includes_network_flag() { + let cfg = DockerRuntimeConfig { + network: "none".into(), + ..DockerRuntimeConfig::default() + }; + let runtime = DockerRuntime::new(cfg); + let workspace = std::env::temp_dir(); + let cmd = runtime + .build_shell_command("echo hello", &workspace) + .unwrap(); + let debug = format!("{cmd:?}"); + assert!( + debug.contains("--network") && debug.contains("none"), + "must include --network none for isolation" + ); + } + + #[test] + fn docker_build_shell_command_includes_read_only_flag() { + let cfg = DockerRuntimeConfig { + read_only_rootfs: true, + ..DockerRuntimeConfig::default() + }; + let runtime = DockerRuntime::new(cfg); + let workspace = std::env::temp_dir(); + let cmd = runtime + .build_shell_command("echo hello", &workspace) + .unwrap(); + let debug = format!("{cmd:?}"); + assert!( + debug.contains("--read-only"), + "must include --read-only flag when read_only_rootfs is set" + ); + } + + #[cfg(unix)] + #[test] + fn docker_refuses_root_mount() { + let cfg = DockerRuntimeConfig { + mount_workspace: true, + ..DockerRuntimeConfig::default() + }; + let runtime = DockerRuntime::new(cfg); + let result = runtime.build_shell_command("echo test", Path::new("/")); + assert!( + result.is_err(), + "mounting filesystem root (/) must be refused" + ); + assert!(result.unwrap_err().to_string().contains("root")); + } + + #[test] + fn docker_no_memory_flag_when_not_configured() { + let cfg = DockerRuntimeConfig { + memory_limit_mb: None, + ..DockerRuntimeConfig::default() + }; + let runtime = DockerRuntime::new(cfg); + let workspace = std::env::temp_dir(); + let cmd = runtime + .build_shell_command("echo hello", &workspace) + .unwrap(); + let debug = format!("{cmd:?}"); + assert!( + !debug.contains("--memory"), + "should not include --memory when not configured" + ); + } } diff --git a/src/runtime/wasm.rs b/src/runtime/wasm.rs index 6b4c6f3..fd41427 100644 --- a/src/runtime/wasm.rs +++ b/src/runtime/wasm.rs @@ -617,4 +617,71 @@ mod tests { assert_eq!(caps.fuel_override, 0); assert_eq!(caps.memory_override_mb, 0); } + + // ── §3.1 / §3.2 WASM fuel & memory exhaustion tests ───── + + #[test] + fn wasm_fuel_limit_enforced_in_config() { + let rt = WasmRuntime::new(default_config()); + let caps = WasmCapabilities::default(); + let fuel = rt.effective_fuel(&caps); + assert!( + fuel > 0, + "default fuel limit must be > 0 to prevent infinite loops" + ); + } + + #[test] + fn wasm_memory_limit_enforced_in_config() { + let rt = WasmRuntime::new(default_config()); + let caps = WasmCapabilities::default(); + let mem_bytes = rt.effective_memory_bytes(&caps); + assert!( + mem_bytes > 0, + "default memory limit must be > 0" + ); + assert!( + mem_bytes <= 4096 * 1024 * 1024, + "default memory must not exceed 4 GB safety limit" + ); + } + + #[test] + fn wasm_zero_fuel_override_uses_default() { + let rt = WasmRuntime::new(default_config()); + let caps = WasmCapabilities { + fuel_override: 0, + ..Default::default() + }; + assert_eq!( + rt.effective_fuel(&caps), + 1_000_000, + "fuel_override=0 must use config default" + ); + } + + #[test] + fn validate_rejects_memory_just_above_limit() { + let mut cfg = default_config(); + cfg.memory_limit_mb = 4097; + let rt = WasmRuntime::new(cfg); + let err = rt.validate_config().unwrap_err(); + assert!(err.to_string().contains("4 GB safety limit")); + } + + #[test] + fn execute_module_stub_returns_error_without_feature() { + if !WasmRuntime::is_available() { + let dir = tempfile::tempdir().unwrap(); + let tools_dir = dir.path().join("tools/wasm"); + std::fs::create_dir_all(&tools_dir).unwrap(); + std::fs::write(tools_dir.join("test.wasm"), b"\0asm\x01\0\0\0").unwrap(); + + let rt = WasmRuntime::new(default_config()); + let caps = WasmCapabilities::default(); + let result = rt.execute_module("test", dir.path(), &caps); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("not available")); + } + } } diff --git a/src/security/audit.rs b/src/security/audit.rs index 5eb2b42..80c45cb 100644 --- a/src/security/audit.rs +++ b/src/security/audit.rs @@ -332,4 +332,92 @@ mod tests { assert!(!tmp.path().join("audit.log").exists()); Ok(()) } + + // ── §8.1 Log rotation tests ───────────────────────────── + + #[test] + fn audit_logger_writes_event_when_enabled() -> Result<()> { + let tmp = TempDir::new()?; + let config = AuditConfig { + enabled: true, + max_size_mb: 10, + ..Default::default() + }; + let logger = AuditLogger::new(config, tmp.path().to_path_buf())?; + let event = AuditEvent::new(AuditEventType::CommandExecution) + .with_actor("cli".to_string(), None, None) + .with_action("ls".to_string(), "low".to_string(), false, true); + + logger.log(&event)?; + + let log_path = tmp.path().join("audit.log"); + assert!(log_path.exists(), "audit log file must be created"); + + let content = std::fs::read_to_string(&log_path)?; + assert!(!content.is_empty(), "audit log must not be empty"); + + let parsed: AuditEvent = serde_json::from_str(content.trim())?; + assert!(parsed.action.is_some()); + Ok(()) + } + + #[test] + fn audit_log_command_event_writes_structured_entry() -> Result<()> { + let tmp = TempDir::new()?; + let config = AuditConfig { + enabled: true, + max_size_mb: 10, + ..Default::default() + }; + let logger = AuditLogger::new(config, tmp.path().to_path_buf())?; + + logger.log_command_event(CommandExecutionLog { + channel: "telegram", + command: "echo test", + risk_level: "low", + approved: false, + allowed: true, + success: true, + duration_ms: 42, + })?; + + let log_path = tmp.path().join("audit.log"); + let content = std::fs::read_to_string(&log_path)?; + let parsed: AuditEvent = serde_json::from_str(content.trim())?; + + let action = parsed.action.unwrap(); + assert_eq!(action.command, Some("echo test".to_string())); + assert_eq!(action.risk_level, Some("low".to_string())); + assert!(action.allowed); + + let result = parsed.result.unwrap(); + assert!(result.success); + assert_eq!(result.duration_ms, Some(42)); + Ok(()) + } + + #[test] + fn audit_rotation_creates_numbered_backup() -> Result<()> { + let tmp = TempDir::new()?; + let config = AuditConfig { + enabled: true, + max_size_mb: 0, // Force rotation on first write + ..Default::default() + }; + let logger = AuditLogger::new(config, tmp.path().to_path_buf())?; + + // Write initial content that triggers rotation + let log_path = tmp.path().join("audit.log"); + std::fs::write(&log_path, "initial content\n")?; + + let event = AuditEvent::new(AuditEventType::CommandExecution); + logger.log(&event)?; + + let rotated = format!("{}.1.log", log_path.display()); + assert!( + std::path::Path::new(&rotated).exists(), + "rotation must create .1.log backup" + ); + Ok(()) + } } diff --git a/src/security/bubblewrap.rs b/src/security/bubblewrap.rs index fca76e6..f2d498a 100644 --- a/src/security/bubblewrap.rs +++ b/src/security/bubblewrap.rs @@ -94,4 +94,90 @@ mod tests { // Either way, the name should still work assert_eq!(sandbox.name(), "bubblewrap"); } + + // ── §1.1 Sandbox isolation flag tests ────────────────────── + + #[test] + fn bubblewrap_wrap_command_includes_isolation_flags() { + let sandbox = BubblewrapSandbox; + let mut cmd = Command::new("echo"); + cmd.arg("hello"); + sandbox.wrap_command(&mut cmd).unwrap(); + + assert_eq!( + cmd.get_program().to_string_lossy(), + "bwrap", + "wrapped command should use bwrap as program" + ); + + let args: Vec = cmd + .get_args() + .map(|s| s.to_string_lossy().to_string()) + .collect(); + + assert!( + args.contains(&"--unshare-all".to_string()), + "must include --unshare-all for namespace isolation" + ); + assert!( + args.contains(&"--die-with-parent".to_string()), + "must include --die-with-parent to prevent orphan processes" + ); + assert!( + !args.contains(&"--share-net".to_string()), + "must NOT include --share-net (network should be blocked)" + ); + } + + #[test] + fn bubblewrap_wrap_command_preserves_original_command() { + let sandbox = BubblewrapSandbox; + let mut cmd = Command::new("ls"); + cmd.arg("-la"); + cmd.arg("/tmp"); + sandbox.wrap_command(&mut cmd).unwrap(); + + let args: Vec = cmd + .get_args() + .map(|s| s.to_string_lossy().to_string()) + .collect(); + + assert!( + args.contains(&"ls".to_string()), + "original program must be passed as argument" + ); + assert!( + args.contains(&"-la".to_string()), + "original args must be preserved" + ); + assert!( + args.contains(&"/tmp".to_string()), + "original args must be preserved" + ); + } + + #[test] + fn bubblewrap_wrap_command_binds_required_paths() { + let sandbox = BubblewrapSandbox; + let mut cmd = Command::new("echo"); + sandbox.wrap_command(&mut cmd).unwrap(); + + let args: Vec = cmd + .get_args() + .map(|s| s.to_string_lossy().to_string()) + .collect(); + + assert!( + args.contains(&"--ro-bind".to_string()), + "must include read-only bind for /usr" + ); + assert!( + args.contains(&"--dev".to_string()), + "must include /dev mount" + ); + assert!( + args.contains(&"--proc".to_string()), + "must include /proc mount" + ); + } } diff --git a/src/security/docker.rs b/src/security/docker.rs index 2c32e20..88a75a3 100644 --- a/src/security/docker.rs +++ b/src/security/docker.rs @@ -117,4 +117,100 @@ mod tests { Err(_) => assert!(!DockerSandbox::is_installed()), } } + + // ── §1.1 Sandbox isolation flag tests ────────────────────── + + #[test] + fn docker_wrap_command_includes_isolation_flags() { + let sandbox = DockerSandbox::default(); + let mut cmd = Command::new("echo"); + cmd.arg("hello"); + sandbox.wrap_command(&mut cmd).unwrap(); + + assert_eq!( + cmd.get_program().to_string_lossy(), + "docker", + "wrapped command should use docker as program" + ); + + let args: Vec = cmd + .get_args() + .map(|s| s.to_string_lossy().to_string()) + .collect(); + + assert!( + args.contains(&"run".to_string()), + "must include 'run' subcommand" + ); + assert!( + args.contains(&"--rm".to_string()), + "must include --rm for auto-cleanup" + ); + assert!( + args.contains(&"--network".to_string()), + "must include --network flag" + ); + assert!( + args.contains(&"none".to_string()), + "network must be set to 'none' for isolation" + ); + assert!( + args.contains(&"--memory".to_string()), + "must include --memory limit" + ); + assert!( + args.contains(&"512m".to_string()), + "memory limit must be 512m" + ); + assert!( + args.contains(&"--cpus".to_string()), + "must include --cpus limit" + ); + assert!(args.contains(&"1.0".to_string()), "CPU limit must be 1.0"); + } + + #[test] + fn docker_wrap_command_preserves_original_command() { + let sandbox = DockerSandbox::default(); + let mut cmd = Command::new("ls"); + cmd.arg("-la"); + sandbox.wrap_command(&mut cmd).unwrap(); + + let args: Vec = cmd + .get_args() + .map(|s| s.to_string_lossy().to_string()) + .collect(); + + assert!( + args.contains(&"alpine:latest".to_string()), + "must include the container image" + ); + assert!( + args.contains(&"ls".to_string()), + "original program must be passed as argument" + ); + assert!( + args.contains(&"-la".to_string()), + "original args must be preserved" + ); + } + + #[test] + fn docker_wrap_command_uses_custom_image() { + let sandbox = DockerSandbox { + image: "ubuntu:22.04".to_string(), + }; + let mut cmd = Command::new("echo"); + sandbox.wrap_command(&mut cmd).unwrap(); + + let args: Vec = cmd + .get_args() + .map(|s| s.to_string_lossy().to_string()) + .collect(); + + assert!( + args.contains(&"ubuntu:22.04".to_string()), + "must use the custom image" + ); + } } diff --git a/src/security/firejail.rs b/src/security/firejail.rs index 9eeb6c7..7eda3e8 100644 --- a/src/security/firejail.rs +++ b/src/security/firejail.rs @@ -125,4 +125,71 @@ mod tests { assert_eq!(cmd.get_program().to_string_lossy(), "firejail"); } } + + // ── §1.1 Sandbox isolation flag tests ────────────────────── + + #[test] + fn firejail_wrap_command_includes_all_security_flags() { + let sandbox = FirejailSandbox; + let mut cmd = Command::new("echo"); + cmd.arg("test"); + sandbox.wrap_command(&mut cmd).unwrap(); + + assert_eq!( + cmd.get_program().to_string_lossy(), + "firejail", + "wrapped command should use firejail as program" + ); + + let args: Vec = cmd + .get_args() + .map(|s| s.to_string_lossy().to_string()) + .collect(); + + let expected_flags = [ + "--private=home", + "--private-dev", + "--nosound", + "--no3d", + "--novideo", + "--nowheel", + "--notv", + "--noprofile", + "--quiet", + ]; + + for flag in &expected_flags { + assert!( + args.contains(&flag.to_string()), + "must include security flag: {flag}" + ); + } + } + + #[test] + fn firejail_wrap_command_preserves_original_command() { + let sandbox = FirejailSandbox; + let mut cmd = Command::new("ls"); + cmd.arg("-la"); + cmd.arg("/workspace"); + sandbox.wrap_command(&mut cmd).unwrap(); + + let args: Vec = cmd + .get_args() + .map(|s| s.to_string_lossy().to_string()) + .collect(); + + assert!( + args.contains(&"ls".to_string()), + "original program must be passed as argument" + ); + assert!( + args.contains(&"-la".to_string()), + "original args must be preserved" + ); + assert!( + args.contains(&"/workspace".to_string()), + "original args must be preserved" + ); + } } diff --git a/src/security/landlock.rs b/src/security/landlock.rs index 53e3b11..898e4ff 100644 --- a/src/security/landlock.rs +++ b/src/security/landlock.rs @@ -231,4 +231,31 @@ mod tests { ))), } } + + // ── §1.1 Landlock stub tests ────────────────────────────── + + #[cfg(not(all(feature = "sandbox-landlock", target_os = "linux")))] + #[test] + fn landlock_stub_wrap_command_returns_unsupported() { + let sandbox = LandlockSandbox; + let mut cmd = std::process::Command::new("echo"); + let result = sandbox.wrap_command(&mut cmd); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::Unsupported); + } + + #[cfg(not(all(feature = "sandbox-landlock", target_os = "linux")))] + #[test] + fn landlock_stub_new_returns_unsupported() { + let result = LandlockSandbox::new(); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::Unsupported); + } + + #[cfg(not(all(feature = "sandbox-landlock", target_os = "linux")))] + #[test] + fn landlock_stub_probe_returns_unsupported() { + let result = LandlockSandbox::probe(); + assert!(result.is_err()); + } } diff --git a/src/security/policy.rs b/src/security/policy.rs index 3e726dd..806a399 100644 --- a/src/security/policy.rs +++ b/src/security/policy.rs @@ -1388,4 +1388,112 @@ mod tests { ); } } + + // ── §1.2 Path resolution / symlink bypass tests ────────── + + #[test] + fn resolved_path_blocks_outside_workspace() { + let workspace = std::env::temp_dir().join("zeroclaw_test_resolved_path"); + let _ = std::fs::create_dir_all(&workspace); + + // Use the canonicalized workspace so starts_with checks match + let canonical_workspace = workspace + .canonicalize() + .unwrap_or_else(|_| workspace.clone()); + + let policy = SecurityPolicy { + workspace_dir: canonical_workspace.clone(), + ..SecurityPolicy::default() + }; + + // A resolved path inside the workspace should be allowed + let inside = canonical_workspace.join("subdir").join("file.txt"); + assert!( + policy.is_resolved_path_allowed(&inside), + "path inside workspace should be allowed" + ); + + // A resolved path outside the workspace should be blocked + let canonical_temp = std::env::temp_dir() + .canonicalize() + .unwrap_or_else(|_| std::env::temp_dir()); + let outside = canonical_temp.join("outside_workspace_zeroclaw"); + assert!( + !policy.is_resolved_path_allowed(&outside), + "path outside workspace must be blocked" + ); + + let _ = std::fs::remove_dir_all(&workspace); + } + + #[test] + fn resolved_path_blocks_root_escape() { + let policy = SecurityPolicy { + workspace_dir: PathBuf::from("/home/zeroclaw_user/project"), + ..SecurityPolicy::default() + }; + + assert!( + !policy.is_resolved_path_allowed(Path::new("/etc/passwd")), + "resolved path to /etc/passwd must be blocked" + ); + assert!( + !policy.is_resolved_path_allowed(Path::new("/root/.bashrc")), + "resolved path to /root/.bashrc must be blocked" + ); + } + + #[cfg(unix)] + #[test] + fn resolved_path_blocks_symlink_escape() { + use std::os::unix::fs::symlink; + + let root = std::env::temp_dir().join("zeroclaw_test_symlink_escape"); + let workspace = root.join("workspace"); + let outside = root.join("outside_target"); + + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&workspace).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + + // Create a symlink inside workspace pointing outside + let link_path = workspace.join("escape_link"); + symlink(&outside, &link_path).unwrap(); + + let policy = SecurityPolicy { + workspace_dir: workspace.clone(), + ..SecurityPolicy::default() + }; + + // The resolved symlink target should be outside workspace + let resolved = link_path.canonicalize().unwrap(); + assert!( + !policy.is_resolved_path_allowed(&resolved), + "symlink-resolved path outside workspace must be blocked" + ); + + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn is_path_allowed_blocks_null_bytes() { + let policy = default_policy(); + assert!( + !policy.is_path_allowed("file\0.txt"), + "paths with null bytes must be blocked" + ); + } + + #[test] + fn is_path_allowed_blocks_url_encoded_traversal() { + let policy = default_policy(); + assert!( + !policy.is_path_allowed("..%2fetc%2fpasswd"), + "URL-encoded path traversal must be blocked" + ); + assert!( + !policy.is_path_allowed("subdir%2f..%2f..%2fetc"), + "URL-encoded parent dir traversal must be blocked" + ); + } } diff --git a/src/tools/delegate.rs b/src/tools/delegate.rs index 2f3cd71..fabb99c 100644 --- a/src/tools/delegate.rs +++ b/src/tools/delegate.rs @@ -493,4 +493,85 @@ mod tests { .unwrap_or("") .contains("Rate limit exceeded")); } + + #[tokio::test] + async fn delegate_context_is_prepended_to_prompt() { + let mut agents = HashMap::new(); + agents.insert( + "tester".to_string(), + DelegateAgentConfig { + provider: "invalid-for-test".to_string(), + model: "test-model".to_string(), + system_prompt: None, + api_key: None, + temperature: None, + max_depth: 3, + }, + ); + let tool = DelegateTool::new(agents, None, test_security()); + let result = tool + .execute(json!({ + "agent": "tester", + "prompt": "do something", + "context": "some context data" + })) + .await + .unwrap(); + + assert!(!result.success); + assert!(result + .error + .as_deref() + .unwrap_or("") + .contains("Failed to create provider")); + } + + #[tokio::test] + async fn delegate_empty_context_omits_prefix() { + let mut agents = HashMap::new(); + agents.insert( + "tester".to_string(), + DelegateAgentConfig { + provider: "invalid-for-test".to_string(), + model: "test-model".to_string(), + system_prompt: None, + api_key: None, + temperature: None, + max_depth: 3, + }, + ); + let tool = DelegateTool::new(agents, None, test_security()); + let result = tool + .execute(json!({ + "agent": "tester", + "prompt": "do something", + "context": "" + })) + .await + .unwrap(); + + assert!(!result.success); + assert!(result + .error + .as_deref() + .unwrap_or("") + .contains("Failed to create provider")); + } + + #[test] + fn delegate_depth_construction() { + let tool = DelegateTool::with_depth(sample_agents(), None, test_security(), 5); + assert_eq!(tool.depth, 5); + } + + #[tokio::test] + async fn delegate_no_agents_configured() { + let tool = DelegateTool::new(HashMap::new(), None, test_security()); + let result = tool + .execute(json!({"agent": "any", "prompt": "test"})) + .await + .unwrap(); + assert!(!result.success); + assert!(result.error.unwrap().contains("none configured")); + } } diff --git a/src/tools/file_write.rs b/src/tools/file_write.rs index 620487f..877c618 100644 --- a/src/tools/file_write.rs +++ b/src/tools/file_write.rs @@ -407,4 +407,62 @@ mod tests { let _ = tokio::fs::remove_dir_all(&dir).await; } + + // ── §5.1 TOCTOU / symlink file write protection tests ──── + + #[cfg(unix)] + #[tokio::test] + async fn file_write_blocks_symlink_target_file() { + use std::os::unix::fs::symlink; + + let root = std::env::temp_dir().join("zeroclaw_test_file_write_symlink_target"); + let workspace = root.join("workspace"); + let outside = root.join("outside"); + + let _ = tokio::fs::remove_dir_all(&root).await; + tokio::fs::create_dir_all(&workspace).await.unwrap(); + tokio::fs::create_dir_all(&outside).await.unwrap(); + + // Create a file outside and symlink to it inside workspace + tokio::fs::write(outside.join("target.txt"), "original") + .await + .unwrap(); + symlink(outside.join("target.txt"), workspace.join("linked.txt")).unwrap(); + + let tool = FileWriteTool::new(test_security(workspace.clone())); + let result = tool + .execute(json!({"path": "linked.txt", "content": "overwritten"})) + .await + .unwrap(); + + assert!(!result.success, "writing through symlink must be blocked"); + assert!( + result.error.as_deref().unwrap_or("").contains("symlink"), + "error should mention symlink" + ); + + // Verify original file was not modified + let content = tokio::fs::read_to_string(outside.join("target.txt")) + .await + .unwrap(); + assert_eq!(content, "original", "original file must not be modified"); + + let _ = tokio::fs::remove_dir_all(&root).await; + } + + #[tokio::test] + async fn file_write_blocks_null_byte_in_path() { + let dir = std::env::temp_dir().join("zeroclaw_test_file_write_null"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + + let tool = FileWriteTool::new(test_security(dir.clone())); + let result = tool + .execute(json!({"path": "file\u{0000}.txt", "content": "bad"})) + .await + .unwrap(); + assert!(!result.success, "paths with null bytes must be blocked"); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } } diff --git a/src/tools/http_request.rs b/src/tools/http_request.rs index d6f4cac..03a44cf 100644 --- a/src/tools/http_request.rs +++ b/src/tools/http_request.rs @@ -808,4 +808,73 @@ mod tests { let tool = test_tool(vec!["example.com"]); assert_eq!(tool.name(), "http_request"); } + + // ── §1.4 DNS rebinding / SSRF defense-in-depth tests ───── + + #[test] + fn ssrf_blocks_loopback_127_range() { + assert!(is_private_or_local_host("127.0.0.1")); + assert!(is_private_or_local_host("127.0.0.2")); + assert!(is_private_or_local_host("127.255.255.255")); + } + + #[test] + fn ssrf_blocks_rfc1918_10_range() { + assert!(is_private_or_local_host("10.0.0.1")); + assert!(is_private_or_local_host("10.255.255.255")); + } + + #[test] + fn ssrf_blocks_rfc1918_172_range() { + assert!(is_private_or_local_host("172.16.0.1")); + assert!(is_private_or_local_host("172.31.255.255")); + } + + #[test] + fn ssrf_blocks_unspecified_address() { + assert!(is_private_or_local_host("0.0.0.0")); + } + + #[test] + fn ssrf_blocks_dot_localhost_subdomain() { + assert!(is_private_or_local_host("evil.localhost")); + assert!(is_private_or_local_host("a.b.localhost")); + } + + #[test] + fn ssrf_blocks_dot_local_tld() { + assert!(is_private_or_local_host("service.local")); + } + + #[test] + fn ssrf_ipv6_unspecified() { + assert!(is_private_or_local_host("::")); + } + + #[test] + fn validate_rejects_ftp_scheme() { + let tool = test_tool(vec!["example.com"]); + let err = tool + .validate_url("ftp://example.com") + .unwrap_err() + .to_string(); + assert!(err.contains("http://") || err.contains("https://")); + } + + #[test] + fn validate_rejects_empty_url() { + let tool = test_tool(vec!["example.com"]); + let err = tool.validate_url("").unwrap_err().to_string(); + assert!(err.contains("empty")); + } + + #[test] + fn validate_rejects_ipv6_host() { + let tool = test_tool(vec!["example.com"]); + let err = tool + .validate_url("http://[::1]:8080/path") + .unwrap_err() + .to_string(); + assert!(err.contains("IPv6")); + } } diff --git a/src/tools/shell.rs b/src/tools/shell.rs index 662d7ab..031ed4b 100644 --- a/src/tools/shell.rs +++ b/src/tools/shell.rs @@ -365,4 +365,62 @@ mod tests { let _ = std::fs::remove_file(std::env::temp_dir().join("zeroclaw_shell_approval_test")); } + + // ── §5.2 Shell timeout enforcement tests ───────────────── + + #[test] + fn shell_timeout_constant_is_reasonable() { + assert_eq!(SHELL_TIMEOUT_SECS, 60, "shell timeout must be 60 seconds"); + } + + #[test] + fn shell_output_limit_is_1mb() { + assert_eq!( + MAX_OUTPUT_BYTES, 1_048_576, + "max output must be 1 MB to prevent OOM" + ); + } + + // ── §5.3 Non-UTF8 binary output tests ──────────────────── + + #[test] + fn shell_safe_env_vars_excludes_secrets() { + for var in SAFE_ENV_VARS { + let lower = var.to_lowercase(); + assert!( + !lower.contains("key") && !lower.contains("secret") && !lower.contains("token"), + "SAFE_ENV_VARS must not include sensitive variable: {var}" + ); + } + } + + #[test] + fn shell_safe_env_vars_includes_essentials() { + assert!( + SAFE_ENV_VARS.contains(&"PATH"), + "PATH must be in safe env vars" + ); + assert!( + SAFE_ENV_VARS.contains(&"HOME"), + "HOME must be in safe env vars" + ); + assert!( + SAFE_ENV_VARS.contains(&"TERM"), + "TERM must be in safe env vars" + ); + } + + #[tokio::test] + async fn shell_blocks_rate_limited() { + let security = Arc::new(SecurityPolicy { + autonomy: AutonomyLevel::Supervised, + max_actions_per_hour: 0, + workspace_dir: std::env::temp_dir(), + ..SecurityPolicy::default() + }); + let tool = ShellTool::new(security, test_runtime()); + let result = tool.execute(json!({"command": "echo test"})).await.unwrap(); + assert!(!result.success); + assert!(result.error.as_deref().unwrap_or("").contains("Rate limit")); + } }