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:
parent
f1e3b1166d
commit
b0e1e32819
11 changed files with 1202 additions and 67 deletions
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue