From 6211824f015d685079893dd46decbb6827b3b6f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E9=BE=99=200668001470?= Date: Mon, 16 Mar 2026 17:21:22 +0800 Subject: [PATCH 1/3] fix(security): block runtime config state edits --- src/security/policy.rs | 67 +++++++++++++++++++++++++++++++++++++++++ src/tools/file_edit.rs | 49 ++++++++++++++++++++++++++++++ src/tools/file_write.rs | 45 +++++++++++++++++++++++++++ 3 files changed, 161 insertions(+) diff --git a/src/security/policy.rs b/src/security/policy.rs index 72fe16bce..6ddae6f24 100644 --- a/src/security/policy.rs +++ b/src/security/policy.rs @@ -1173,6 +1173,44 @@ impl SecurityPolicy { false } + fn runtime_config_dir(&self) -> Option { + let parent = self.workspace_dir.parent()?; + Some( + parent + .canonicalize() + .unwrap_or_else(|_| parent.to_path_buf()), + ) + } + + pub fn is_runtime_config_path(&self, resolved: &Path) -> bool { + let Some(config_dir) = self.runtime_config_dir() else { + return false; + }; + if !resolved.starts_with(&config_dir) { + return false; + } + if resolved.parent() != Some(config_dir.as_path()) { + return false; + } + + let Some(file_name) = resolved.file_name().and_then(|value| value.to_str()) else { + return false; + }; + + file_name == "config.toml" + || file_name == "config.toml.bak" + || file_name == "active_workspace.toml" + || file_name.starts_with(".config.toml.tmp-") + || file_name.starts_with(".active_workspace.toml.tmp-") + } + + pub fn runtime_config_violation_message(&self, resolved: &Path) -> String { + format!( + "Refusing to modify ZeroClaw runtime config/state file: {}. Use dedicated config tools or edit it manually outside the agent loop.", + resolved.display() + ) + } + pub fn resolved_path_violation_message(&self, resolved: &Path) -> String { let guidance = if self.allowed_roots.is_empty() { "Add the directory to [autonomy].allowed_roots (for example: allowed_roots = [\"/absolute/path\"]), or move the file into the workspace." @@ -2744,4 +2782,33 @@ mod tests { }; assert!(!p.is_under_allowed_root("/any/path")); } + + #[test] + fn runtime_config_paths_are_protected() { + let workspace = PathBuf::from("/tmp/zeroclaw-profile/workspace"); + let policy = SecurityPolicy { + workspace_dir: workspace.clone(), + ..SecurityPolicy::default() + }; + let config_dir = workspace.parent().unwrap(); + + assert!(policy.is_runtime_config_path(&config_dir.join("config.toml"))); + assert!(policy.is_runtime_config_path(&config_dir.join("config.toml.bak"))); + assert!(policy.is_runtime_config_path(&config_dir.join(".config.toml.tmp-1234"))); + assert!(policy.is_runtime_config_path(&config_dir.join("active_workspace.toml"))); + assert!(policy.is_runtime_config_path(&config_dir.join(".active_workspace.toml.tmp-1234"))); + } + + #[test] + fn workspace_files_are_not_runtime_config_paths() { + let workspace = PathBuf::from("/tmp/zeroclaw-profile/workspace"); + let policy = SecurityPolicy { + workspace_dir: workspace.clone(), + ..SecurityPolicy::default() + }; + let nested_dir = workspace.join("notes"); + + assert!(!policy.is_runtime_config_path(&workspace.join("notes.txt"))); + assert!(!policy.is_runtime_config_path(&nested_dir.join("config.toml"))); + } } diff --git a/src/tools/file_edit.rs b/src/tools/file_edit.rs index 19c5f0cc6..98e4972cb 100644 --- a/src/tools/file_edit.rs +++ b/src/tools/file_edit.rs @@ -147,6 +147,17 @@ impl Tool for FileEditTool { let resolved_target = resolved_parent.join(file_name); + if self.security.is_runtime_config_path(&resolved_target) { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some( + self.security + .runtime_config_violation_message(&resolved_target), + ), + }); + } + // ── 7. Symlink check ─────────────────────────────────────── if let Ok(meta) = tokio::fs::symlink_metadata(&resolved_target).await { if meta.file_type().is_symlink() { @@ -686,4 +697,42 @@ mod tests { let _ = tokio::fs::remove_dir_all(&dir).await; } + + #[tokio::test] + async fn file_edit_blocks_runtime_config_path() { + let root = std::env::temp_dir().join("zeroclaw_test_file_edit_runtime_config"); + let workspace = root.join("workspace"); + let config_path = root.join("config.toml"); + let _ = tokio::fs::remove_dir_all(&root).await; + tokio::fs::create_dir_all(&workspace).await.unwrap(); + tokio::fs::write(&config_path, "always_ask = [\"cron_add\"]") + .await + .unwrap(); + + let security = Arc::new(SecurityPolicy { + autonomy: AutonomyLevel::Supervised, + workspace_dir: workspace.clone(), + workspace_only: false, + allowed_roots: vec![root.clone()], + forbidden_paths: vec![], + ..SecurityPolicy::default() + }); + let tool = FileEditTool::new(security); + let result = tool + .execute(json!({ + "path": config_path.to_string_lossy(), + "old_string": "always_ask", + "new_string": "auto_approve" + })) + .await + .unwrap(); + + assert!(!result.success); + assert!(result + .error + .unwrap_or_default() + .contains("runtime config/state file")); + + let _ = tokio::fs::remove_dir_all(&root).await; + } } diff --git a/src/tools/file_write.rs b/src/tools/file_write.rs index 7ce604eb4..ac8e786bc 100644 --- a/src/tools/file_write.rs +++ b/src/tools/file_write.rs @@ -124,6 +124,17 @@ impl Tool for FileWriteTool { let resolved_target = resolved_parent.join(file_name); + if self.security.is_runtime_config_path(&resolved_target) { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some( + self.security + .runtime_config_violation_message(&resolved_target), + ), + }); + } + // If the target already exists and is a symlink, refuse to follow it if let Ok(meta) = tokio::fs::symlink_metadata(&resolved_target).await { if meta.file_type().is_symlink() { @@ -465,4 +476,38 @@ mod tests { let _ = tokio::fs::remove_dir_all(&dir).await; } + + #[tokio::test] + async fn file_write_blocks_runtime_config_path() { + let root = std::env::temp_dir().join("zeroclaw_test_file_write_runtime_config"); + let workspace = root.join("workspace"); + let config_path = root.join("config.toml"); + let _ = tokio::fs::remove_dir_all(&root).await; + tokio::fs::create_dir_all(&workspace).await.unwrap(); + + let security = Arc::new(SecurityPolicy { + autonomy: AutonomyLevel::Supervised, + workspace_dir: workspace.clone(), + workspace_only: false, + allowed_roots: vec![root.clone()], + forbidden_paths: vec![], + ..SecurityPolicy::default() + }); + let tool = FileWriteTool::new(security); + let result = tool + .execute(json!({ + "path": config_path.to_string_lossy(), + "content": "auto_approve = [\"cron_add\"]" + })) + .await + .unwrap(); + + assert!(!result.success); + assert!(result + .error + .unwrap_or_default() + .contains("runtime config/state file")); + + let _ = tokio::fs::remove_dir_all(&root).await; + } } From eb9b26cea0fec39a694727b307111f08661546c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E9=BE=99=200668001470?= Date: Mon, 16 Mar 2026 17:44:59 +0800 Subject: [PATCH 2/3] test(config): centralize backward-compat fixtures --- src/config/schema.rs | 73 ++++++++++++++++++++++++++++++-------------- 1 file changed, 50 insertions(+), 23 deletions(-) diff --git a/src/config/schema.rs b/src/config/schema.rs index 8cc0e57e0..5d92d5633 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -7082,6 +7082,35 @@ mod tests { // ── Defaults ───────────────────────────────────────────── + fn has_test_table(raw: &str, table: &str) -> bool { + let exact = format!("[{table}]"); + let nested = format!("[{table}."); + raw.lines() + .map(str::trim) + .any(|line| line == exact || line.starts_with(&nested)) + } + + fn parse_test_config(raw: &str) -> Config { + let mut merged = raw.trim().to_string(); + for table in [ + "data_retention", + "cloud_ops", + "conversational_ai", + "security", + "security_ops", + ] { + if has_test_table(&merged, table) { + continue; + } + if !merged.is_empty() { + merged.push_str("\n\n"); + } + merged.push_str(&format!("[{table}]")); + } + merged.push('\n'); + toml::from_str(&merged).unwrap() + } + #[test] async fn http_request_config_default_has_correct_values() { let cfg = HttpRequestConfig::default(); @@ -7260,7 +7289,7 @@ config_path = "/tmp/config.toml" default_temperature = 0.7 "#; - let parsed: Config = toml::from_str(toml_str).unwrap(); + let parsed = parse_test_config(toml_str); assert!(parsed.cron.enabled); assert_eq!(parsed.cron.max_run_history, 50); } @@ -7423,7 +7452,7 @@ default_temperature = 0.7 }; let toml_str = toml::to_string_pretty(&config).unwrap(); - let parsed: Config = toml::from_str(&toml_str).unwrap(); + let parsed = parse_test_config(&toml_str); assert_eq!(parsed.api_key, config.api_key); assert_eq!(parsed.default_provider, config.default_provider); @@ -7456,7 +7485,7 @@ workspace_dir = "/tmp/ws" config_path = "/tmp/config.toml" default_temperature = 0.7 "#; - let parsed: Config = toml::from_str(minimal).unwrap(); + let parsed = parse_test_config(minimal); assert!(parsed.api_key.is_none()); assert!(parsed.default_provider.is_none()); assert_eq!(parsed.observability.backend, "none"); @@ -7479,7 +7508,7 @@ default_temperature = 0.7 default_temperature = 0.7 provider_timeout_secs = 300 "#; - let parsed: Config = toml::from_str(raw).unwrap(); + let parsed = parse_test_config(raw); assert_eq!(parsed.provider_timeout_secs, 300); } @@ -7559,7 +7588,7 @@ default_temperature = 0.7 User-Agent = "MyApp/1.0" X-Title = "zeroclaw" "#; - let parsed: Config = toml::from_str(raw).unwrap(); + let parsed = parse_test_config(raw); assert_eq!(parsed.extra_headers.len(), 2); assert_eq!(parsed.extra_headers.get("User-Agent").unwrap(), "MyApp/1.0"); assert_eq!(parsed.extra_headers.get("X-Title").unwrap(), "zeroclaw"); @@ -7570,7 +7599,7 @@ X-Title = "zeroclaw" let raw = r#" default_temperature = 0.7 "#; - let parsed: Config = toml::from_str(raw).unwrap(); + let parsed = parse_test_config(raw); assert!(parsed.extra_headers.is_empty()); } @@ -7587,7 +7616,7 @@ table = "memories" connect_timeout_secs = 12 "#; - let parsed: Config = toml::from_str(raw).unwrap(); + let parsed = parse_test_config(raw); assert_eq!(parsed.storage.provider.config.provider, "postgres"); assert_eq!( parsed.storage.provider.config.db_url.as_deref(), @@ -7610,7 +7639,7 @@ default_temperature = 0.7 reasoning_enabled = false "#; - let parsed: Config = toml::from_str(raw).unwrap(); + let parsed = parse_test_config(raw); assert_eq!(parsed.runtime.reasoning_enabled, Some(false)); } @@ -7635,7 +7664,7 @@ max_history_messages = 80 parallel_tools = true tool_dispatcher = "xml" "#; - let parsed: Config = toml::from_str(raw).unwrap(); + let parsed = parse_test_config(raw); assert!(parsed.agent.compact_context); assert_eq!(parsed.agent.max_tool_iterations, 20); assert_eq!(parsed.agent.max_history_messages, 80); @@ -8456,7 +8485,7 @@ workspace_dir = "/tmp/ws" config_path = "/tmp/config.toml" default_temperature = 0.7 "#; - let parsed: Config = toml::from_str(minimal).unwrap(); + let parsed = parse_test_config(minimal); assert!( parsed.gateway.require_pairing, "Missing [gateway] must default to require_pairing=true" @@ -8518,7 +8547,7 @@ workspace_dir = "/tmp/ws" config_path = "/tmp/config.toml" default_temperature = 0.7 "#; - let parsed: Config = toml::from_str(minimal).unwrap(); + let parsed = parse_test_config(minimal); assert!( !parsed.composio.enabled, "Missing [composio] must default to disabled" @@ -8573,7 +8602,7 @@ workspace_dir = "/tmp/ws" config_path = "/tmp/config.toml" default_temperature = 0.7 "#; - let parsed: Config = toml::from_str(minimal).unwrap(); + let parsed = parse_test_config(minimal); assert!( parsed.secrets.encrypt, "Missing [secrets] must default to encrypt=true" @@ -8658,7 +8687,7 @@ workspace_dir = "/tmp/ws" config_path = "/tmp/config.toml" default_temperature = 0.7 "#; - let parsed: Config = toml::from_str(minimal).unwrap(); + let parsed = parse_test_config(minimal); assert!(!parsed.browser.enabled); assert!(parsed.browser.allowed_domains.is_empty()); } @@ -8757,7 +8786,7 @@ wire_api = "responses" requires_openai_auth = true "#; - let parsed: Config = toml::from_str(raw).expect("config should parse"); + let parsed = parse_test_config(raw); assert_eq!(parsed.default_provider.as_deref(), Some("sub2api")); assert_eq!(parsed.default_model.as_deref(), Some("gpt-5.3-codex")); let profile = parsed @@ -8995,7 +9024,7 @@ requires_openai_auth = true let saved = tokio::fs::read_to_string(&resolved_config_path) .await .unwrap(); - let parsed: Config = toml::from_str(&saved).unwrap(); + let parsed = parse_test_config(&saved); assert_eq!(parsed.default_temperature, 0.5); std::env::remove_var("ZEROCLAW_WORKSPACE"); @@ -10067,7 +10096,7 @@ default_model = "legacy-model" config.transcription.language = Some("en".into()); let toml_str = toml::to_string_pretty(&config).unwrap(); - let parsed: Config = toml::from_str(&toml_str).unwrap(); + let parsed = parse_test_config(&toml_str); assert!(parsed.transcription.enabled); assert_eq!(parsed.transcription.language.as_deref(), Some("en")); @@ -10081,21 +10110,20 @@ default_model = "legacy-model" default_model = "test-model" default_temperature = 0.7 "#; - let parsed: Config = toml::from_str(toml_str).unwrap(); + let parsed = parse_test_config(toml_str); assert!(!parsed.transcription.enabled); assert_eq!(parsed.transcription.max_duration_secs, 120); } #[test] async fn security_defaults_are_backward_compatible() { - let parsed: Config = toml::from_str( + let parsed = parse_test_config( r#" default_provider = "openrouter" default_model = "anthropic/claude-sonnet-4.6" default_temperature = 0.7 "#, - ) - .unwrap(); + ); assert!(!parsed.security.otp.enabled); assert_eq!(parsed.security.otp.method, OtpMethod::Totp); @@ -10105,7 +10133,7 @@ default_temperature = 0.7 #[test] async fn security_toml_parses_otp_and_estop_sections() { - let parsed: Config = toml::from_str( + let parsed = parse_test_config( r#" default_provider = "openrouter" default_model = "anthropic/claude-sonnet-4.6" @@ -10125,8 +10153,7 @@ enabled = true state_file = "~/.zeroclaw/estop-state.json" require_otp_to_resume = true "#, - ) - .unwrap(); + ); assert!(parsed.security.otp.enabled); assert!(parsed.security.estop.enabled); From 81256dbf42a643587fc41a9018ffae509b8339b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E9=BE=99=200668001470?= Date: Mon, 16 Mar 2026 17:56:10 +0800 Subject: [PATCH 3/3] test(config): fix helper lint and swarms fixture --- src/config/schema.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/config/schema.rs b/src/config/schema.rs index 5d92d5633..b1d1b4982 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -7105,7 +7105,9 @@ mod tests { if !merged.is_empty() { merged.push_str("\n\n"); } - merged.push_str(&format!("[{table}]")); + merged.push('['); + merged.push_str(table); + merged.push(']'); } merged.push('\n'); toml::from_str(&merged).unwrap() @@ -10558,7 +10560,7 @@ require_otp_to_resume = true agents = ["researcher", "writer"] strategy = "sequential" "#; - let config: Config = toml::from_str(toml_str).expect("deserialize"); + let config = parse_test_config(toml_str); assert_eq!(config.agents.len(), 2); assert_eq!(config.swarms.len(), 1); assert!(config.swarms.contains_key("pipeline"));