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"));