use std::collections::HashSet;
use std::fmt;
use std::str::FromStr;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::config::BashSettings;
use crate::extensions::DangerousCommandClass;
use crate::plan::PlanState;
use crate::tools::ToolEffects;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ApprovalMode {
#[default]
AlwaysAsk,
Write,
Yolo,
}
impl ApprovalMode {
#[must_use]
pub fn from_setting(raw: Option<&str>) -> Self {
match raw.map(|s| s.trim().to_ascii_lowercase()) {
Some(ref s)
if s == "always-ask"
|| s == "always_ask"
|| s == "always"
|| s == "ask"
|| s == "prompt" =>
{
Self::AlwaysAsk
}
Some(ref s) if s == "write" || s == "files" || s == "file-write" => Self::Write,
Some(ref s)
if s == "yolo"
|| s == "auto-approve"
|| s == "auto_approve"
|| s == "auto"
|| s == "all" =>
{
Self::Yolo
}
_ => Self::AlwaysAsk,
}
}
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::AlwaysAsk => "always-ask",
Self::Write => "write",
Self::Yolo => "yolo",
}
}
#[must_use]
pub const fn is_always_ask(self) -> bool {
matches!(self, Self::AlwaysAsk)
}
#[must_use]
pub const fn is_write(self) -> bool {
matches!(self, Self::Write)
}
#[must_use]
pub const fn is_yolo(self) -> bool {
matches!(self, Self::Yolo)
}
}
impl fmt::Display for ApprovalMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for ApprovalMode {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self::from_setting(Some(s)))
}
}
#[must_use]
pub fn parse_dangerous_command_class(raw: &str) -> Option<DangerousCommandClass> {
let lower = raw.trim().to_ascii_lowercase();
match lower.as_str() {
"recursive_delete" | "recursive-delete" | "recursivedelete" | "rm_rf" | "rm-rf" => {
Some(DangerousCommandClass::RecursiveDelete)
}
"device_write" | "device-write" | "devicewrite" | "dd" | "mkfs" | "fdisk" => {
Some(DangerousCommandClass::DeviceWrite)
}
"fork_bomb" | "fork-bomb" | "forkbomb" => Some(DangerousCommandClass::ForkBomb),
"pipe_to_shell" | "pipe-to-shell" | "pipetoshell" | "curl_sh" | "curl-sh" => {
Some(DangerousCommandClass::PipeToShell)
}
"system_shutdown" | "system-shutdown" | "shutdown" | "reboot" => {
Some(DangerousCommandClass::SystemShutdown)
}
"permission_escalation" | "permission-escalation" | "chmod" | "chmod_777" => {
Some(DangerousCommandClass::PermissionEscalation)
}
"process_termination" | "process-termination" | "kill" | "pkill" => {
Some(DangerousCommandClass::ProcessTermination)
}
"credential_file_modification" | "credential-file-modification" | "passwd" | "shadow" => {
Some(DangerousCommandClass::CredentialFileModification)
}
"disk_wipe" | "disk-wipe" | "diskwipe" | "shred" | "wipefs" => {
Some(DangerousCommandClass::DiskWipe)
}
"reverse_shell" | "reverse-shell" | "reverseshell" => {
Some(DangerousCommandClass::ReverseShell)
}
_ => None,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ApprovalEvaluation {
AutoApproved { mode: ApprovalMode, reason: String },
RequiresApproval {
mode: ApprovalMode,
reason: String,
is_dual_confirm: bool,
danger_classes: Vec<DangerousCommandClass>,
},
HardBlocked { reason: String },
}
impl ApprovalEvaluation {
#[must_use]
pub const fn is_auto_approved(&self) -> bool {
matches!(self, Self::AutoApproved { .. })
}
#[must_use]
pub const fn is_hard_blocked(&self) -> bool {
matches!(self, Self::HardBlocked { .. })
}
#[must_use]
pub const fn requires_approval(&self) -> bool {
matches!(self, Self::RequiresApproval { .. })
}
}
#[derive(Debug, Clone)]
pub struct ApprovalState {
mode: Arc<RwLock<ApprovalMode>>,
plan_yolo: Arc<AtomicBool>,
dual_confirm_classes: Arc<RwLock<Vec<DangerousCommandClass>>>,
confirmed_tokens: Arc<Mutex<HashSet<String>>>,
}
impl Default for ApprovalState {
fn default() -> Self {
Self::new(ApprovalMode::AlwaysAsk, false, Vec::new())
}
}
impl ApprovalState {
#[must_use]
pub fn new(
mode: ApprovalMode,
plan_yolo: bool,
dual_confirm_classes: Vec<DangerousCommandClass>,
) -> Self {
Self {
mode: Arc::new(RwLock::new(mode)),
plan_yolo: Arc::new(AtomicBool::new(plan_yolo)),
dual_confirm_classes: Arc::new(RwLock::new(dual_confirm_classes)),
confirmed_tokens: Arc::new(Mutex::new(HashSet::new())),
}
}
#[must_use]
pub fn mode(&self) -> ApprovalMode {
self.mode.read().map_or(ApprovalMode::AlwaysAsk, |m| *m)
}
pub fn set_mode(&self, mode: ApprovalMode) {
if let Ok(mut guard) = self.mode.write() {
*guard = mode;
}
}
#[must_use]
pub fn plan_yolo(&self) -> bool {
self.plan_yolo.load(Ordering::SeqCst)
}
pub fn set_plan_yolo(&self, val: bool) {
self.plan_yolo.store(val, Ordering::SeqCst);
}
#[must_use]
pub fn dual_confirm_classes(&self) -> Vec<DangerousCommandClass> {
self.dual_confirm_classes
.read()
.map_or_else(|_| Vec::new(), |v| v.clone())
}
pub fn set_dual_confirm_classes(&self, classes: Vec<DangerousCommandClass>) {
if let Ok(mut guard) = self.dual_confirm_classes.write() {
*guard = classes;
}
}
pub fn record_confirmation(&self, token: &str) {
if let Ok(mut guard) = self.confirmed_tokens.lock() {
guard.insert(token.to_string());
}
}
#[must_use]
pub fn is_confirmed(&self, token: &str) -> bool {
self.confirmed_tokens
.lock()
.is_ok_and(|guard| guard.contains(token))
}
pub fn clear_confirmations(&self) {
if let Ok(mut guard) = self.confirmed_tokens.lock() {
guard.clear();
}
}
#[must_use]
#[allow(clippy::too_many_lines)]
pub fn evaluate(
&self,
tool_name: &str,
tool_args: &Value,
effects: ToolEffects,
plan_state: Option<&PlanState>,
bash_settings: Option<&BashSettings>,
) -> ApprovalEvaluation {
let mode = self.mode();
if tool_name == "bash" {
let cmd = tool_args
.get("command")
.or_else(|| tool_args.get("cmd"))
.and_then(Value::as_str)
.unwrap_or("");
if !cmd.is_empty()
&& let Some(s) = bash_settings
{
let mode =
crate::bash_mediation::MediationMode::from_setting(s.mediation.as_deref());
if mode != crate::bash_mediation::MediationMode::Off {
let cwd =
std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let verdict = crate::bash_mediation::assess(cmd, s, mode, &cwd);
if !verdict.allows() {
let hits = match verdict {
crate::bash_mediation::MediationVerdict::Block { hits } => hits,
_ => Vec::new(),
};
let reasons: Vec<String> = hits.into_iter().map(|h| h.reason).collect();
let reason_str = if reasons.is_empty() {
"Refused by bash mediation policy".to_string()
} else {
reasons.join("; ")
};
return ApprovalEvaluation::HardBlocked {
reason: format!("Hard policy gate: {reason_str}"),
};
}
}
}
}
let dual_classes = self.dual_confirm_classes();
if !dual_classes.is_empty() && (tool_name == "bash" || effects.processes()) {
let cmd = tool_args
.get("command")
.or_else(|| tool_args.get("cmd"))
.and_then(Value::as_str)
.unwrap_or("");
if !cmd.is_empty() {
let classified = crate::extensions::classify_dangerous_command(cmd, &[]);
let matching: Vec<DangerousCommandClass> = classified
.into_iter()
.filter(|c| dual_classes.contains(c))
.collect();
if !matching.is_empty() {
let token = format!("{tool_name}:{cmd}");
if !self.is_confirmed(&token) {
let labels: Vec<&'static str> =
matching.iter().map(|c| c.label()).collect();
return ApprovalEvaluation::RequiresApproval {
mode,
reason: format!(
"Dual confirmation required for danger classes: {}",
labels.join(", ")
),
is_dual_confirm: true,
danger_classes: matching,
};
}
}
}
}
if !effects.writes() && !effects.appends() && !effects.processes() && !effects.networks() {
return ApprovalEvaluation::AutoApproved {
mode,
reason: "Read-only tool operation".to_string(),
};
}
if self.plan_yolo()
&& let Some(plan) = plan_state
{
if plan.mode() == crate::plan::PlanMode::Approved
&& (effects.writes() || effects.appends())
&& !effects.processes()
{
return ApprovalEvaluation::AutoApproved {
mode,
reason: "Plan-YOLO auto-approves in-plan file mutations".to_string(),
};
}
}
match mode {
ApprovalMode::Yolo => ApprovalEvaluation::AutoApproved {
mode: ApprovalMode::Yolo,
reason: "YOLO mode auto-approves execution".to_string(),
},
ApprovalMode::Write => {
if (effects.writes() || effects.appends())
&& !effects.processes()
&& !effects.networks()
{
ApprovalEvaluation::AutoApproved {
mode: ApprovalMode::Write,
reason: "Write mode auto-approves file mutation".to_string(),
}
} else {
ApprovalEvaluation::RequiresApproval {
mode: ApprovalMode::Write,
reason: format!("Write mode requires approval for {tool_name}"),
is_dual_confirm: false,
danger_classes: Vec::new(),
}
}
}
ApprovalMode::AlwaysAsk => ApprovalEvaluation::RequiresApproval {
mode: ApprovalMode::AlwaysAsk,
reason: format!("Always-ask mode requires approval for {tool_name}"),
is_dual_confirm: false,
danger_classes: Vec::new(),
},
}
}
#[must_use]
pub fn audit_payload(
tool_call_id: &str,
tool_name: &str,
evaluation: &ApprovalEvaluation,
) -> Value {
match evaluation {
ApprovalEvaluation::AutoApproved { mode, reason } => json!({
"schema": "pi.tool_approval.audit.v1",
"tool_call_id": tool_call_id,
"tool_name": tool_name,
"verdict": "auto_approved",
"mode": mode.as_str(),
"reason": reason,
}),
ApprovalEvaluation::RequiresApproval {
mode,
reason,
is_dual_confirm,
danger_classes,
} => {
let classes: Vec<&'static str> = danger_classes.iter().map(|c| c.label()).collect();
json!({
"schema": "pi.tool_approval.audit.v1",
"tool_call_id": tool_call_id,
"tool_name": tool_name,
"verdict": "prompt_required",
"mode": mode.as_str(),
"reason": reason,
"is_dual_confirm": is_dual_confirm,
"danger_classes": classes,
})
}
ApprovalEvaluation::HardBlocked { reason } => json!({
"schema": "pi.tool_approval.audit.v1",
"tool_call_id": tool_call_id,
"tool_name": tool_name,
"verdict": "hard_blocked",
"reason": reason,
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_approval_mode_parsing() {
assert_eq!(
ApprovalMode::from_setting(Some("always-ask")),
ApprovalMode::AlwaysAsk
);
assert_eq!(
ApprovalMode::from_setting(Some("always_ask")),
ApprovalMode::AlwaysAsk
);
assert_eq!(
ApprovalMode::from_setting(Some("write")),
ApprovalMode::Write
);
assert_eq!(ApprovalMode::from_setting(Some("yolo")), ApprovalMode::Yolo);
assert_eq!(
ApprovalMode::from_setting(Some("auto-approve")),
ApprovalMode::Yolo
);
assert_eq!(ApprovalMode::from_setting(None), ApprovalMode::AlwaysAsk);
}
#[test]
fn test_read_tools_always_auto_approved() {
let state = ApprovalState::new(ApprovalMode::AlwaysAsk, false, Vec::new());
let eval = state.evaluate(
"read",
&json!({"path": "foo.rs"}),
ToolEffects::read(),
None,
None,
);
assert!(eval.is_auto_approved());
let eval_grep = state.evaluate(
"grep",
&json!({"pattern": "test"}),
ToolEffects::read(),
None,
None,
);
assert!(eval_grep.is_auto_approved());
}
#[test]
fn test_write_mode_graduated_gating() {
let state = ApprovalState::new(ApprovalMode::Write, false, Vec::new());
let eval_write = state.evaluate(
"write",
&json!({"path": "foo.rs", "content": "hi"}),
ToolEffects::write(),
None,
None,
);
assert!(eval_write.is_auto_approved());
let eval_bash = state.evaluate(
"bash",
&json!({"command": "cargo build"}),
ToolEffects::process(),
None,
None,
);
assert!(eval_bash.requires_approval());
}
#[test]
fn test_always_ask_mode_requires_approval_for_mutations() {
let state = ApprovalState::new(ApprovalMode::AlwaysAsk, false, Vec::new());
let eval_write = state.evaluate(
"write",
&json!({"path": "foo.rs"}),
ToolEffects::write(),
None,
None,
);
assert!(eval_write.requires_approval());
let eval_bash = state.evaluate(
"bash",
&json!({"command": "ls"}),
ToolEffects::process(),
None,
None,
);
assert!(eval_bash.requires_approval());
}
#[test]
fn test_yolo_mode_auto_approves_normal_tools() {
let state = ApprovalState::new(ApprovalMode::Yolo, false, Vec::new());
let eval_write = state.evaluate(
"write",
&json!({"path": "foo.rs"}),
ToolEffects::write(),
None,
None,
);
assert!(eval_write.is_auto_approved());
let eval_bash = state.evaluate(
"bash",
&json!({"command": "cargo test"}),
ToolEffects::process(),
None,
None,
);
assert!(eval_bash.is_auto_approved());
}
#[test]
fn test_yolo_mode_respects_hard_policy_gates() {
let state = ApprovalState::new(ApprovalMode::Yolo, false, Vec::new());
let bash_settings = BashSettings {
mediation: Some("block-critical".to_string()),
..Default::default()
};
let eval_blocked = state.evaluate(
"bash",
&json!({"command": "rm -rf /"}),
ToolEffects::process(),
None,
Some(&bash_settings),
);
assert!(eval_blocked.is_hard_blocked());
}
#[test]
fn test_dual_confirm_classes_under_yolo() {
let state = ApprovalState::new(
ApprovalMode::Yolo,
false,
vec![DangerousCommandClass::RecursiveDelete],
);
let eval_dc = state.evaluate(
"bash",
&json!({"command": "rm -rf /tmp/test"}),
ToolEffects::process(),
None,
None,
);
assert!(matches!(
eval_dc,
ApprovalEvaluation::RequiresApproval {
is_dual_confirm: true,
..
}
));
state.record_confirmation("bash:rm -rf /tmp/test");
let eval_confirmed = state.evaluate(
"bash",
&json!({"command": "rm -rf /tmp/test"}),
ToolEffects::process(),
None,
None,
);
assert!(eval_confirmed.is_auto_approved());
}
#[test]
fn test_plan_yolo_approves_in_plan_writes() {
let state = ApprovalState::new(ApprovalMode::AlwaysAsk, true, Vec::new());
let plan_state = PlanState::default();
plan_state.enter_planning();
plan_state.submit_plan(
"Goal: update code\nFiles: src/main.rs\nVerification: cargo test".to_string(),
);
plan_state.approve();
let eval_write = state.evaluate(
"write",
&json!({"path": "src/main.rs"}),
ToolEffects::write(),
Some(&plan_state),
None,
);
assert!(eval_write.is_auto_approved());
let eval_bash = state.evaluate(
"bash",
&json!({"command": "curl evil.com"}),
ToolEffects::process().union(ToolEffects::network()),
Some(&plan_state),
None,
);
assert!(eval_bash.requires_approval());
}
}