#[derive(Debug, Clone, PartialEq)]
pub enum DestructiveOperation {
FileDelete {
path: String,
},
GitForcePush {
branch: String,
},
GitResetHard,
GitClean,
ShellExec {
command: String,
},
FileOverwrite {
path: String,
},
DatabaseModify {
query: String,
},
}
impl DestructiveOperation {
pub fn description(&self) -> String {
match self {
Self::FileDelete { path } => format!("Delete file: {}", path),
Self::GitForcePush { branch } => format!("Force push to branch: {}", branch),
Self::GitResetHard => "Reset git repository (discard all changes)".to_string(),
Self::GitClean => "Clean untracked files from repository".to_string(),
Self::ShellExec { command } => {
format!("Execute shell command: {}", truncate(command, 50))
}
Self::FileOverwrite { path } => format!("Overwrite existing file: {}", path),
Self::DatabaseModify { query } => format!("Modify database: {}", truncate(query, 50)),
}
}
pub fn risk_level(&self) -> RiskLevel {
match self {
Self::GitForcePush { .. } | Self::GitResetHard | Self::GitClean => RiskLevel::High,
Self::FileDelete { .. } | Self::DatabaseModify { .. } => RiskLevel::Medium,
Self::ShellExec { .. } | Self::FileOverwrite { .. } => RiskLevel::Low,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum RiskLevel {
Low,
Medium,
High,
}
impl RiskLevel {
pub fn as_str(&self) -> &'static str {
match self {
Self::Low => "LOW",
Self::Medium => "MEDIUM",
Self::High => "HIGH",
}
}
pub fn color_code(&self) -> &'static str {
match self {
Self::Low => "\x1b[33m", Self::Medium => "\x1b[35m", Self::High => "\x1b[31m", }
}
}
#[derive(Debug, Clone)]
pub struct ConfirmConfig {
pub enabled: bool,
pub min_risk_level: RiskLevel,
pub auto_approve_non_interactive: bool,
pub always_confirm: Vec<String>,
pub never_confirm: Vec<String>,
}
impl Default for ConfirmConfig {
fn default() -> Self {
Self {
enabled: true,
min_risk_level: RiskLevel::Medium,
auto_approve_non_interactive: false,
always_confirm: vec!["git_push".to_string(), "file_delete".to_string()],
never_confirm: vec![
"file_read".to_string(),
"directory_tree".to_string(),
"git_status".to_string(),
"git_diff".to_string(),
],
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ConfirmResult {
Approved,
Rejected,
Skipped,
NotRequired,
}
pub fn requires_confirmation(
tool_name: &str,
operation: Option<&DestructiveOperation>,
config: &ConfirmConfig,
) -> bool {
if !config.enabled {
return false;
}
if config.never_confirm.iter().any(|t| t == tool_name) {
return false;
}
if config.always_confirm.iter().any(|t| t == tool_name) {
return true;
}
if let Some(op) = operation {
op.risk_level() >= config.min_risk_level
} else {
false
}
}
pub fn auto_confirm(operation: &DestructiveOperation, config: &ConfirmConfig) -> ConfirmResult {
if config.auto_approve_non_interactive {
tracing::warn!(
"Auto-approving operation in non-interactive mode: {}",
operation.description()
);
ConfirmResult::Approved
} else {
tracing::error!(
"Operation rejected in non-interactive mode: {}",
operation.description()
);
ConfirmResult::Rejected
}
}
pub fn detect_destructive_shell_command(command: &str) -> Option<DestructiveOperation> {
let dangerous_patterns = [
("rm -rf", true),
("rm -r", true),
("rmdir", true),
("git push -f", true),
("git push --force", true),
("git reset --hard", true),
("git clean", true),
("DROP TABLE", true),
("DROP DATABASE", true),
("DELETE FROM", true),
("TRUNCATE", true),
("> /dev/", true),
("dd if=", true),
("mkfs", true),
];
for (pattern, _) in &dangerous_patterns {
if command.to_lowercase().contains(&pattern.to_lowercase()) {
return Some(DestructiveOperation::ShellExec {
command: command.to_string(),
});
}
}
None
}
pub fn detect_destructive_git_operation(
tool_name: &str,
args: &serde_json::Value,
) -> Option<DestructiveOperation> {
match tool_name {
"git_push" => {
if args.get("force").and_then(|v| v.as_bool()).unwrap_or(false) {
let branch = args
.get("branch")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
Some(DestructiveOperation::GitForcePush { branch })
} else {
None
}
}
"git_reset" => {
if args.get("hard").and_then(|v| v.as_bool()).unwrap_or(false) {
Some(DestructiveOperation::GitResetHard)
} else {
None
}
}
"git_clean" => Some(DestructiveOperation::GitClean),
_ => None,
}
}
fn truncate(s: &str, max_len: usize) -> String {
if s.chars().count() <= max_len {
s.to_string()
} else {
let truncated: String = s.chars().take(max_len).collect();
format!("{}...", truncated)
}
}
#[cfg(test)]
#[path = "../../tests/unit/safety/confirm/confirm_test.rs"]
mod tests;