feat(config): make config writes atomic with rollback-safe replacement (#190)

* feat(runtime): add Docker runtime MVP and runtime-aware command builder

* feat(security): add shell risk classification, approval gates, and action throttling

* feat(gateway): add per-endpoint rate limiting and webhook idempotency

* feat(config): make config writes atomic with rollback-safe replacement

---------

Co-authored-by: chumyin <chumyin@users.noreply.github.com>
This commit is contained in:
Chummy 2026-02-16 01:18:45 +08:00 committed by GitHub
parent f1e3b1166d
commit b0e1e32819
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1202 additions and 67 deletions

View file

@ -1,4 +1,5 @@
use super::traits::{Tool, ToolResult};
use crate::runtime::RuntimeAdapter;
use crate::security::SecurityPolicy;
use async_trait::async_trait;
use serde_json::json;
@ -18,11 +19,12 @@ const SAFE_ENV_VARS: &[&str] = &[
/// Shell command execution tool with sandboxing
pub struct ShellTool {
security: Arc<SecurityPolicy>,
runtime: Arc<dyn RuntimeAdapter>,
}
impl ShellTool {
pub fn new(security: Arc<SecurityPolicy>) -> Self {
Self { security }
pub fn new(security: Arc<SecurityPolicy>, runtime: Arc<dyn RuntimeAdapter>) -> Self {
Self { security, runtime }
}
}
@ -43,6 +45,11 @@ impl Tool for ShellTool {
"command": {
"type": "string",
"description": "The shell command to execute"
},
"approved": {
"type": "boolean",
"description": "Set true to explicitly approve medium/high-risk commands in supervised mode",
"default": false
}
},
"required": ["command"]
@ -54,24 +61,55 @@ impl Tool for ShellTool {
.get("command")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'command' parameter"))?;
let approved = args
.get("approved")
.and_then(|v| v.as_bool())
.unwrap_or(false);
// Security check: validate command against allowlist
if !self.security.is_command_allowed(command) {
if self.security.is_rate_limited() {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!("Command not allowed by security policy: {command}")),
error: Some("Rate limit exceeded: too many actions in the last hour".into()),
});
}
match self.security.validate_command_execution(command, approved) {
Ok(_) => {}
Err(reason) => {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(reason),
});
}
}
if !self.security.record_action() {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some("Rate limit exceeded: action budget exhausted".into()),
});
}
// Execute with timeout to prevent hanging commands.
// Clear the environment to prevent leaking API keys and other secrets
// (CWE-200), then re-add only safe, functional variables.
let mut cmd = tokio::process::Command::new("sh");
cmd.arg("-c")
.arg(command)
.current_dir(&self.security.workspace_dir)
.env_clear();
let mut cmd = match self
.runtime
.build_shell_command(command, &self.security.workspace_dir)
{
Ok(cmd) => cmd,
Err(e) => {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!("Failed to build runtime command: {e}")),
});
}
};
cmd.env_clear();
for var in SAFE_ENV_VARS {
if let Ok(val) = std::env::var(var) {
@ -126,6 +164,7 @@ impl Tool for ShellTool {
#[cfg(test)]
mod tests {
use super::*;
use crate::runtime::{NativeRuntime, RuntimeAdapter};
use crate::security::{AutonomyLevel, SecurityPolicy};
fn test_security(autonomy: AutonomyLevel) -> Arc<SecurityPolicy> {
@ -136,32 +175,37 @@ mod tests {
})
}
fn test_runtime() -> Arc<dyn RuntimeAdapter> {
Arc::new(NativeRuntime::new())
}
#[test]
fn shell_tool_name() {
let tool = ShellTool::new(test_security(AutonomyLevel::Supervised));
let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime());
assert_eq!(tool.name(), "shell");
}
#[test]
fn shell_tool_description() {
let tool = ShellTool::new(test_security(AutonomyLevel::Supervised));
let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime());
assert!(!tool.description().is_empty());
}
#[test]
fn shell_tool_schema_has_command() {
let tool = ShellTool::new(test_security(AutonomyLevel::Supervised));
let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime());
let schema = tool.parameters_schema();
assert!(schema["properties"]["command"].is_object());
assert!(schema["required"]
.as_array()
.unwrap()
.contains(&json!("command")));
assert!(schema["properties"]["approved"].is_object());
}
#[tokio::test]
async fn shell_executes_allowed_command() {
let tool = ShellTool::new(test_security(AutonomyLevel::Supervised));
let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime());
let result = tool
.execute(json!({"command": "echo hello"}))
.await
@ -173,15 +217,16 @@ mod tests {
#[tokio::test]
async fn shell_blocks_disallowed_command() {
let tool = ShellTool::new(test_security(AutonomyLevel::Supervised));
let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime());
let result = tool.execute(json!({"command": "rm -rf /"})).await.unwrap();
assert!(!result.success);
assert!(result.error.as_ref().unwrap().contains("not allowed"));
let error = result.error.as_deref().unwrap_or("");
assert!(error.contains("not allowed") || error.contains("high-risk"));
}
#[tokio::test]
async fn shell_blocks_readonly() {
let tool = ShellTool::new(test_security(AutonomyLevel::ReadOnly));
let tool = ShellTool::new(test_security(AutonomyLevel::ReadOnly), test_runtime());
let result = tool.execute(json!({"command": "ls"})).await.unwrap();
assert!(!result.success);
assert!(result.error.as_ref().unwrap().contains("not allowed"));
@ -189,7 +234,7 @@ mod tests {
#[tokio::test]
async fn shell_missing_command_param() {
let tool = ShellTool::new(test_security(AutonomyLevel::Supervised));
let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime());
let result = tool.execute(json!({})).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("command"));
@ -197,14 +242,14 @@ mod tests {
#[tokio::test]
async fn shell_wrong_type_param() {
let tool = ShellTool::new(test_security(AutonomyLevel::Supervised));
let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime());
let result = tool.execute(json!({"command": 123})).await;
assert!(result.is_err());
}
#[tokio::test]
async fn shell_captures_exit_code() {
let tool = ShellTool::new(test_security(AutonomyLevel::Supervised));
let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime());
let result = tool
.execute(json!({"command": "ls /nonexistent_dir_xyz"}))
.await
@ -250,7 +295,7 @@ mod tests {
let _g1 = EnvGuard::set("API_KEY", "sk-test-secret-12345");
let _g2 = EnvGuard::set("ZEROCLAW_API_KEY", "sk-test-secret-67890");
let tool = ShellTool::new(test_security_with_env_cmd());
let tool = ShellTool::new(test_security_with_env_cmd(), test_runtime());
let result = tool.execute(json!({"command": "env"})).await.unwrap();
assert!(result.success);
assert!(
@ -265,7 +310,7 @@ mod tests {
#[tokio::test]
async fn shell_preserves_path_and_home() {
let tool = ShellTool::new(test_security_with_env_cmd());
let tool = ShellTool::new(test_security_with_env_cmd(), test_runtime());
let result = tool
.execute(json!({"command": "echo $HOME"}))
@ -287,4 +332,37 @@ mod tests {
"PATH should be available in shell"
);
}
#[tokio::test]
async fn shell_requires_approval_for_medium_risk_command() {
let security = Arc::new(SecurityPolicy {
autonomy: AutonomyLevel::Supervised,
allowed_commands: vec!["touch".into()],
workspace_dir: std::env::temp_dir(),
..SecurityPolicy::default()
});
let tool = ShellTool::new(security.clone(), test_runtime());
let denied = tool
.execute(json!({"command": "touch zeroclaw_shell_approval_test"}))
.await
.unwrap();
assert!(!denied.success);
assert!(denied
.error
.as_deref()
.unwrap_or("")
.contains("explicit approval"));
let allowed = tool
.execute(json!({
"command": "touch zeroclaw_shell_approval_test",
"approved": true
}))
.await
.unwrap();
assert!(allowed.success);
let _ = std::fs::remove_file(std::env::temp_dir().join("zeroclaw_shell_approval_test"));
}
}