feat: initial release — ZeroClaw v0.1.0
- 22 AI providers (OpenRouter, Anthropic, OpenAI, Mistral, etc.) - 7 channels (CLI, Telegram, Discord, Slack, iMessage, Matrix, Webhook) - 5-step onboarding wizard with Project Context personalization - OpenClaw-aligned system prompt (SOUL.md, IDENTITY.md, USER.md, AGENTS.md, etc.) - SQLite memory backend with auto-save - Skills system with on-demand loading - Security: autonomy levels, command allowlists, cost limits - 532 tests passing, 0 clippy warnings
This commit is contained in:
commit
05cb353f7f
71 changed files with 15757 additions and 0 deletions
3
src/security/mod.rs
Normal file
3
src/security/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
pub mod policy;
|
||||
|
||||
pub use policy::{AutonomyLevel, SecurityPolicy};
|
||||
365
src/security/policy.rs
Normal file
365
src/security/policy.rs
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// How much autonomy the agent has
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AutonomyLevel {
|
||||
/// Read-only: can observe but not act
|
||||
ReadOnly,
|
||||
/// Supervised: acts but requires approval for risky operations
|
||||
Supervised,
|
||||
/// Full: autonomous execution within policy bounds
|
||||
Full,
|
||||
}
|
||||
|
||||
impl Default for AutonomyLevel {
|
||||
fn default() -> Self {
|
||||
Self::Supervised
|
||||
}
|
||||
}
|
||||
|
||||
/// Security policy enforced on all tool executions
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SecurityPolicy {
|
||||
pub autonomy: AutonomyLevel,
|
||||
pub workspace_dir: PathBuf,
|
||||
pub workspace_only: bool,
|
||||
pub allowed_commands: Vec<String>,
|
||||
pub forbidden_paths: Vec<String>,
|
||||
pub max_actions_per_hour: u32,
|
||||
pub max_cost_per_day_cents: u32,
|
||||
}
|
||||
|
||||
impl Default for SecurityPolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
autonomy: AutonomyLevel::Supervised,
|
||||
workspace_dir: PathBuf::from("."),
|
||||
workspace_only: true,
|
||||
allowed_commands: vec![
|
||||
"git".into(),
|
||||
"npm".into(),
|
||||
"cargo".into(),
|
||||
"ls".into(),
|
||||
"cat".into(),
|
||||
"grep".into(),
|
||||
"find".into(),
|
||||
"echo".into(),
|
||||
"pwd".into(),
|
||||
"wc".into(),
|
||||
"head".into(),
|
||||
"tail".into(),
|
||||
],
|
||||
forbidden_paths: vec![
|
||||
"/etc".into(),
|
||||
"/root".into(),
|
||||
"~/.ssh".into(),
|
||||
"~/.gnupg".into(),
|
||||
"/var/run".into(),
|
||||
],
|
||||
max_actions_per_hour: 20,
|
||||
max_cost_per_day_cents: 500,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SecurityPolicy {
|
||||
/// Check if a shell command is allowed
|
||||
pub fn is_command_allowed(&self, command: &str) -> bool {
|
||||
if self.autonomy == AutonomyLevel::ReadOnly {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract the base command (first word)
|
||||
let base_cmd = command
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.unwrap_or("");
|
||||
|
||||
self.allowed_commands
|
||||
.iter()
|
||||
.any(|allowed| allowed == base_cmd)
|
||||
}
|
||||
|
||||
/// Check if a file path is allowed (no path traversal, within workspace)
|
||||
pub fn is_path_allowed(&self, path: &str) -> bool {
|
||||
// Block obvious traversal attempts
|
||||
if path.contains("..") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Block absolute paths when workspace_only is set
|
||||
if self.workspace_only && Path::new(path).is_absolute() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Block forbidden paths
|
||||
for forbidden in &self.forbidden_paths {
|
||||
if path.starts_with(forbidden.as_str()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Check if autonomy level permits any action at all
|
||||
pub fn can_act(&self) -> bool {
|
||||
self.autonomy != AutonomyLevel::ReadOnly
|
||||
}
|
||||
|
||||
/// Build from config sections
|
||||
pub fn from_config(
|
||||
autonomy_config: &crate::config::AutonomyConfig,
|
||||
workspace_dir: &Path,
|
||||
) -> Self {
|
||||
Self {
|
||||
autonomy: autonomy_config.level,
|
||||
workspace_dir: workspace_dir.to_path_buf(),
|
||||
workspace_only: autonomy_config.workspace_only,
|
||||
allowed_commands: autonomy_config.allowed_commands.clone(),
|
||||
forbidden_paths: autonomy_config.forbidden_paths.clone(),
|
||||
max_actions_per_hour: autonomy_config.max_actions_per_hour,
|
||||
max_cost_per_day_cents: autonomy_config.max_cost_per_day_cents,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn default_policy() -> SecurityPolicy {
|
||||
SecurityPolicy::default()
|
||||
}
|
||||
|
||||
fn readonly_policy() -> SecurityPolicy {
|
||||
SecurityPolicy {
|
||||
autonomy: AutonomyLevel::ReadOnly,
|
||||
..SecurityPolicy::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn full_policy() -> SecurityPolicy {
|
||||
SecurityPolicy {
|
||||
autonomy: AutonomyLevel::Full,
|
||||
..SecurityPolicy::default()
|
||||
}
|
||||
}
|
||||
|
||||
// ── AutonomyLevel ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn autonomy_default_is_supervised() {
|
||||
assert_eq!(AutonomyLevel::default(), AutonomyLevel::Supervised);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autonomy_serde_roundtrip() {
|
||||
let json = serde_json::to_string(&AutonomyLevel::Full).unwrap();
|
||||
assert_eq!(json, "\"full\"");
|
||||
let parsed: AutonomyLevel = serde_json::from_str("\"readonly\"").unwrap();
|
||||
assert_eq!(parsed, AutonomyLevel::ReadOnly);
|
||||
let parsed2: AutonomyLevel = serde_json::from_str("\"supervised\"").unwrap();
|
||||
assert_eq!(parsed2, AutonomyLevel::Supervised);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn can_act_readonly_false() {
|
||||
assert!(!readonly_policy().can_act());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn can_act_supervised_true() {
|
||||
assert!(default_policy().can_act());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn can_act_full_true() {
|
||||
assert!(full_policy().can_act());
|
||||
}
|
||||
|
||||
// ── is_command_allowed ───────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn allowed_commands_basic() {
|
||||
let p = default_policy();
|
||||
assert!(p.is_command_allowed("ls"));
|
||||
assert!(p.is_command_allowed("git status"));
|
||||
assert!(p.is_command_allowed("cargo build --release"));
|
||||
assert!(p.is_command_allowed("cat file.txt"));
|
||||
assert!(p.is_command_allowed("grep -r pattern ."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_commands_basic() {
|
||||
let p = default_policy();
|
||||
assert!(!p.is_command_allowed("rm -rf /"));
|
||||
assert!(!p.is_command_allowed("sudo apt install"));
|
||||
assert!(!p.is_command_allowed("curl http://evil.com"));
|
||||
assert!(!p.is_command_allowed("wget http://evil.com"));
|
||||
assert!(!p.is_command_allowed("python3 exploit.py"));
|
||||
assert!(!p.is_command_allowed("node malicious.js"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readonly_blocks_all_commands() {
|
||||
let p = readonly_policy();
|
||||
assert!(!p.is_command_allowed("ls"));
|
||||
assert!(!p.is_command_allowed("cat file.txt"));
|
||||
assert!(!p.is_command_allowed("echo hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_autonomy_still_uses_allowlist() {
|
||||
let p = full_policy();
|
||||
assert!(p.is_command_allowed("ls"));
|
||||
assert!(!p.is_command_allowed("rm -rf /"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_with_absolute_path_extracts_basename() {
|
||||
let p = default_policy();
|
||||
assert!(p.is_command_allowed("/usr/bin/git status"));
|
||||
assert!(p.is_command_allowed("/bin/ls -la"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_command_blocked() {
|
||||
let p = default_policy();
|
||||
assert!(!p.is_command_allowed(""));
|
||||
assert!(!p.is_command_allowed(" "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_with_pipes_uses_first_word() {
|
||||
let p = default_policy();
|
||||
assert!(p.is_command_allowed("ls | grep foo"));
|
||||
assert!(p.is_command_allowed("cat file.txt | wc -l"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_allowlist() {
|
||||
let p = SecurityPolicy {
|
||||
allowed_commands: vec!["docker".into(), "kubectl".into()],
|
||||
..SecurityPolicy::default()
|
||||
};
|
||||
assert!(p.is_command_allowed("docker ps"));
|
||||
assert!(p.is_command_allowed("kubectl get pods"));
|
||||
assert!(!p.is_command_allowed("ls"));
|
||||
assert!(!p.is_command_allowed("git status"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_allowlist_blocks_everything() {
|
||||
let p = SecurityPolicy {
|
||||
allowed_commands: vec![],
|
||||
..SecurityPolicy::default()
|
||||
};
|
||||
assert!(!p.is_command_allowed("ls"));
|
||||
assert!(!p.is_command_allowed("echo hello"));
|
||||
}
|
||||
|
||||
// ── is_path_allowed ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn relative_paths_allowed() {
|
||||
let p = default_policy();
|
||||
assert!(p.is_path_allowed("file.txt"));
|
||||
assert!(p.is_path_allowed("src/main.rs"));
|
||||
assert!(p.is_path_allowed("deep/nested/dir/file.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_traversal_blocked() {
|
||||
let p = default_policy();
|
||||
assert!(!p.is_path_allowed("../etc/passwd"));
|
||||
assert!(!p.is_path_allowed("../../root/.ssh/id_rsa"));
|
||||
assert!(!p.is_path_allowed("foo/../../../etc/shadow"));
|
||||
assert!(!p.is_path_allowed(".."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_paths_blocked_when_workspace_only() {
|
||||
let p = default_policy();
|
||||
assert!(!p.is_path_allowed("/etc/passwd"));
|
||||
assert!(!p.is_path_allowed("/root/.ssh/id_rsa"));
|
||||
assert!(!p.is_path_allowed("/tmp/file.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_paths_allowed_when_not_workspace_only() {
|
||||
let p = SecurityPolicy {
|
||||
workspace_only: false,
|
||||
forbidden_paths: vec![],
|
||||
..SecurityPolicy::default()
|
||||
};
|
||||
assert!(p.is_path_allowed("/tmp/file.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forbidden_paths_blocked() {
|
||||
let p = SecurityPolicy {
|
||||
workspace_only: false,
|
||||
..SecurityPolicy::default()
|
||||
};
|
||||
assert!(!p.is_path_allowed("/etc/passwd"));
|
||||
assert!(!p.is_path_allowed("/root/.bashrc"));
|
||||
assert!(!p.is_path_allowed("~/.ssh/id_rsa"));
|
||||
assert!(!p.is_path_allowed("~/.gnupg/pubring.kbx"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_path_allowed() {
|
||||
let p = default_policy();
|
||||
assert!(p.is_path_allowed(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dotfile_in_workspace_allowed() {
|
||||
let p = default_policy();
|
||||
assert!(p.is_path_allowed(".gitignore"));
|
||||
assert!(p.is_path_allowed(".env"));
|
||||
}
|
||||
|
||||
// ── from_config ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn from_config_maps_all_fields() {
|
||||
let autonomy_config = crate::config::AutonomyConfig {
|
||||
level: AutonomyLevel::Full,
|
||||
workspace_only: false,
|
||||
allowed_commands: vec!["docker".into()],
|
||||
forbidden_paths: vec!["/secret".into()],
|
||||
max_actions_per_hour: 100,
|
||||
max_cost_per_day_cents: 1000,
|
||||
};
|
||||
let workspace = PathBuf::from("/tmp/test-workspace");
|
||||
let policy = SecurityPolicy::from_config(&autonomy_config, &workspace);
|
||||
|
||||
assert_eq!(policy.autonomy, AutonomyLevel::Full);
|
||||
assert!(!policy.workspace_only);
|
||||
assert_eq!(policy.allowed_commands, vec!["docker"]);
|
||||
assert_eq!(policy.forbidden_paths, vec!["/secret"]);
|
||||
assert_eq!(policy.max_actions_per_hour, 100);
|
||||
assert_eq!(policy.max_cost_per_day_cents, 1000);
|
||||
assert_eq!(policy.workspace_dir, PathBuf::from("/tmp/test-workspace"));
|
||||
}
|
||||
|
||||
// ── Default policy ──────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn default_policy_has_sane_values() {
|
||||
let p = SecurityPolicy::default();
|
||||
assert_eq!(p.autonomy, AutonomyLevel::Supervised);
|
||||
assert!(p.workspace_only);
|
||||
assert!(!p.allowed_commands.is_empty());
|
||||
assert!(!p.forbidden_paths.is_empty());
|
||||
assert!(p.max_actions_per_hour > 0);
|
||||
assert!(p.max_cost_per_day_cents > 0);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue