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;
+ }
}