05cb353f7f
- 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
366 lines
11 KiB
Rust
366 lines
11 KiB
Rust
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);
|
|
}
|
|
}
|