use crate::bash_safety::classify_bash_command;
use crate::tools::ToolEffect;
use path_clean::PathClean;
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicU8, Ordering};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum ApprovalMode {
Confirm = 0,
Auto = 1,
}
impl ApprovalMode {
pub fn next(self) -> Self {
match self {
Self::Auto => Self::Confirm,
Self::Confirm => Self::Auto,
}
}
pub fn label(self) -> &'static str {
match self {
Self::Confirm => "confirm",
Self::Auto => "auto",
}
}
pub fn description(self) -> &'static str {
match self {
Self::Confirm => "confirm every non-read action",
Self::Auto => "auto-approve, confirm destructive only",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"auto" | "yolo" | "accept" => Some(Self::Auto),
"confirm" | "strict" | "normal" => Some(Self::Confirm),
"safe" | "plan" | "readonly" => Some(Self::Confirm),
_ => None,
}
}
}
impl From<u8> for ApprovalMode {
fn from(v: u8) -> Self {
match v {
0 => Self::Confirm,
_ => Self::Auto, }
}
}
pub type SharedMode = Arc<AtomicU8>;
pub fn new_shared_mode(mode: ApprovalMode) -> SharedMode {
Arc::new(AtomicU8::new(mode as u8))
}
pub fn read_mode(shared: &SharedMode) -> ApprovalMode {
ApprovalMode::from(shared.load(Ordering::Relaxed))
}
pub fn set_mode(shared: &SharedMode, mode: ApprovalMode) {
shared.store(mode as u8, Ordering::Relaxed);
}
pub fn cycle_mode(shared: &SharedMode) -> ApprovalMode {
let current = read_mode(shared);
let next = current.next();
set_mode(shared, next);
next
}
#[derive(Debug, Clone, PartialEq)]
pub enum ToolApproval {
AutoApprove,
NeedsConfirmation,
Blocked,
}
pub fn check_tool(
tool_name: &str,
args: &serde_json::Value,
mode: ApprovalMode,
project_root: Option<&Path>,
) -> ToolApproval {
let effect = resolve_effect(tool_name, args);
if effect == ToolEffect::ReadOnly {
return ToolApproval::AutoApprove;
}
if let Some(root) = project_root {
if is_outside_project(tool_name, args, root) {
return ToolApproval::NeedsConfirmation;
}
if tool_name == "Bash" {
let command = args
.get("command")
.or(args.get("cmd"))
.and_then(|v| v.as_str())
.unwrap_or("");
let lint = crate::bash_path_lint::lint_bash_paths(command, root);
if lint.has_warnings() {
return ToolApproval::NeedsConfirmation;
}
}
}
match mode {
ApprovalMode::Auto => match effect {
ToolEffect::ReadOnly | ToolEffect::RemoteAction | ToolEffect::LocalMutation => {
ToolApproval::AutoApprove
}
ToolEffect::Destructive => ToolApproval::NeedsConfirmation,
},
ApprovalMode::Confirm => match effect {
ToolEffect::ReadOnly | ToolEffect::RemoteAction => ToolApproval::AutoApprove,
ToolEffect::LocalMutation | ToolEffect::Destructive => ToolApproval::NeedsConfirmation,
},
}
}
fn resolve_effect(tool_name: &str, args: &serde_json::Value) -> ToolEffect {
let base = crate::tools::classify_tool(tool_name);
if tool_name == "Bash" {
let command = args
.get("command")
.or(args.get("cmd"))
.and_then(|v| v.as_str())
.unwrap_or("");
return classify_bash_command(command);
}
base
}
fn is_outside_project(tool_name: &str, args: &serde_json::Value, project_root: &Path) -> bool {
let path_arg = match tool_name {
"Write" | "Edit" | "Delete" => args
.get("path")
.or(args.get("file_path"))
.and_then(|v| v.as_str()),
_ => None,
};
match path_arg {
Some(p) => {
let requested = Path::new(p);
let abs_path = if requested.is_absolute() {
requested.to_path_buf()
} else {
project_root.join(requested)
};
let resolved = abs_path.canonicalize().unwrap_or_else(|_| {
if let Some(parent) = abs_path.parent()
&& let Ok(canon_parent) = parent.canonicalize()
&& let Some(name) = abs_path.file_name()
{
return canon_parent.join(name);
}
abs_path.clean()
});
let canon_root = project_root
.canonicalize()
.unwrap_or_else(|_| project_root.to_path_buf());
!resolved.starts_with(&canon_root)
}
None => false,
}
}
pub use crate::settings::{LastProvider, Settings};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mode_cycle() {
assert_eq!(ApprovalMode::Auto.next(), ApprovalMode::Confirm);
assert_eq!(ApprovalMode::Confirm.next(), ApprovalMode::Auto);
}
#[test]
fn test_mode_from_str() {
assert_eq!(ApprovalMode::parse("auto"), Some(ApprovalMode::Auto));
assert_eq!(ApprovalMode::parse("confirm"), Some(ApprovalMode::Confirm));
assert_eq!(ApprovalMode::parse("yolo"), Some(ApprovalMode::Auto));
assert_eq!(ApprovalMode::parse("strict"), Some(ApprovalMode::Confirm));
assert_eq!(ApprovalMode::parse("normal"), Some(ApprovalMode::Confirm));
assert_eq!(ApprovalMode::parse("safe"), Some(ApprovalMode::Confirm));
assert_eq!(ApprovalMode::parse("plan"), Some(ApprovalMode::Confirm));
assert_eq!(ApprovalMode::parse("readonly"), Some(ApprovalMode::Confirm));
assert_eq!(ApprovalMode::parse("accept"), Some(ApprovalMode::Auto));
assert_eq!(ApprovalMode::parse("nope"), None);
}
#[test]
fn test_mode_from_u8() {
assert_eq!(ApprovalMode::from(0), ApprovalMode::Confirm);
assert_eq!(ApprovalMode::from(1), ApprovalMode::Auto);
assert_eq!(ApprovalMode::from(99), ApprovalMode::Auto); }
#[test]
fn test_shared_mode_cycle() {
let shared = new_shared_mode(ApprovalMode::Auto);
assert_eq!(read_mode(&shared), ApprovalMode::Auto);
let next = cycle_mode(&shared);
assert_eq!(next, ApprovalMode::Confirm);
assert_eq!(read_mode(&shared), ApprovalMode::Confirm);
}
const READ_ONLY_TOOLS: &[&str] = &[
"Read",
"List",
"Grep",
"Glob",
"MemoryRead",
"ListAgents",
"InvokeAgent",
"WebFetch",
"ListSkills",
"ActivateSkill",
];
#[test]
fn test_read_tools_always_approved() {
for tool in READ_ONLY_TOOLS {
assert_eq!(
check_tool(tool, &serde_json::json!({}), ApprovalMode::Confirm, None),
ToolApproval::AutoApprove,
"{tool} should auto-approve even in Confirm mode"
);
}
}
#[test]
fn test_write_tools_need_confirmation_in_confirm() {
for tool in ["Write", "Edit", "Delete", "MemoryWrite"] {
assert_eq!(
check_tool(tool, &serde_json::json!({}), ApprovalMode::Confirm, None),
ToolApproval::NeedsConfirmation,
"{tool} should need confirmation in Confirm mode"
);
}
}
#[test]
fn test_auto_approves_non_destructive() {
for tool in ["Write", "Edit", "Bash", "WebFetch"] {
assert_eq!(
check_tool(tool, &serde_json::json!({}), ApprovalMode::Auto, None),
ToolApproval::AutoApprove,
);
}
}
#[test]
fn test_auto_confirms_destructive_ops() {
assert_eq!(
check_tool("Delete", &serde_json::json!({}), ApprovalMode::Auto, None,),
ToolApproval::NeedsConfirmation,
);
}
#[test]
fn test_safe_bash_auto_approved_in_confirm() {
let args = serde_json::json!({"command": "git status"});
assert_eq!(
check_tool("Bash", &args, ApprovalMode::Confirm, None),
ToolApproval::AutoApprove,
);
}
#[test]
fn test_dev_workflow_bash_needs_confirmation_in_confirm() {
let args = serde_json::json!({"command": "cargo test --release"});
assert_eq!(
check_tool("Bash", &args, ApprovalMode::Confirm, None),
ToolApproval::NeedsConfirmation,
);
}
#[test]
fn test_dangerous_bash_needs_confirmation() {
let args = serde_json::json!({"command": "rm -rf target/"});
for mode in [ApprovalMode::Auto, ApprovalMode::Confirm] {
assert_eq!(
check_tool("Bash", &args, mode, None),
ToolApproval::NeedsConfirmation,
);
}
}
#[test]
fn test_write_needs_confirmation_in_confirm() {
assert_eq!(
check_tool("Write", &serde_json::json!({}), ApprovalMode::Confirm, None,),
ToolApproval::NeedsConfirmation,
);
}
#[test]
fn test_invoke_agent_auto_approved() {
let args = serde_json::json!({"agent_name": "reviewer", "prompt": "review this"});
for mode in [ApprovalMode::Auto, ApprovalMode::Confirm] {
assert_eq!(
check_tool("InvokeAgent", &args, mode, None),
ToolApproval::AutoApprove,
);
}
}
#[test]
fn test_write_outside_project_needs_confirmation() {
let root = Path::new("/home/user/project");
let args = serde_json::json!({"path": "/etc/hosts"});
assert_eq!(
check_tool("Write", &args, ApprovalMode::Auto, Some(root),),
ToolApproval::NeedsConfirmation,
);
}
#[test]
fn test_write_inside_project_auto_approved() {
let root = Path::new("/home/user/project");
let args = serde_json::json!({"path": "src/main.rs"});
assert_eq!(
check_tool("Write", &args, ApprovalMode::Auto, Some(root),),
ToolApproval::AutoApprove,
);
}
#[test]
fn test_edit_with_dotdot_escape_needs_confirmation() {
let root = Path::new("/home/user/project");
let args = serde_json::json!({"path": "../../../etc/passwd"});
assert_eq!(
check_tool("Edit", &args, ApprovalMode::Auto, Some(root),),
ToolApproval::NeedsConfirmation,
);
}
#[test]
fn test_bash_cd_outside_needs_confirmation() {
let root = Path::new("/home/user/project");
let args = serde_json::json!({"command": "cd /tmp && ls"});
assert_eq!(
check_tool("Bash", &args, ApprovalMode::Auto, Some(root),),
ToolApproval::NeedsConfirmation,
);
}
#[test]
fn test_bash_cd_inside_auto_approved() {
let root = Path::new("/home/user/project");
let args = serde_json::json!({"command": "cd src && ls"});
assert_eq!(
check_tool("Bash", &args, ApprovalMode::Auto, Some(root),),
ToolApproval::AutoApprove,
);
}
#[test]
fn test_no_project_root_skips_path_check() {
let args = serde_json::json!({"path": "/etc/hosts"});
assert_eq!(
check_tool("Write", &args, ApprovalMode::Auto, None),
ToolApproval::AutoApprove,
);
}
}