1959 lines
65 KiB
Rust
1959 lines
65 KiB
Rust
use parking_lot::Mutex;
|
||
use schemars::JsonSchema;
|
||
use serde::{Deserialize, Serialize};
|
||
use std::path::{Path, PathBuf};
|
||
use std::time::Instant;
|
||
|
||
/// How much autonomy the agent has
|
||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||
#[serde(rename_all = "lowercase")]
|
||
pub enum AutonomyLevel {
|
||
/// Read-only: can observe but not act
|
||
ReadOnly,
|
||
/// Supervised: acts but requires approval for risky operations
|
||
#[default]
|
||
Supervised,
|
||
/// Full: autonomous execution within policy bounds
|
||
Full,
|
||
}
|
||
|
||
/// Risk score for shell command execution.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum CommandRiskLevel {
|
||
Low,
|
||
Medium,
|
||
High,
|
||
}
|
||
|
||
/// Classifies whether a tool operation is read-only or side-effecting.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum ToolOperation {
|
||
Read,
|
||
Act,
|
||
}
|
||
|
||
/// Sliding-window action tracker for rate limiting.
|
||
#[derive(Debug)]
|
||
pub struct ActionTracker {
|
||
/// Timestamps of recent actions (kept within the last hour).
|
||
actions: Mutex<Vec<Instant>>,
|
||
}
|
||
|
||
impl ActionTracker {
|
||
pub fn new() -> Self {
|
||
Self {
|
||
actions: Mutex::new(Vec::new()),
|
||
}
|
||
}
|
||
|
||
/// Record an action and return the current count within the window.
|
||
pub fn record(&self) -> usize {
|
||
let mut actions = self.actions.lock();
|
||
let cutoff = Instant::now()
|
||
.checked_sub(std::time::Duration::from_secs(3600))
|
||
.unwrap_or_else(Instant::now);
|
||
actions.retain(|t| *t > cutoff);
|
||
actions.push(Instant::now());
|
||
actions.len()
|
||
}
|
||
|
||
/// Count of actions in the current window without recording.
|
||
pub fn count(&self) -> usize {
|
||
let mut actions = self.actions.lock();
|
||
let cutoff = Instant::now()
|
||
.checked_sub(std::time::Duration::from_secs(3600))
|
||
.unwrap_or_else(Instant::now);
|
||
actions.retain(|t| *t > cutoff);
|
||
actions.len()
|
||
}
|
||
}
|
||
|
||
impl Clone for ActionTracker {
|
||
fn clone(&self) -> Self {
|
||
let actions = self.actions.lock();
|
||
Self {
|
||
actions: Mutex::new(actions.clone()),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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 allowed_roots: Vec<PathBuf>,
|
||
pub max_actions_per_hour: u32,
|
||
pub max_cost_per_day_cents: u32,
|
||
pub require_approval_for_medium_risk: bool,
|
||
pub block_high_risk_commands: bool,
|
||
pub shell_env_passthrough: Vec<String>,
|
||
pub tracker: ActionTracker,
|
||
}
|
||
|
||
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(),
|
||
"date".into(),
|
||
],
|
||
forbidden_paths: vec![
|
||
// System directories (blocked even when workspace_only=false)
|
||
"/etc".into(),
|
||
"/root".into(),
|
||
"/home".into(),
|
||
"/usr".into(),
|
||
"/bin".into(),
|
||
"/sbin".into(),
|
||
"/lib".into(),
|
||
"/opt".into(),
|
||
"/boot".into(),
|
||
"/dev".into(),
|
||
"/proc".into(),
|
||
"/sys".into(),
|
||
"/var".into(),
|
||
"/tmp".into(),
|
||
// Sensitive dotfiles
|
||
"~/.ssh".into(),
|
||
"~/.gnupg".into(),
|
||
"~/.aws".into(),
|
||
"~/.config".into(),
|
||
],
|
||
allowed_roots: Vec::new(),
|
||
max_actions_per_hour: 20,
|
||
max_cost_per_day_cents: 500,
|
||
require_approval_for_medium_risk: true,
|
||
block_high_risk_commands: true,
|
||
shell_env_passthrough: vec![],
|
||
tracker: ActionTracker::new(),
|
||
}
|
||
}
|
||
}
|
||
|
||
fn home_dir() -> Option<PathBuf> {
|
||
std::env::var_os("HOME").map(PathBuf::from)
|
||
}
|
||
|
||
fn expand_user_path(path: &str) -> PathBuf {
|
||
if path == "~" {
|
||
if let Some(home) = home_dir() {
|
||
return home;
|
||
}
|
||
}
|
||
|
||
if let Some(stripped) = path.strip_prefix("~/") {
|
||
if let Some(home) = home_dir() {
|
||
return home.join(stripped);
|
||
}
|
||
}
|
||
|
||
PathBuf::from(path)
|
||
}
|
||
|
||
// ── Shell Command Parsing Utilities ───────────────────────────────────────
|
||
// These helpers implement a minimal quote-aware shell lexer. They exist
|
||
// because security validation must reason about the *structure* of a
|
||
// command (separators, operators, quoting) rather than treating it as a
|
||
// flat string — otherwise an attacker could hide dangerous sub-commands
|
||
// inside quoted arguments or chained operators.
|
||
/// Skip leading environment variable assignments (e.g. `FOO=bar cmd args`).
|
||
/// Returns the remainder starting at the first non-assignment word.
|
||
fn skip_env_assignments(s: &str) -> &str {
|
||
let mut rest = s;
|
||
loop {
|
||
let Some(word) = rest.split_whitespace().next() else {
|
||
return rest;
|
||
};
|
||
// Environment assignment: contains '=' and starts with a letter or underscore
|
||
if word.contains('=')
|
||
&& word
|
||
.chars()
|
||
.next()
|
||
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
|
||
{
|
||
// Advance past this word
|
||
rest = rest[word.len()..].trim_start();
|
||
} else {
|
||
return rest;
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
enum QuoteState {
|
||
None,
|
||
Single,
|
||
Double,
|
||
}
|
||
|
||
/// Split a shell command into sub-commands by unquoted separators.
|
||
///
|
||
/// Separators:
|
||
/// - `;` and newline
|
||
/// - `|`
|
||
/// - `&&`, `||`
|
||
///
|
||
/// Characters inside single or double quotes are treated as literals, so
|
||
/// `sqlite3 db "SELECT 1; SELECT 2;"` remains a single segment.
|
||
fn split_unquoted_segments(command: &str) -> Vec<String> {
|
||
let mut segments = Vec::new();
|
||
let mut current = String::new();
|
||
let mut quote = QuoteState::None;
|
||
let mut escaped = false;
|
||
let mut chars = command.chars().peekable();
|
||
|
||
let push_segment = |segments: &mut Vec<String>, current: &mut String| {
|
||
let trimmed = current.trim();
|
||
if !trimmed.is_empty() {
|
||
segments.push(trimmed.to_string());
|
||
}
|
||
current.clear();
|
||
};
|
||
|
||
while let Some(ch) = chars.next() {
|
||
match quote {
|
||
QuoteState::Single => {
|
||
if ch == '\'' {
|
||
quote = QuoteState::None;
|
||
}
|
||
current.push(ch);
|
||
}
|
||
QuoteState::Double => {
|
||
if escaped {
|
||
escaped = false;
|
||
current.push(ch);
|
||
continue;
|
||
}
|
||
if ch == '\\' {
|
||
escaped = true;
|
||
current.push(ch);
|
||
continue;
|
||
}
|
||
if ch == '"' {
|
||
quote = QuoteState::None;
|
||
}
|
||
current.push(ch);
|
||
}
|
||
QuoteState::None => {
|
||
if escaped {
|
||
escaped = false;
|
||
current.push(ch);
|
||
continue;
|
||
}
|
||
if ch == '\\' {
|
||
escaped = true;
|
||
current.push(ch);
|
||
continue;
|
||
}
|
||
|
||
match ch {
|
||
'\'' => {
|
||
quote = QuoteState::Single;
|
||
current.push(ch);
|
||
}
|
||
'"' => {
|
||
quote = QuoteState::Double;
|
||
current.push(ch);
|
||
}
|
||
';' | '\n' => push_segment(&mut segments, &mut current),
|
||
'|' => {
|
||
if chars.next_if_eq(&'|').is_some() {
|
||
// Consume full `||`; both characters are separators.
|
||
}
|
||
push_segment(&mut segments, &mut current);
|
||
}
|
||
'&' => {
|
||
if chars.next_if_eq(&'&').is_some() {
|
||
// `&&` is a separator; single `&` is handled separately.
|
||
push_segment(&mut segments, &mut current);
|
||
} else {
|
||
current.push(ch);
|
||
}
|
||
}
|
||
_ => current.push(ch),
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
let trimmed = current.trim();
|
||
if !trimmed.is_empty() {
|
||
segments.push(trimmed.to_string());
|
||
}
|
||
|
||
segments
|
||
}
|
||
|
||
/// Detect a single unquoted `&` operator (background/chain). `&&` is allowed.
|
||
///
|
||
/// We treat any standalone `&` as unsafe in policy validation because it can
|
||
/// chain hidden sub-commands and escape foreground timeout expectations.
|
||
fn contains_unquoted_single_ampersand(command: &str) -> bool {
|
||
let mut quote = QuoteState::None;
|
||
let mut escaped = false;
|
||
let mut chars = command.chars().peekable();
|
||
|
||
while let Some(ch) = chars.next() {
|
||
match quote {
|
||
QuoteState::Single => {
|
||
if ch == '\'' {
|
||
quote = QuoteState::None;
|
||
}
|
||
}
|
||
QuoteState::Double => {
|
||
if escaped {
|
||
escaped = false;
|
||
continue;
|
||
}
|
||
if ch == '\\' {
|
||
escaped = true;
|
||
continue;
|
||
}
|
||
if ch == '"' {
|
||
quote = QuoteState::None;
|
||
}
|
||
}
|
||
QuoteState::None => {
|
||
if escaped {
|
||
escaped = false;
|
||
continue;
|
||
}
|
||
if ch == '\\' {
|
||
escaped = true;
|
||
continue;
|
||
}
|
||
match ch {
|
||
'\'' => quote = QuoteState::Single,
|
||
'"' => quote = QuoteState::Double,
|
||
'&' => {
|
||
if chars.next_if_eq(&'&').is_none() {
|
||
return true;
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
false
|
||
}
|
||
|
||
/// Detect an unquoted character in a shell command.
|
||
fn contains_unquoted_char(command: &str, target: char) -> bool {
|
||
let mut quote = QuoteState::None;
|
||
let mut escaped = false;
|
||
|
||
for ch in command.chars() {
|
||
match quote {
|
||
QuoteState::Single => {
|
||
if ch == '\'' {
|
||
quote = QuoteState::None;
|
||
}
|
||
}
|
||
QuoteState::Double => {
|
||
if escaped {
|
||
escaped = false;
|
||
continue;
|
||
}
|
||
if ch == '\\' {
|
||
escaped = true;
|
||
continue;
|
||
}
|
||
if ch == '"' {
|
||
quote = QuoteState::None;
|
||
}
|
||
}
|
||
QuoteState::None => {
|
||
if escaped {
|
||
escaped = false;
|
||
continue;
|
||
}
|
||
if ch == '\\' {
|
||
escaped = true;
|
||
continue;
|
||
}
|
||
match ch {
|
||
'\'' => quote = QuoteState::Single,
|
||
'"' => quote = QuoteState::Double,
|
||
_ if ch == target => return true,
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
false
|
||
}
|
||
|
||
/// Detect unquoted shell variable expansions like `$HOME`, `$1`, `$?`.
|
||
///
|
||
/// Escaped dollars (`\$`) are ignored. Variables inside single quotes are
|
||
/// treated as literals and therefore ignored.
|
||
fn contains_unquoted_shell_variable_expansion(command: &str) -> bool {
|
||
let mut quote = QuoteState::None;
|
||
let mut escaped = false;
|
||
let chars: Vec<char> = command.chars().collect();
|
||
|
||
for i in 0..chars.len() {
|
||
let ch = chars[i];
|
||
|
||
match quote {
|
||
QuoteState::Single => {
|
||
if ch == '\'' {
|
||
quote = QuoteState::None;
|
||
}
|
||
continue;
|
||
}
|
||
QuoteState::Double => {
|
||
if escaped {
|
||
escaped = false;
|
||
continue;
|
||
}
|
||
if ch == '\\' {
|
||
escaped = true;
|
||
continue;
|
||
}
|
||
if ch == '"' {
|
||
quote = QuoteState::None;
|
||
continue;
|
||
}
|
||
}
|
||
QuoteState::None => {
|
||
if escaped {
|
||
escaped = false;
|
||
continue;
|
||
}
|
||
if ch == '\\' {
|
||
escaped = true;
|
||
continue;
|
||
}
|
||
if ch == '\'' {
|
||
quote = QuoteState::Single;
|
||
continue;
|
||
}
|
||
if ch == '"' {
|
||
quote = QuoteState::Double;
|
||
continue;
|
||
}
|
||
}
|
||
}
|
||
|
||
if ch != '$' {
|
||
continue;
|
||
}
|
||
|
||
let Some(next) = chars.get(i + 1).copied() else {
|
||
continue;
|
||
};
|
||
if next.is_ascii_alphanumeric()
|
||
|| matches!(
|
||
next,
|
||
'_' | '{' | '(' | '#' | '?' | '!' | '$' | '*' | '@' | '-'
|
||
)
|
||
{
|
||
return true;
|
||
}
|
||
}
|
||
|
||
false
|
||
}
|
||
|
||
impl SecurityPolicy {
|
||
// ── Risk Classification ──────────────────────────────────────────────
|
||
// Risk is assessed per-segment (split on shell operators), and the
|
||
// highest risk across all segments wins. This prevents bypasses like
|
||
// `ls && rm -rf /` from being classified as Low just because `ls` is safe.
|
||
|
||
/// Classify command risk. Any high-risk segment marks the whole command high.
|
||
pub fn command_risk_level(&self, command: &str) -> CommandRiskLevel {
|
||
let mut saw_medium = false;
|
||
|
||
for segment in split_unquoted_segments(command) {
|
||
let cmd_part = skip_env_assignments(&segment);
|
||
let mut words = cmd_part.split_whitespace();
|
||
let Some(base_raw) = words.next() else {
|
||
continue;
|
||
};
|
||
|
||
let base = base_raw
|
||
.rsplit('/')
|
||
.next()
|
||
.unwrap_or("")
|
||
.to_ascii_lowercase();
|
||
|
||
let args: Vec<String> = words.map(|w| w.to_ascii_lowercase()).collect();
|
||
let joined_segment = cmd_part.to_ascii_lowercase();
|
||
|
||
// High-risk commands
|
||
if matches!(
|
||
base.as_str(),
|
||
"rm" | "mkfs"
|
||
| "dd"
|
||
| "shutdown"
|
||
| "reboot"
|
||
| "halt"
|
||
| "poweroff"
|
||
| "sudo"
|
||
| "su"
|
||
| "chown"
|
||
| "chmod"
|
||
| "useradd"
|
||
| "userdel"
|
||
| "usermod"
|
||
| "passwd"
|
||
| "mount"
|
||
| "umount"
|
||
| "iptables"
|
||
| "ufw"
|
||
| "firewall-cmd"
|
||
| "curl"
|
||
| "wget"
|
||
| "nc"
|
||
| "ncat"
|
||
| "netcat"
|
||
| "scp"
|
||
| "ssh"
|
||
| "ftp"
|
||
| "telnet"
|
||
) {
|
||
return CommandRiskLevel::High;
|
||
}
|
||
|
||
if joined_segment.contains("rm -rf /")
|
||
|| joined_segment.contains("rm -fr /")
|
||
|| joined_segment.contains(":(){:|:&};:")
|
||
{
|
||
return CommandRiskLevel::High;
|
||
}
|
||
|
||
// Medium-risk commands (state-changing, but not inherently destructive)
|
||
let medium = match base.as_str() {
|
||
"git" => args.first().is_some_and(|verb| {
|
||
matches!(
|
||
verb.as_str(),
|
||
"commit"
|
||
| "push"
|
||
| "reset"
|
||
| "clean"
|
||
| "rebase"
|
||
| "merge"
|
||
| "cherry-pick"
|
||
| "revert"
|
||
| "branch"
|
||
| "checkout"
|
||
| "switch"
|
||
| "tag"
|
||
)
|
||
}),
|
||
"npm" | "pnpm" | "yarn" => args.first().is_some_and(|verb| {
|
||
matches!(
|
||
verb.as_str(),
|
||
"install" | "add" | "remove" | "uninstall" | "update" | "publish"
|
||
)
|
||
}),
|
||
"cargo" => args.first().is_some_and(|verb| {
|
||
matches!(
|
||
verb.as_str(),
|
||
"add" | "remove" | "install" | "clean" | "publish"
|
||
)
|
||
}),
|
||
"touch" | "mkdir" | "mv" | "cp" | "ln" => true,
|
||
_ => false,
|
||
};
|
||
|
||
saw_medium |= medium;
|
||
}
|
||
|
||
if saw_medium {
|
||
CommandRiskLevel::Medium
|
||
} else {
|
||
CommandRiskLevel::Low
|
||
}
|
||
}
|
||
|
||
// ── Command Execution Policy Gate ──────────────────────────────────────
|
||
// Validation follows a strict precedence order:
|
||
// 1. Allowlist check (is the base command permitted at all?)
|
||
// 2. Risk classification (high / medium / low)
|
||
// 3. Policy flags (block_high_risk_commands, require_approval_for_medium_risk)
|
||
// 4. Autonomy level × approval status (supervised requires explicit approval)
|
||
// This ordering ensures deny-by-default: unknown commands are rejected
|
||
// before any risk or autonomy logic runs.
|
||
|
||
/// Validate full command execution policy (allowlist + risk gate).
|
||
pub fn validate_command_execution(
|
||
&self,
|
||
command: &str,
|
||
approved: bool,
|
||
) -> Result<CommandRiskLevel, String> {
|
||
if !self.is_command_allowed(command) {
|
||
return Err(format!("Command not allowed by security policy: {command}"));
|
||
}
|
||
|
||
let risk = self.command_risk_level(command);
|
||
|
||
if risk == CommandRiskLevel::High {
|
||
if self.block_high_risk_commands {
|
||
return Err("Command blocked: high-risk command is disallowed by policy".into());
|
||
}
|
||
if self.autonomy == AutonomyLevel::Supervised && !approved {
|
||
return Err(
|
||
"Command requires explicit approval (approved=true): high-risk operation"
|
||
.into(),
|
||
);
|
||
}
|
||
}
|
||
|
||
if risk == CommandRiskLevel::Medium
|
||
&& self.autonomy == AutonomyLevel::Supervised
|
||
&& self.require_approval_for_medium_risk
|
||
&& !approved
|
||
{
|
||
return Err(
|
||
"Command requires explicit approval (approved=true): medium-risk operation".into(),
|
||
);
|
||
}
|
||
|
||
Ok(risk)
|
||
}
|
||
|
||
// ── Layered Command Allowlist ──────────────────────────────────────────
|
||
// Defence-in-depth: five independent gates run in order before the
|
||
// per-segment allowlist check. Each gate targets a specific bypass
|
||
// technique. If any gate rejects, the whole command is blocked.
|
||
|
||
/// Check if a shell command is allowed.
|
||
///
|
||
/// Validates the **entire** command string, not just the first word:
|
||
/// - Blocks subshell operators (`` ` ``, `$(`) that hide arbitrary execution
|
||
/// - Splits on command separators (`|`, `&&`, `||`, `;`, newlines) and
|
||
/// validates each sub-command against the allowlist
|
||
/// - Blocks single `&` background chaining (`&&` remains supported)
|
||
/// - Blocks output redirections (`>`, `>>`) that could write outside workspace
|
||
/// - Blocks dangerous arguments (e.g. `find -exec`, `git config`)
|
||
pub fn is_command_allowed(&self, command: &str) -> bool {
|
||
if self.autonomy == AutonomyLevel::ReadOnly {
|
||
return false;
|
||
}
|
||
|
||
// Block subshell/expansion operators — these allow hiding arbitrary
|
||
// commands inside an allowed command (e.g. `echo $(rm -rf /)`) and
|
||
// bypassing path checks through variable indirection.
|
||
if command.contains('`')
|
||
|| command.contains("$(")
|
||
|| command.contains("${")
|
||
|| contains_unquoted_shell_variable_expansion(command)
|
||
|| command.contains("<(")
|
||
|| command.contains(">(")
|
||
{
|
||
return false;
|
||
}
|
||
|
||
// Block output redirections (`>`, `>>`) — they can write to arbitrary paths.
|
||
// Ignore quoted literals, e.g. `echo "a>b"`.
|
||
if contains_unquoted_char(command, '>') {
|
||
return false;
|
||
}
|
||
|
||
// Block `tee` — it can write to arbitrary files, bypassing the
|
||
// redirect check above (e.g. `echo secret | tee /etc/crontab`)
|
||
if command
|
||
.split_whitespace()
|
||
.any(|w| w == "tee" || w.ends_with("/tee"))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
// Block background command chaining (`&`), which can hide extra
|
||
// sub-commands and outlive timeout expectations. Keep `&&` allowed.
|
||
if contains_unquoted_single_ampersand(command) {
|
||
return false;
|
||
}
|
||
|
||
// Split on unquoted command separators and validate each sub-command.
|
||
let segments = split_unquoted_segments(command);
|
||
for segment in &segments {
|
||
// Strip leading env var assignments (e.g. FOO=bar cmd)
|
||
let cmd_part = skip_env_assignments(segment);
|
||
|
||
let mut words = cmd_part.split_whitespace();
|
||
let base_raw = words.next().unwrap_or("");
|
||
let base_cmd = base_raw.rsplit('/').next().unwrap_or("");
|
||
|
||
if base_cmd.is_empty() {
|
||
continue;
|
||
}
|
||
|
||
if !self
|
||
.allowed_commands
|
||
.iter()
|
||
.any(|allowed| allowed == base_cmd)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
// Validate arguments for the command
|
||
let args: Vec<String> = words.map(|w| w.to_ascii_lowercase()).collect();
|
||
if !self.is_args_safe(base_cmd, &args) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// At least one command must be present
|
||
let has_cmd = segments.iter().any(|s| {
|
||
let s = skip_env_assignments(s.trim());
|
||
s.split_whitespace().next().is_some_and(|w| !w.is_empty())
|
||
});
|
||
|
||
has_cmd
|
||
}
|
||
|
||
/// Check for dangerous arguments that allow sub-command execution.
|
||
fn is_args_safe(&self, base: &str, args: &[String]) -> bool {
|
||
let base = base.to_ascii_lowercase();
|
||
match base.as_str() {
|
||
"find" => {
|
||
// find -exec and find -ok allow arbitrary command execution
|
||
!args.iter().any(|arg| arg == "-exec" || arg == "-ok")
|
||
}
|
||
"git" => {
|
||
// git config, alias, and -c can be used to set dangerous options
|
||
// (e.g. git config core.editor "rm -rf /")
|
||
!args.iter().any(|arg| {
|
||
arg == "config"
|
||
|| arg.starts_with("config.")
|
||
|| arg == "alias"
|
||
|| arg.starts_with("alias.")
|
||
|| arg == "-c"
|
||
})
|
||
}
|
||
_ => true,
|
||
}
|
||
}
|
||
|
||
// ── Path Validation ────────────────────────────────────────────────
|
||
// Layered checks: null-byte injection → component-level traversal →
|
||
// URL-encoded traversal → tilde expansion → absolute-path block →
|
||
// forbidden-prefix match. Each layer addresses a distinct escape
|
||
// technique; together they enforce workspace confinement.
|
||
|
||
/// Check if a file path is allowed (no path traversal, within workspace)
|
||
pub fn is_path_allowed(&self, path: &str) -> bool {
|
||
// Block null bytes (can truncate paths in C-backed syscalls)
|
||
if path.contains('\0') {
|
||
return false;
|
||
}
|
||
|
||
// Block path traversal: check for ".." as a path component
|
||
if Path::new(path)
|
||
.components()
|
||
.any(|c| matches!(c, std::path::Component::ParentDir))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
// Block URL-encoded traversal attempts (e.g. ..%2f)
|
||
let lower = path.to_lowercase();
|
||
if lower.contains("..%2f") || lower.contains("%2f..") {
|
||
return false;
|
||
}
|
||
|
||
// Expand "~" for consistent matching with forbidden paths and allowlists.
|
||
let expanded_path = expand_user_path(path);
|
||
|
||
// Block absolute paths when workspace_only is set
|
||
if self.workspace_only && expanded_path.is_absolute() {
|
||
return false;
|
||
}
|
||
|
||
// Block forbidden paths using path-component-aware matching
|
||
for forbidden in &self.forbidden_paths {
|
||
let forbidden_path = expand_user_path(forbidden);
|
||
if expanded_path.starts_with(forbidden_path) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
true
|
||
}
|
||
|
||
/// Validate that a resolved path is inside the workspace or an allowed root.
|
||
/// Call this AFTER joining `workspace_dir` + relative path and canonicalizing.
|
||
pub fn is_resolved_path_allowed(&self, resolved: &Path) -> bool {
|
||
// Must be under workspace_dir (prevents symlink escapes).
|
||
// Prefer canonical workspace root so `/a/../b` style config paths don't
|
||
// cause false positives or negatives.
|
||
let workspace_root = self
|
||
.workspace_dir
|
||
.canonicalize()
|
||
.unwrap_or_else(|_| self.workspace_dir.clone());
|
||
if resolved.starts_with(&workspace_root) {
|
||
return true;
|
||
}
|
||
|
||
// Check extra allowed roots (e.g. shared skills directories).
|
||
for root in &self.allowed_roots {
|
||
let canonical = root.canonicalize().unwrap_or_else(|_| root.clone());
|
||
if resolved.starts_with(&canonical) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
false
|
||
}
|
||
|
||
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."
|
||
} else {
|
||
"Add a matching parent directory to [autonomy].allowed_roots, or move the file into the workspace."
|
||
};
|
||
|
||
format!(
|
||
"Resolved path escapes workspace allowlist: {}. {}",
|
||
resolved.display(),
|
||
guidance
|
||
)
|
||
}
|
||
|
||
/// Check if autonomy level permits any action at all
|
||
pub fn can_act(&self) -> bool {
|
||
self.autonomy != AutonomyLevel::ReadOnly
|
||
}
|
||
|
||
// ── Tool Operation Gating ──────────────────────────────────────────────
|
||
// Read operations bypass autonomy and rate checks because they have
|
||
// no side effects. Act operations must pass both the autonomy gate
|
||
// (not read-only) and the sliding-window rate limiter.
|
||
|
||
/// Enforce policy for a tool operation.
|
||
///
|
||
/// Read operations are always allowed by autonomy/rate gates.
|
||
/// Act operations require non-readonly autonomy and available action budget.
|
||
pub fn enforce_tool_operation(
|
||
&self,
|
||
operation: ToolOperation,
|
||
operation_name: &str,
|
||
) -> Result<(), String> {
|
||
match operation {
|
||
ToolOperation::Read => Ok(()),
|
||
ToolOperation::Act => {
|
||
if !self.can_act() {
|
||
return Err(format!(
|
||
"Security policy: read-only mode, cannot perform '{operation_name}'"
|
||
));
|
||
}
|
||
|
||
if !self.record_action() {
|
||
return Err("Rate limit exceeded: action budget exhausted".to_string());
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Record an action and check if the rate limit has been exceeded.
|
||
/// Returns `true` if the action is allowed, `false` if rate-limited.
|
||
pub fn record_action(&self) -> bool {
|
||
let count = self.tracker.record();
|
||
count <= self.max_actions_per_hour as usize
|
||
}
|
||
|
||
/// Check if the rate limit would be exceeded without recording.
|
||
pub fn is_rate_limited(&self) -> bool {
|
||
self.tracker.count() >= self.max_actions_per_hour as usize
|
||
}
|
||
|
||
/// 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(),
|
||
allowed_roots: autonomy_config
|
||
.allowed_roots
|
||
.iter()
|
||
.map(|root| {
|
||
let expanded = expand_user_path(root);
|
||
if expanded.is_absolute() {
|
||
expanded
|
||
} else {
|
||
workspace_dir.join(expanded)
|
||
}
|
||
})
|
||
.collect(),
|
||
max_actions_per_hour: autonomy_config.max_actions_per_hour,
|
||
max_cost_per_day_cents: autonomy_config.max_cost_per_day_cents,
|
||
require_approval_for_medium_risk: autonomy_config.require_approval_for_medium_risk,
|
||
block_high_risk_commands: autonomy_config.block_high_risk_commands,
|
||
shell_env_passthrough: autonomy_config.shell_env_passthrough.clone(),
|
||
tracker: ActionTracker::new(),
|
||
}
|
||
}
|
||
}
|
||
|
||
#[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());
|
||
}
|
||
|
||
#[test]
|
||
fn enforce_tool_operation_read_allowed_in_readonly_mode() {
|
||
let p = readonly_policy();
|
||
assert!(p
|
||
.enforce_tool_operation(ToolOperation::Read, "memory_recall")
|
||
.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn enforce_tool_operation_act_blocked_in_readonly_mode() {
|
||
let p = readonly_policy();
|
||
let err = p
|
||
.enforce_tool_operation(ToolOperation::Act, "memory_store")
|
||
.unwrap_err();
|
||
assert!(err.contains("read-only mode"));
|
||
}
|
||
|
||
#[test]
|
||
fn enforce_tool_operation_act_uses_rate_budget() {
|
||
let p = SecurityPolicy {
|
||
max_actions_per_hour: 0,
|
||
..default_policy()
|
||
};
|
||
let err = p
|
||
.enforce_tool_operation(ToolOperation::Act, "memory_store")
|
||
.unwrap_err();
|
||
assert!(err.contains("Rate limit exceeded"));
|
||
}
|
||
|
||
// ── 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 ."));
|
||
assert!(p.is_command_allowed("date"));
|
||
}
|
||
|
||
#[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_validates_all_segments() {
|
||
let p = default_policy();
|
||
// Both sides of the pipe are in the allowlist
|
||
assert!(p.is_command_allowed("ls | grep foo"));
|
||
assert!(p.is_command_allowed("cat file.txt | wc -l"));
|
||
// Second command not in allowlist — blocked
|
||
assert!(!p.is_command_allowed("ls | curl http://evil.com"));
|
||
assert!(!p.is_command_allowed("echo hello | python3 -"));
|
||
}
|
||
|
||
#[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"));
|
||
}
|
||
|
||
#[test]
|
||
fn command_risk_low_for_read_commands() {
|
||
let p = default_policy();
|
||
assert_eq!(p.command_risk_level("git status"), CommandRiskLevel::Low);
|
||
assert_eq!(p.command_risk_level("ls -la"), CommandRiskLevel::Low);
|
||
}
|
||
|
||
#[test]
|
||
fn command_risk_medium_for_mutating_commands() {
|
||
let p = SecurityPolicy {
|
||
allowed_commands: vec!["git".into(), "touch".into()],
|
||
..SecurityPolicy::default()
|
||
};
|
||
assert_eq!(
|
||
p.command_risk_level("git reset --hard HEAD~1"),
|
||
CommandRiskLevel::Medium
|
||
);
|
||
assert_eq!(
|
||
p.command_risk_level("touch file.txt"),
|
||
CommandRiskLevel::Medium
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn command_risk_high_for_dangerous_commands() {
|
||
let p = SecurityPolicy {
|
||
allowed_commands: vec!["rm".into()],
|
||
..SecurityPolicy::default()
|
||
};
|
||
assert_eq!(
|
||
p.command_risk_level("rm -rf /tmp/test"),
|
||
CommandRiskLevel::High
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn validate_command_requires_approval_for_medium_risk() {
|
||
let p = SecurityPolicy {
|
||
autonomy: AutonomyLevel::Supervised,
|
||
require_approval_for_medium_risk: true,
|
||
allowed_commands: vec!["touch".into()],
|
||
..SecurityPolicy::default()
|
||
};
|
||
|
||
let denied = p.validate_command_execution("touch test.txt", false);
|
||
assert!(denied.is_err());
|
||
assert!(denied.unwrap_err().contains("requires explicit approval"),);
|
||
|
||
let allowed = p.validate_command_execution("touch test.txt", true);
|
||
assert_eq!(allowed.unwrap(), CommandRiskLevel::Medium);
|
||
}
|
||
|
||
#[test]
|
||
fn validate_command_blocks_high_risk_by_default() {
|
||
let p = SecurityPolicy {
|
||
autonomy: AutonomyLevel::Supervised,
|
||
allowed_commands: vec!["rm".into()],
|
||
..SecurityPolicy::default()
|
||
};
|
||
|
||
let result = p.validate_command_execution("rm -rf /tmp/test", true);
|
||
assert!(result.is_err());
|
||
assert!(result.unwrap_err().contains("high-risk"));
|
||
}
|
||
|
||
#[test]
|
||
fn validate_command_full_mode_skips_medium_risk_approval_gate() {
|
||
let p = SecurityPolicy {
|
||
autonomy: AutonomyLevel::Full,
|
||
require_approval_for_medium_risk: true,
|
||
allowed_commands: vec!["touch".into()],
|
||
..SecurityPolicy::default()
|
||
};
|
||
|
||
let result = p.validate_command_execution("touch test.txt", false);
|
||
assert_eq!(result.unwrap(), CommandRiskLevel::Medium);
|
||
}
|
||
|
||
#[test]
|
||
fn validate_command_rejects_background_chain_bypass() {
|
||
let p = default_policy();
|
||
let result = p.validate_command_execution("ls & python3 -c 'print(1)'", false);
|
||
assert!(result.is_err());
|
||
assert!(result.unwrap_err().contains("not allowed"));
|
||
}
|
||
|
||
// ── 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,
|
||
require_approval_for_medium_risk: false,
|
||
block_high_risk_commands: false,
|
||
shell_env_passthrough: vec!["DATABASE_URL".into()],
|
||
..crate::config::AutonomyConfig::default()
|
||
};
|
||
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!(!policy.require_approval_for_medium_risk);
|
||
assert!(!policy.block_high_risk_commands);
|
||
assert_eq!(policy.shell_env_passthrough, vec!["DATABASE_URL"]);
|
||
assert_eq!(policy.workspace_dir, PathBuf::from("/tmp/test-workspace"));
|
||
}
|
||
|
||
#[test]
|
||
fn from_config_normalizes_allowed_roots() {
|
||
let autonomy_config = crate::config::AutonomyConfig {
|
||
allowed_roots: vec!["~/Desktop".into(), "shared-data".into()],
|
||
..crate::config::AutonomyConfig::default()
|
||
};
|
||
let workspace = PathBuf::from("/tmp/test-workspace");
|
||
let policy = SecurityPolicy::from_config(&autonomy_config, &workspace);
|
||
|
||
let expected_home_root = if let Some(home) = std::env::var_os("HOME") {
|
||
PathBuf::from(home).join("Desktop")
|
||
} else {
|
||
PathBuf::from("~/Desktop")
|
||
};
|
||
|
||
assert_eq!(policy.allowed_roots[0], expected_home_root);
|
||
assert_eq!(policy.allowed_roots[1], workspace.join("shared-data"));
|
||
}
|
||
|
||
#[test]
|
||
fn resolved_path_violation_message_includes_allowed_roots_guidance() {
|
||
let p = default_policy();
|
||
let msg = p.resolved_path_violation_message(Path::new("/tmp/outside.txt"));
|
||
assert!(msg.contains("escapes workspace"));
|
||
assert!(msg.contains("allowed_roots"));
|
||
}
|
||
|
||
// ── 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);
|
||
assert!(p.require_approval_for_medium_risk);
|
||
assert!(p.block_high_risk_commands);
|
||
assert!(p.shell_env_passthrough.is_empty());
|
||
}
|
||
|
||
// ── ActionTracker / rate limiting ───────────────────────
|
||
|
||
#[test]
|
||
fn action_tracker_starts_at_zero() {
|
||
let tracker = ActionTracker::new();
|
||
assert_eq!(tracker.count(), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn action_tracker_records_actions() {
|
||
let tracker = ActionTracker::new();
|
||
assert_eq!(tracker.record(), 1);
|
||
assert_eq!(tracker.record(), 2);
|
||
assert_eq!(tracker.record(), 3);
|
||
assert_eq!(tracker.count(), 3);
|
||
}
|
||
|
||
#[test]
|
||
fn record_action_allows_within_limit() {
|
||
let p = SecurityPolicy {
|
||
max_actions_per_hour: 5,
|
||
..SecurityPolicy::default()
|
||
};
|
||
for _ in 0..5 {
|
||
assert!(p.record_action(), "should allow actions within limit");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn record_action_blocks_over_limit() {
|
||
let p = SecurityPolicy {
|
||
max_actions_per_hour: 3,
|
||
..SecurityPolicy::default()
|
||
};
|
||
assert!(p.record_action()); // 1
|
||
assert!(p.record_action()); // 2
|
||
assert!(p.record_action()); // 3
|
||
assert!(!p.record_action()); // 4 — over limit
|
||
}
|
||
|
||
#[test]
|
||
fn is_rate_limited_reflects_count() {
|
||
let p = SecurityPolicy {
|
||
max_actions_per_hour: 2,
|
||
..SecurityPolicy::default()
|
||
};
|
||
assert!(!p.is_rate_limited());
|
||
p.record_action();
|
||
assert!(!p.is_rate_limited());
|
||
p.record_action();
|
||
assert!(p.is_rate_limited());
|
||
}
|
||
|
||
#[test]
|
||
fn action_tracker_clone_is_independent() {
|
||
let tracker = ActionTracker::new();
|
||
tracker.record();
|
||
tracker.record();
|
||
let cloned = tracker.clone();
|
||
assert_eq!(cloned.count(), 2);
|
||
tracker.record();
|
||
assert_eq!(tracker.count(), 3);
|
||
assert_eq!(cloned.count(), 2); // clone is independent
|
||
}
|
||
|
||
// ── Edge cases: command injection ────────────────────────
|
||
|
||
#[test]
|
||
fn command_injection_semicolon_blocked() {
|
||
let p = default_policy();
|
||
// First word is "ls;" (with semicolon) — doesn't match "ls" in allowlist.
|
||
// This is a safe default: chained commands are blocked.
|
||
assert!(!p.is_command_allowed("ls; rm -rf /"));
|
||
}
|
||
|
||
#[test]
|
||
fn command_injection_semicolon_no_space() {
|
||
let p = default_policy();
|
||
assert!(!p.is_command_allowed("ls;rm -rf /"));
|
||
}
|
||
|
||
#[test]
|
||
fn quoted_semicolons_do_not_split_sqlite_command() {
|
||
let p = SecurityPolicy {
|
||
allowed_commands: vec!["sqlite3".into()],
|
||
..SecurityPolicy::default()
|
||
};
|
||
assert!(p.is_command_allowed(
|
||
"sqlite3 /tmp/test.db \"CREATE TABLE t(id INT); INSERT INTO t VALUES(1); SELECT * FROM t;\""
|
||
));
|
||
assert_eq!(
|
||
p.command_risk_level(
|
||
"sqlite3 /tmp/test.db \"CREATE TABLE t(id INT); INSERT INTO t VALUES(1); SELECT * FROM t;\""
|
||
),
|
||
CommandRiskLevel::Low
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn unquoted_semicolon_after_quoted_sql_still_splits_commands() {
|
||
let p = SecurityPolicy {
|
||
allowed_commands: vec!["sqlite3".into()],
|
||
..SecurityPolicy::default()
|
||
};
|
||
assert!(!p.is_command_allowed("sqlite3 /tmp/test.db \"SELECT 1;\"; rm -rf /"));
|
||
}
|
||
|
||
#[test]
|
||
fn command_injection_backtick_blocked() {
|
||
let p = default_policy();
|
||
assert!(!p.is_command_allowed("echo `whoami`"));
|
||
assert!(!p.is_command_allowed("echo `rm -rf /`"));
|
||
}
|
||
|
||
#[test]
|
||
fn command_injection_dollar_paren_blocked() {
|
||
let p = default_policy();
|
||
assert!(!p.is_command_allowed("echo $(cat /etc/passwd)"));
|
||
assert!(!p.is_command_allowed("echo $(rm -rf /)"));
|
||
}
|
||
|
||
#[test]
|
||
fn command_with_env_var_prefix() {
|
||
let p = default_policy();
|
||
// "FOO=bar" is the first word — not in allowlist
|
||
assert!(!p.is_command_allowed("FOO=bar rm -rf /"));
|
||
}
|
||
|
||
#[test]
|
||
fn command_newline_injection_blocked() {
|
||
let p = default_policy();
|
||
// Newline splits into two commands; "rm" is not in allowlist
|
||
assert!(!p.is_command_allowed("ls\nrm -rf /"));
|
||
// Both allowed — OK
|
||
assert!(p.is_command_allowed("ls\necho hello"));
|
||
}
|
||
|
||
#[test]
|
||
fn command_injection_and_chain_blocked() {
|
||
let p = default_policy();
|
||
assert!(!p.is_command_allowed("ls && rm -rf /"));
|
||
assert!(!p.is_command_allowed("echo ok && curl http://evil.com"));
|
||
// Both allowed — OK
|
||
assert!(p.is_command_allowed("ls && echo done"));
|
||
}
|
||
|
||
#[test]
|
||
fn command_injection_or_chain_blocked() {
|
||
let p = default_policy();
|
||
assert!(!p.is_command_allowed("ls || rm -rf /"));
|
||
// Both allowed — OK
|
||
assert!(p.is_command_allowed("ls || echo fallback"));
|
||
}
|
||
|
||
#[test]
|
||
fn command_injection_background_chain_blocked() {
|
||
let p = default_policy();
|
||
assert!(!p.is_command_allowed("ls & rm -rf /"));
|
||
assert!(!p.is_command_allowed("ls&rm -rf /"));
|
||
assert!(!p.is_command_allowed("echo ok & python3 -c 'print(1)'"));
|
||
}
|
||
|
||
#[test]
|
||
fn command_injection_redirect_blocked() {
|
||
let p = default_policy();
|
||
assert!(!p.is_command_allowed("echo secret > /etc/crontab"));
|
||
assert!(!p.is_command_allowed("ls >> /tmp/exfil.txt"));
|
||
}
|
||
|
||
#[test]
|
||
fn quoted_ampersand_and_redirect_literals_are_not_treated_as_operators() {
|
||
let p = default_policy();
|
||
assert!(p.is_command_allowed("echo \"A&B\""));
|
||
assert!(p.is_command_allowed("echo \"A>B\""));
|
||
}
|
||
|
||
#[test]
|
||
fn command_argument_injection_blocked() {
|
||
let p = default_policy();
|
||
// find -exec is a common bypass
|
||
assert!(!p.is_command_allowed("find . -exec rm -rf {} +"));
|
||
assert!(!p.is_command_allowed("find / -ok cat {} \\;"));
|
||
// git config/alias can execute commands
|
||
assert!(!p.is_command_allowed("git config core.editor \"rm -rf /\""));
|
||
assert!(!p.is_command_allowed("git alias.st status"));
|
||
assert!(!p.is_command_allowed("git -c core.editor=calc.exe commit"));
|
||
// Legitimate commands should still work
|
||
assert!(p.is_command_allowed("find . -name '*.txt'"));
|
||
assert!(p.is_command_allowed("git status"));
|
||
assert!(p.is_command_allowed("git add ."));
|
||
}
|
||
|
||
#[test]
|
||
fn command_injection_dollar_brace_blocked() {
|
||
let p = default_policy();
|
||
assert!(!p.is_command_allowed("echo ${IFS}cat${IFS}/etc/passwd"));
|
||
}
|
||
|
||
#[test]
|
||
fn command_injection_plain_dollar_var_blocked() {
|
||
let p = default_policy();
|
||
assert!(!p.is_command_allowed("cat $HOME/.ssh/id_rsa"));
|
||
assert!(!p.is_command_allowed("cat $SECRET_FILE"));
|
||
}
|
||
|
||
#[test]
|
||
fn command_injection_tee_blocked() {
|
||
let p = default_policy();
|
||
assert!(!p.is_command_allowed("echo secret | tee /etc/crontab"));
|
||
assert!(!p.is_command_allowed("ls | /usr/bin/tee outfile"));
|
||
assert!(!p.is_command_allowed("tee file.txt"));
|
||
}
|
||
|
||
#[test]
|
||
fn command_injection_process_substitution_blocked() {
|
||
let p = default_policy();
|
||
assert!(!p.is_command_allowed("cat <(echo pwned)"));
|
||
assert!(!p.is_command_allowed("ls >(cat /etc/passwd)"));
|
||
}
|
||
|
||
#[test]
|
||
fn command_env_var_prefix_with_allowed_cmd() {
|
||
let p = default_policy();
|
||
// env assignment + allowed command — OK
|
||
assert!(p.is_command_allowed("FOO=bar ls"));
|
||
assert!(p.is_command_allowed("LANG=C grep pattern file"));
|
||
// env assignment + disallowed command — blocked
|
||
assert!(!p.is_command_allowed("FOO=bar rm -rf /"));
|
||
}
|
||
|
||
// ── Edge cases: path traversal ──────────────────────────
|
||
|
||
#[test]
|
||
fn path_traversal_encoded_dots() {
|
||
let p = default_policy();
|
||
// Literal ".." in path — always blocked
|
||
assert!(!p.is_path_allowed("foo/..%2f..%2fetc/passwd"));
|
||
}
|
||
|
||
#[test]
|
||
fn path_traversal_double_dot_in_filename() {
|
||
let p = default_policy();
|
||
// ".." in a filename (not a path component) is allowed
|
||
assert!(p.is_path_allowed("my..file.txt"));
|
||
// But actual traversal components are still blocked
|
||
assert!(!p.is_path_allowed("../etc/passwd"));
|
||
assert!(!p.is_path_allowed("foo/../etc/passwd"));
|
||
}
|
||
|
||
#[test]
|
||
fn path_with_null_byte_blocked() {
|
||
let p = default_policy();
|
||
assert!(!p.is_path_allowed("file\0.txt"));
|
||
}
|
||
|
||
#[test]
|
||
fn path_symlink_style_absolute() {
|
||
let p = default_policy();
|
||
assert!(!p.is_path_allowed("/proc/self/root/etc/passwd"));
|
||
}
|
||
|
||
#[test]
|
||
fn path_home_tilde_ssh() {
|
||
let p = SecurityPolicy {
|
||
workspace_only: false,
|
||
..SecurityPolicy::default()
|
||
};
|
||
assert!(!p.is_path_allowed("~/.ssh/id_rsa"));
|
||
assert!(!p.is_path_allowed("~/.gnupg/secring.gpg"));
|
||
}
|
||
|
||
#[test]
|
||
fn path_var_run_blocked() {
|
||
let p = SecurityPolicy {
|
||
workspace_only: false,
|
||
..SecurityPolicy::default()
|
||
};
|
||
assert!(!p.is_path_allowed("/var/run/docker.sock"));
|
||
}
|
||
|
||
// ── Edge cases: rate limiter boundary ────────────────────
|
||
|
||
#[test]
|
||
fn rate_limit_exactly_at_boundary() {
|
||
let p = SecurityPolicy {
|
||
max_actions_per_hour: 1,
|
||
..SecurityPolicy::default()
|
||
};
|
||
assert!(p.record_action()); // 1 — exactly at limit
|
||
assert!(!p.record_action()); // 2 — over
|
||
assert!(!p.record_action()); // 3 — still over
|
||
}
|
||
|
||
#[test]
|
||
fn rate_limit_zero_blocks_everything() {
|
||
let p = SecurityPolicy {
|
||
max_actions_per_hour: 0,
|
||
..SecurityPolicy::default()
|
||
};
|
||
assert!(!p.record_action());
|
||
}
|
||
|
||
#[test]
|
||
fn rate_limit_high_allows_many() {
|
||
let p = SecurityPolicy {
|
||
max_actions_per_hour: 10000,
|
||
..SecurityPolicy::default()
|
||
};
|
||
for _ in 0..100 {
|
||
assert!(p.record_action());
|
||
}
|
||
}
|
||
|
||
// ── Edge cases: autonomy + command combos ────────────────
|
||
|
||
#[test]
|
||
fn readonly_blocks_even_safe_commands() {
|
||
let p = SecurityPolicy {
|
||
autonomy: AutonomyLevel::ReadOnly,
|
||
allowed_commands: vec!["ls".into(), "cat".into()],
|
||
..SecurityPolicy::default()
|
||
};
|
||
assert!(!p.is_command_allowed("ls"));
|
||
assert!(!p.is_command_allowed("cat"));
|
||
assert!(!p.can_act());
|
||
}
|
||
|
||
#[test]
|
||
fn supervised_allows_listed_commands() {
|
||
let p = SecurityPolicy {
|
||
autonomy: AutonomyLevel::Supervised,
|
||
allowed_commands: vec!["git".into()],
|
||
..SecurityPolicy::default()
|
||
};
|
||
assert!(p.is_command_allowed("git status"));
|
||
assert!(!p.is_command_allowed("docker ps"));
|
||
}
|
||
|
||
#[test]
|
||
fn full_autonomy_still_respects_forbidden_paths() {
|
||
let p = SecurityPolicy {
|
||
autonomy: AutonomyLevel::Full,
|
||
workspace_only: false,
|
||
..SecurityPolicy::default()
|
||
};
|
||
assert!(!p.is_path_allowed("/etc/shadow"));
|
||
assert!(!p.is_path_allowed("/root/.bashrc"));
|
||
}
|
||
|
||
// ── Edge cases: from_config preserves tracker ────────────
|
||
|
||
#[test]
|
||
fn from_config_creates_fresh_tracker() {
|
||
let autonomy_config = crate::config::AutonomyConfig {
|
||
level: AutonomyLevel::Full,
|
||
workspace_only: false,
|
||
allowed_commands: vec![],
|
||
forbidden_paths: vec![],
|
||
max_actions_per_hour: 10,
|
||
max_cost_per_day_cents: 100,
|
||
require_approval_for_medium_risk: true,
|
||
block_high_risk_commands: true,
|
||
..crate::config::AutonomyConfig::default()
|
||
};
|
||
let workspace = PathBuf::from("/tmp/test");
|
||
let policy = SecurityPolicy::from_config(&autonomy_config, &workspace);
|
||
assert_eq!(policy.tracker.count(), 0);
|
||
assert!(!policy.is_rate_limited());
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════
|
||
// SECURITY CHECKLIST TESTS
|
||
// Checklist: gateway not public, pairing required,
|
||
// filesystem scoped (no /), access via tunnel
|
||
// ══════════════════════════════════════════════════════════
|
||
|
||
// ── Checklist #3: Filesystem scoped (no /) ──────────────
|
||
|
||
#[test]
|
||
fn checklist_root_path_blocked() {
|
||
let p = default_policy();
|
||
assert!(!p.is_path_allowed("/"));
|
||
assert!(!p.is_path_allowed("/anything"));
|
||
}
|
||
|
||
#[test]
|
||
fn checklist_all_system_dirs_blocked() {
|
||
let p = SecurityPolicy {
|
||
workspace_only: false,
|
||
..SecurityPolicy::default()
|
||
};
|
||
for dir in [
|
||
"/etc", "/root", "/home", "/usr", "/bin", "/sbin", "/lib", "/opt", "/boot", "/dev",
|
||
"/proc", "/sys", "/var", "/tmp",
|
||
] {
|
||
assert!(
|
||
!p.is_path_allowed(dir),
|
||
"System dir should be blocked: {dir}"
|
||
);
|
||
assert!(
|
||
!p.is_path_allowed(&format!("{dir}/subpath")),
|
||
"Subpath of system dir should be blocked: {dir}/subpath"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn checklist_sensitive_dotfiles_blocked() {
|
||
let p = SecurityPolicy {
|
||
workspace_only: false,
|
||
..SecurityPolicy::default()
|
||
};
|
||
for path in [
|
||
"~/.ssh/id_rsa",
|
||
"~/.gnupg/secring.gpg",
|
||
"~/.aws/credentials",
|
||
"~/.config/secrets",
|
||
] {
|
||
assert!(
|
||
!p.is_path_allowed(path),
|
||
"Sensitive dotfile should be blocked: {path}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn checklist_null_byte_injection_blocked() {
|
||
let p = default_policy();
|
||
assert!(!p.is_path_allowed("safe\0/../../../etc/passwd"));
|
||
assert!(!p.is_path_allowed("\0"));
|
||
assert!(!p.is_path_allowed("file\0"));
|
||
}
|
||
|
||
#[test]
|
||
fn checklist_workspace_only_blocks_all_absolute() {
|
||
let p = SecurityPolicy {
|
||
workspace_only: true,
|
||
..SecurityPolicy::default()
|
||
};
|
||
assert!(!p.is_path_allowed("/any/absolute/path"));
|
||
assert!(p.is_path_allowed("relative/path.txt"));
|
||
}
|
||
|
||
#[test]
|
||
fn checklist_resolved_path_must_be_in_workspace() {
|
||
let p = SecurityPolicy {
|
||
workspace_dir: PathBuf::from("/home/user/project"),
|
||
..SecurityPolicy::default()
|
||
};
|
||
// Inside workspace — allowed
|
||
assert!(p.is_resolved_path_allowed(Path::new("/home/user/project/src/main.rs")));
|
||
// Outside workspace — blocked (symlink escape)
|
||
assert!(!p.is_resolved_path_allowed(Path::new("/etc/passwd")));
|
||
assert!(!p.is_resolved_path_allowed(Path::new("/home/user/other_project/file")));
|
||
// Root — blocked
|
||
assert!(!p.is_resolved_path_allowed(Path::new("/")));
|
||
}
|
||
|
||
#[test]
|
||
fn checklist_default_policy_is_workspace_only() {
|
||
let p = SecurityPolicy::default();
|
||
assert!(
|
||
p.workspace_only,
|
||
"Default policy must be workspace_only=true"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn checklist_default_forbidden_paths_comprehensive() {
|
||
let p = SecurityPolicy::default();
|
||
// Must contain all critical system dirs
|
||
for dir in ["/etc", "/root", "/proc", "/sys", "/dev", "/var", "/tmp"] {
|
||
assert!(
|
||
p.forbidden_paths.iter().any(|f| f == dir),
|
||
"Default forbidden_paths must include {dir}"
|
||
);
|
||
}
|
||
// Must contain sensitive dotfiles
|
||
for dot in ["~/.ssh", "~/.gnupg", "~/.aws"] {
|
||
assert!(
|
||
p.forbidden_paths.iter().any(|f| f == dot),
|
||
"Default forbidden_paths must include {dot}"
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── §1.2 Path resolution / symlink bypass tests ──────────
|
||
|
||
#[test]
|
||
fn resolved_path_blocks_outside_workspace() {
|
||
let workspace = std::env::temp_dir().join("zeroclaw_test_resolved_path");
|
||
let _ = std::fs::create_dir_all(&workspace);
|
||
|
||
// Use the canonicalized workspace so starts_with checks match
|
||
let canonical_workspace = workspace
|
||
.canonicalize()
|
||
.unwrap_or_else(|_| workspace.clone());
|
||
|
||
let policy = SecurityPolicy {
|
||
workspace_dir: canonical_workspace.clone(),
|
||
..SecurityPolicy::default()
|
||
};
|
||
|
||
// A resolved path inside the workspace should be allowed
|
||
let inside = canonical_workspace.join("subdir").join("file.txt");
|
||
assert!(
|
||
policy.is_resolved_path_allowed(&inside),
|
||
"path inside workspace should be allowed"
|
||
);
|
||
|
||
// A resolved path outside the workspace should be blocked
|
||
let canonical_temp = std::env::temp_dir()
|
||
.canonicalize()
|
||
.unwrap_or_else(|_| std::env::temp_dir());
|
||
let outside = canonical_temp.join("outside_workspace_zeroclaw");
|
||
assert!(
|
||
!policy.is_resolved_path_allowed(&outside),
|
||
"path outside workspace must be blocked"
|
||
);
|
||
|
||
let _ = std::fs::remove_dir_all(&workspace);
|
||
}
|
||
|
||
#[test]
|
||
fn resolved_path_blocks_root_escape() {
|
||
let policy = SecurityPolicy {
|
||
workspace_dir: PathBuf::from("/home/zeroclaw_user/project"),
|
||
..SecurityPolicy::default()
|
||
};
|
||
|
||
assert!(
|
||
!policy.is_resolved_path_allowed(Path::new("/etc/passwd")),
|
||
"resolved path to /etc/passwd must be blocked"
|
||
);
|
||
assert!(
|
||
!policy.is_resolved_path_allowed(Path::new("/root/.bashrc")),
|
||
"resolved path to /root/.bashrc must be blocked"
|
||
);
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn resolved_path_blocks_symlink_escape() {
|
||
use std::os::unix::fs::symlink;
|
||
|
||
let root = std::env::temp_dir().join("zeroclaw_test_symlink_escape");
|
||
let workspace = root.join("workspace");
|
||
let outside = root.join("outside_target");
|
||
|
||
let _ = std::fs::remove_dir_all(&root);
|
||
std::fs::create_dir_all(&workspace).unwrap();
|
||
std::fs::create_dir_all(&outside).unwrap();
|
||
|
||
// Create a symlink inside workspace pointing outside
|
||
let link_path = workspace.join("escape_link");
|
||
symlink(&outside, &link_path).unwrap();
|
||
|
||
let policy = SecurityPolicy {
|
||
workspace_dir: workspace.clone(),
|
||
..SecurityPolicy::default()
|
||
};
|
||
|
||
// The resolved symlink target should be outside workspace
|
||
let resolved = link_path.canonicalize().unwrap();
|
||
assert!(
|
||
!policy.is_resolved_path_allowed(&resolved),
|
||
"symlink-resolved path outside workspace must be blocked"
|
||
);
|
||
|
||
let _ = std::fs::remove_dir_all(&root);
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn allowed_roots_permits_paths_outside_workspace() {
|
||
use std::os::unix::fs::symlink;
|
||
|
||
let root = std::env::temp_dir().join("zeroclaw_test_allowed_roots");
|
||
let workspace = root.join("workspace");
|
||
let extra = root.join("extra_root");
|
||
let extra_file = extra.join("data.txt");
|
||
|
||
let _ = std::fs::remove_dir_all(&root);
|
||
std::fs::create_dir_all(&workspace).unwrap();
|
||
std::fs::create_dir_all(&extra).unwrap();
|
||
std::fs::write(&extra_file, "test").unwrap();
|
||
|
||
// Symlink inside workspace pointing to extra root
|
||
let link_path = workspace.join("link_to_extra");
|
||
symlink(&extra, &link_path).unwrap();
|
||
|
||
let resolved = link_path.join("data.txt").canonicalize().unwrap();
|
||
|
||
// Without allowed_roots — blocked (symlink escape)
|
||
let policy_without = SecurityPolicy {
|
||
workspace_dir: workspace.clone(),
|
||
allowed_roots: vec![],
|
||
..SecurityPolicy::default()
|
||
};
|
||
assert!(
|
||
!policy_without.is_resolved_path_allowed(&resolved),
|
||
"without allowed_roots, symlink target must be blocked"
|
||
);
|
||
|
||
// With allowed_roots — permitted
|
||
let policy_with = SecurityPolicy {
|
||
workspace_dir: workspace.clone(),
|
||
allowed_roots: vec![extra.clone()],
|
||
..SecurityPolicy::default()
|
||
};
|
||
assert!(
|
||
policy_with.is_resolved_path_allowed(&resolved),
|
||
"with allowed_roots containing the target, symlink must be allowed"
|
||
);
|
||
|
||
// Unrelated path still blocked
|
||
let unrelated = root.join("unrelated");
|
||
std::fs::create_dir_all(&unrelated).unwrap();
|
||
assert!(
|
||
!policy_with.is_resolved_path_allowed(&unrelated.canonicalize().unwrap()),
|
||
"paths outside workspace and allowed_roots must still be blocked"
|
||
);
|
||
|
||
let _ = std::fs::remove_dir_all(&root);
|
||
}
|
||
|
||
#[test]
|
||
fn is_path_allowed_blocks_null_bytes() {
|
||
let policy = default_policy();
|
||
assert!(
|
||
!policy.is_path_allowed("file\0.txt"),
|
||
"paths with null bytes must be blocked"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn is_path_allowed_blocks_url_encoded_traversal() {
|
||
let policy = default_policy();
|
||
assert!(
|
||
!policy.is_path_allowed("..%2fetc%2fpasswd"),
|
||
"URL-encoded path traversal must be blocked"
|
||
);
|
||
assert!(
|
||
!policy.is_path_allowed("subdir%2f..%2f..%2fetc"),
|
||
"URL-encoded parent dir traversal must be blocked"
|
||
);
|
||
}
|
||
}
|