From a2fdf64a5e87e9b0955c266cc2405b95f72b74cc 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] 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 312a7d388..3c26d76f8 100644 --- a/src/security/policy.rs +++ b/src/security/policy.rs @@ -1193,6 +1193,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." @@ -2787,4 +2825,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 fd2dfefba..63a7fe160 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() { @@ -762,4 +773,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 6a47ea20c..fd2e0a37c 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() { @@ -529,4 +540,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; + } }