use chrono::{DateTime, Utc};
use once_cell::sync::Lazy;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::fs::OpenOptions;
use std::io::Write;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::RwLock;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct YoloConfig {
pub enabled: bool,
pub max_operations: usize,
pub max_hours: f64,
pub forbidden_operations: Vec<String>,
pub protected_paths: Vec<String>,
#[serde(default)]
pub denied_paths: Vec<String>,
pub allow_git_push: bool,
pub allow_destructive_shell: bool,
pub audit_log_path: Option<PathBuf>,
pub status_interval: usize,
}
impl Default for YoloConfig {
fn default() -> Self {
Self {
enabled: false,
max_operations: 0, max_hours: 0.0, forbidden_operations: vec![
"rm -rf /".to_string(),
"rm -rf /*".to_string(),
"dd if=/dev/zero".to_string(),
"mkfs".to_string(),
"> /dev/sda".to_string(),
"chmod -R 777 /".to_string(),
],
protected_paths: vec![
"/etc".to_string(),
"/usr".to_string(),
"/bin".to_string(),
"/sbin".to_string(),
"/boot".to_string(),
"/root".to_string(),
"~/.ssh".to_string(),
"~/.gnupg".to_string(),
],
denied_paths: Vec::new(),
allow_git_push: true,
allow_destructive_shell: false,
audit_log_path: None,
status_interval: 100,
}
}
}
impl YoloConfig {
pub fn for_coding() -> Self {
Self {
enabled: true,
allow_git_push: false, allow_destructive_shell: false, status_interval: 50,
..Default::default()
}
}
pub fn fully_autonomous() -> Self {
Self {
enabled: true,
allow_git_push: true,
allow_destructive_shell: false, status_interval: 100,
..Default::default()
}
}
pub fn with_destructive_shell(mut self, allow: bool) -> Self {
self.allow_destructive_shell = allow;
self
}
pub fn with_git_push(mut self, allow: bool) -> Self {
self.allow_git_push = allow;
self
}
pub fn is_forbidden(&self, operation: &str) -> bool {
let normalized = normalize_input(operation);
self.forbidden_operations.iter().any(|f| {
let pattern = build_boundary_pattern(f);
Regex::new(&pattern)
.map(|re| re.is_match(&normalized))
.unwrap_or(false)
})
}
pub fn is_protected_path(&self, path: &str) -> bool {
let expanded = expand_home(path);
self.protected_paths.iter().any(|p| {
let protected = expand_home(p);
expanded.starts_with(&protected) || expanded == protected
})
}
}
fn normalize_input(input: &str) -> String {
static WS_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\s+").unwrap());
WS_RE.replace_all(input.trim(), " ").to_lowercase()
}
fn build_boundary_pattern(pattern: &str) -> String {
let trimmed = pattern.trim().to_lowercase();
let tokens: Vec<&str> = trimmed.split_whitespace().collect();
if tokens.is_empty() {
return String::new();
}
let escaped_tokens: Vec<String> = tokens.iter().map(|t| regex::escape(t)).collect();
let flexible = escaped_tokens.join(r"\s+");
let first_char = tokens.first().and_then(|t| t.chars().next());
let last_char = tokens.last().and_then(|t| t.chars().last());
let prefix = if first_char.is_some_and(|c| c.is_alphanumeric() || c == '_') {
r"\b"
} else {
""
};
let suffix = if last_char.is_some_and(|c| c.is_alphanumeric() || c == '_') {
r"\b"
} else {
""
};
format!("(?i){}{}{}", prefix, flexible, suffix)
}
fn expand_home(path: &str) -> String {
if path.starts_with("~/") {
if let Some(home) = std::env::var_os("HOME") {
return format!("{}{}", home.to_string_lossy(), &path[1..]);
}
}
path.to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEntry {
pub timestamp: DateTime<Utc>,
pub operation_id: usize,
pub tool_name: String,
pub arguments_summary: String,
pub auto_approved: bool,
pub result: AuditResult,
pub duration_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum AuditResult {
Success,
Failed(String),
Blocked(String),
}
pub struct YoloManager {
config: YoloConfig,
enabled: AtomicBool,
operation_count: AtomicUsize,
start_time: RwLock<Option<std::time::Instant>>,
audit_log: RwLock<Vec<AuditEntry>>,
}
impl YoloManager {
pub fn new(config: YoloConfig) -> Self {
let enabled = config.enabled;
Self {
config,
enabled: AtomicBool::new(enabled),
operation_count: AtomicUsize::new(0),
start_time: RwLock::new(if enabled {
Some(std::time::Instant::now())
} else {
None
}),
audit_log: RwLock::new(Vec::new()),
}
}
pub fn is_active(&self) -> bool {
if !self.enabled.load(Ordering::SeqCst) {
return false;
}
if self.config.max_operations > 0
&& self.operation_count.load(Ordering::SeqCst) >= self.config.max_operations
{
return false;
}
if self.config.max_hours > 0.0 {
if let Ok(start) = self.start_time.read() {
if let Some(start_time) = *start {
let hours = start_time.elapsed().as_secs_f64() / 3600.0;
if hours >= self.config.max_hours {
return false;
}
}
}
}
true
}
pub fn enable(&self) {
self.enabled.store(true, Ordering::SeqCst);
if let Ok(mut start) = self.start_time.write() {
*start = Some(std::time::Instant::now());
}
self.operation_count.store(0, Ordering::SeqCst);
}
pub fn disable(&self) {
self.enabled.store(false, Ordering::SeqCst);
}
pub fn should_auto_approve(&self, tool_name: &str, args: &serde_json::Value) -> YoloDecision {
if !self.is_active() {
return YoloDecision::RequireConfirmation("YOLO mode not active".to_string());
}
let args_str = serde_json::to_string(args).unwrap_or_default();
if self.config.is_forbidden(&args_str) {
return YoloDecision::Block("Operation is in forbidden list".to_string());
}
if let Some(path) = extract_path(args) {
if self.config.is_protected_path(&path) {
return YoloDecision::Block(format!("Path '{}' is protected", path));
}
}
if tool_name == "container_run" {
if let Some(volumes) = args.get("volumes").and_then(|v| v.as_array()) {
for vol in volumes {
if let Some(mount) = vol.as_str() {
if let Some(reason) = check_volume_mount(mount) {
return YoloDecision::Block(reason);
}
}
}
}
}
if tool_name == "git_push" && !self.config.allow_git_push {
return YoloDecision::RequireConfirmation("Git push requires confirmation".to_string());
}
if tool_name == "shell_exec" {
if let Some(cmd) = args.get("command").and_then(|c| c.as_str()) {
if !self.config.allow_destructive_shell {
if is_destructive_command(cmd) {
return YoloDecision::RequireConfirmation(
"Destructive shell command requires confirmation".to_string(),
);
}
if let Some(secret) = reads_sensitive_path(cmd) {
return YoloDecision::RequireConfirmation(format!(
"Shell command reads a sensitive path ({secret}) — requires confirmation."
));
}
if let Some(pattern) = reads_denied_path(cmd, &self.config.denied_paths) {
return YoloDecision::RequireConfirmation(format!(
"Shell command reads a path matching a denied glob ('{pattern}') — \
requires confirmation."
));
}
}
}
}
if !matches!(tool_name, "shell_exec" | "pty_shell" | "git_push") {
let metadata = crate::safety::default_tool_metadata(tool_name);
if metadata.destructive && !self.config.allow_destructive_shell {
return YoloDecision::RequireConfirmation(
"Destructive operation requires confirmation".to_string(),
);
}
}
YoloDecision::AutoApprove
}
pub fn record_operation(
&self,
tool_name: &str,
args: &serde_json::Value,
auto_approved: bool,
result: AuditResult,
duration_ms: u64,
) {
let op_id = self.operation_count.fetch_add(1, Ordering::SeqCst);
let entry = AuditEntry {
timestamp: Utc::now(),
operation_id: op_id,
tool_name: tool_name.to_string(),
arguments_summary: summarize_args(args),
auto_approved,
result,
duration_ms,
};
let _file_guard = if self.config.audit_log_path.is_some() {
Some(AUDIT_FILE_LOCK.lock().unwrap_or_else(|e| e.into_inner()))
} else {
None
};
if let Ok(mut log) = self.audit_log.write() {
log.push(entry.clone());
}
if let Some(ref path) = self.config.audit_log_path {
let _ = append_to_audit_file(path, &entry);
}
if self.config.status_interval > 0
&& op_id > 0
&& op_id.is_multiple_of(self.config.status_interval)
{
self.print_status();
}
}
pub fn operation_count(&self) -> usize {
self.operation_count.load(Ordering::SeqCst)
}
pub fn elapsed_hours(&self) -> f64 {
if let Ok(start) = self.start_time.read() {
if let Some(start_time) = *start {
return start_time.elapsed().as_secs_f64() / 3600.0;
}
}
0.0
}
pub fn print_status(&self) {
let ops = self.operation_count();
let hours = self.elapsed_hours();
let success_count = self
.audit_log
.read()
.map(|log| {
log.iter()
.filter(|e| matches!(e.result, AuditResult::Success))
.count()
})
.unwrap_or(0);
let failed_count = self
.audit_log
.read()
.map(|log| {
log.iter()
.filter(|e| matches!(e.result, AuditResult::Failed(_)))
.count()
})
.unwrap_or(0);
eprintln!("\n╔══════════════════════════════════════╗");
eprintln!("║ YOLO MODE STATUS UPDATE ║");
eprintln!("╠══════════════════════════════════════╣");
eprintln!("║ Operations: {:<6} | Time: {:.1}h ║", ops, hours);
eprintln!(
"║ Success: {:<4} | Failed: {:<4} ║",
success_count, failed_count
);
eprintln!("╚══════════════════════════════════════╝\n");
}
pub fn audit_summary(&self) -> AuditSummary {
let log = self.audit_log.read().unwrap_or_else(|e| e.into_inner());
let mut tools_used: std::collections::HashMap<String, usize> =
std::collections::HashMap::new();
let mut success = 0;
let mut failed = 0;
let mut blocked = 0;
let mut total_duration_ms = 0u64;
for entry in log.iter() {
*tools_used.entry(entry.tool_name.clone()).or_insert(0) += 1;
total_duration_ms += entry.duration_ms;
match &entry.result {
AuditResult::Success => success += 1,
AuditResult::Failed(_) => failed += 1,
AuditResult::Blocked(_) => blocked += 1,
}
}
AuditSummary {
total_operations: log.len(),
success,
failed,
blocked,
tools_used,
total_duration_ms,
elapsed_hours: self.elapsed_hours(),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum YoloDecision {
AutoApprove,
RequireConfirmation(String),
Block(String),
}
#[derive(Debug, Clone, Serialize)]
pub struct AuditSummary {
pub total_operations: usize,
pub success: usize,
pub failed: usize,
pub blocked: usize,
pub tools_used: std::collections::HashMap<String, usize>,
pub total_duration_ms: u64,
pub elapsed_hours: f64,
}
impl std::fmt::Display for AuditSummary {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(f, "YOLO Mode Audit Summary")?;
writeln!(f, "======================")?;
writeln!(f, "Total Operations: {}", self.total_operations)?;
writeln!(f, " - Success: {}", self.success)?;
writeln!(f, " - Failed: {}", self.failed)?;
writeln!(f, " - Blocked: {}", self.blocked)?;
writeln!(f, "Elapsed Time: {:.2} hours", self.elapsed_hours)?;
writeln!(
f,
"Total Duration: {:.1}s",
self.total_duration_ms as f64 / 1000.0
)?;
writeln!(f, "\nTools Used:")?;
for (tool, count) in &self.tools_used {
writeln!(f, " - {}: {}", tool, count)?;
}
Ok(())
}
}
fn extract_path(args: &serde_json::Value) -> Option<String> {
match args {
serde_json::Value::Object(map) => {
for (k, v) in map {
if k == "path" || k == "file" || k == "directory" {
if let Some(s) = v.as_str() {
return Some(s.to_string());
}
}
if let Some(res) = extract_path(v) {
return Some(res);
}
}
None
}
serde_json::Value::Array(arr) => {
for v in arr {
if let Some(res) = extract_path(v) {
return Some(res);
}
}
None
}
_ => None,
}
}
fn check_volume_mount(mount: &str) -> Option<String> {
let host_path = mount.split(':').next().unwrap_or("");
let expanded = expand_home(host_path);
if expanded.contains("/.ssh") || expanded == ".ssh" || expanded.starts_with(".ssh/") {
return Some(format!("Volume mount '{}' is not allowed", mount));
}
let dangerous_mounts = [
"/", "/etc", "/boot", "/usr", "/var", "/root", "/sys", "/proc", "/lib", "/lib64", "/opt",
"/run",
];
for dm in &dangerous_mounts {
if expanded == *dm
|| (expanded.starts_with(dm) && expanded.as_bytes().get(dm.len()) == Some(&b'/'))
{
return Some(format!("Volume mount '{}' is not allowed", mount));
}
}
None
}
static DESTRUCTIVE_PATTERNS: Lazy<Vec<Regex>> = Lazy::new(|| {
let patterns = [
"rm -rf",
"rm -r",
"rmdir",
"git push -f",
"git push --force",
"git reset --hard",
"git clean -f",
"DROP TABLE",
"DROP DATABASE",
"DELETE FROM",
"TRUNCATE",
"> /dev/",
"dd if=",
];
patterns
.iter()
.filter_map(|p| {
let re_pattern = build_boundary_pattern(p);
Regex::new(&re_pattern).ok()
})
.collect()
});
fn is_destructive_command(cmd: &str) -> bool {
let normalized = normalize_input(cmd);
DESTRUCTIVE_PATTERNS
.iter()
.any(|re| re.is_match(&normalized))
}
fn reads_sensitive_path(cmd: &str) -> Option<&'static str> {
let lower = cmd.to_lowercase();
const SENSITIVE: &[&str] = &[
".ssh/",
"id_rsa",
"id_ed25519",
"id_ecdsa",
".aws/credentials",
".netrc",
".git-credentials",
"private_key",
".pem",
"/secrets/",
".env",
];
const READERS: &[&str] = &[
"cat", "less", "more", "head", "tail", "bat", "nl", "tac", "xxd", "od", "strings",
"base64", "grep", "awk", "sed", "cp", "rsync", "scp", "curl", "dd",
];
let secret = SENSITIVE.iter().copied().find(|s| lower.contains(s))?;
if READERS.iter().any(|r| lower.contains(r)) {
Some(secret)
} else {
None
}
}
const SHELL_READERS: &[&str] = &[
"cat", "less", "more", "head", "tail", "bat", "nl", "tac", "xxd", "od", "strings", "base64",
"grep", "awk", "sed", "cp", "rsync", "scp", "curl", "dd",
];
fn shell_path_tokens(cmd: &str) -> Vec<&str> {
cmd.split(|c: char| {
c.is_whitespace()
|| matches!(
c,
'"' | '\'' | '=' | '|' | ';' | '&' | '(' | ')' | '<' | '>' | '`' | ','
)
})
.map(|t| t.trim_start_matches("./"))
.filter(|t| !t.is_empty())
.collect()
}
fn reads_denied_path(cmd: &str, denied_paths: &[String]) -> Option<String> {
if denied_paths.is_empty() {
return None;
}
let lower = cmd.to_lowercase();
if !SHELL_READERS.iter().any(|r| lower.contains(r)) {
return None;
}
let tokens = shell_path_tokens(cmd);
for pattern in denied_paths {
let compiled = match glob::Pattern::new(pattern) {
Ok(p) => p,
Err(_) => continue,
};
let filename_only = !pattern.contains('/');
for tok in &tokens {
if compiled.matches(tok) {
return Some(pattern.clone());
}
if filename_only {
if let Some(base) = std::path::Path::new(tok)
.file_name()
.and_then(|s| s.to_str())
{
if compiled.matches(base) {
return Some(pattern.clone());
}
}
}
}
}
None
}
fn summarize_args(args: &serde_json::Value) -> String {
let mut summary = serde_json::Map::new();
if let Some(obj) = args.as_object() {
for (key, value) in obj {
let summarized = match value {
serde_json::Value::String(s) if s.len() > 100 => {
serde_json::Value::String(format!(
"{}... ({} chars)",
s.chars().take(100).collect::<String>(),
s.len()
))
}
other => other.clone(),
};
summary.insert(key.clone(), summarized);
}
}
serde_json::to_string(&summary).unwrap_or_else(|_| "{}".to_string())
}
fn append_to_audit_file(path: &PathBuf, entry: &AuditEntry) -> std::io::Result<()> {
let json = serde_json::to_string(entry).unwrap_or_default();
let line = format!("{}\n", json);
let mut file = OpenOptions::new().create(true).append(true).open(path)?;
file.write_all(line.as_bytes())?;
file.flush()
}
static AUDIT_FILE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(test)]
#[path = "../../tests/unit/safety/yolo/yolo_test.rs"]
mod tests;