use crate::file_tracker::FileTracker;
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, PartialOrd, Ord, Hash, Default)]
#[repr(u8)]
pub enum TrustMode {
Plan = 0,
#[default]
Safe = 1,
Auto = 2,
}
impl TrustMode {
pub fn next(self) -> Self {
match self {
Self::Plan => Self::Plan, Self::Safe => Self::Auto,
Self::Auto => Self::Safe,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Plan => "plan",
Self::Safe => "safe",
Self::Auto => "auto",
}
}
pub fn label(self) -> &'static str {
self.as_str()
}
pub fn description(self) -> &'static str {
match self {
Self::Plan => "read-only, deny all writes",
Self::Safe => "confirm every side effect",
Self::Auto => "auto-approve, confirm outside-project only",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"auto" | "yolo" | "accept" => Some(Self::Auto),
"safe" | "confirm" | "strict" | "normal" => Some(Self::Safe),
"plan" | "readonly" | "read-only" => Some(Self::Plan),
_ => None,
}
}
pub fn clamp(parent: TrustMode, child: TrustMode) -> TrustMode {
std::cmp::min(parent, child)
}
}
pub fn coerce_for_top_level(candidate: TrustMode) -> (TrustMode, Option<&'static str>) {
match candidate {
TrustMode::Plan => (
TrustMode::Safe,
Some(
"Plan mode is sub-agent-only (read-only mode for spawned agents). \
Falling back to Safe for the main session.",
),
),
other => (other, None),
}
}
pub fn require_sandbox_for_auto(
candidate: TrustMode,
sandbox_available: bool,
) -> Result<TrustMode, String> {
match candidate {
TrustMode::Auto if !sandbox_available => Err(
"Auto mode requires the kernel sandbox, which is unavailable on this system. \
Auto auto-approves mutating tool calls and relies on the sandbox to contain \
them; without the sandbox, the agent could touch arbitrary files outside the \
project. Use `--mode safe` to keep the human in the approval loop, or install \
the platform sandbox backend (see setup hint below if printed by the CLI)."
.to_string(),
),
other => Ok(other),
}
}
pub fn derive_default_trust(sandbox_available: bool) -> TrustMode {
if sandbox_available {
TrustMode::Auto
} else {
TrustMode::Safe
}
}
pub fn cycle_trust_checked(
shared: &SharedTrustMode,
sandbox_available: bool,
) -> Result<TrustMode, String> {
let next = read_trust(shared).next();
let validated = require_sandbox_for_auto(next, sandbox_available)?;
set_trust(shared, validated);
Ok(validated)
}
pub fn set_trust_checked(
shared: &SharedTrustMode,
mode: TrustMode,
sandbox_available: bool,
) -> Result<TrustMode, String> {
let validated = require_sandbox_for_auto(mode, sandbox_available)?;
set_trust(shared, validated);
Ok(validated)
}
pub fn derive_child_trust(parent_runtime: TrustMode, declared: TrustMode) -> TrustMode {
TrustMode::clamp(parent_runtime, declared)
}
impl From<u8> for TrustMode {
fn from(v: u8) -> Self {
match v {
0 => Self::Plan,
1 => Self::Safe,
2 => Self::Auto,
_ => Self::Safe,
}
}
}
impl std::fmt::Display for TrustMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
pub type SharedTrustMode = Arc<AtomicU8>;
pub fn new_shared_trust(mode: TrustMode) -> SharedTrustMode {
Arc::new(AtomicU8::new(mode as u8))
}
pub fn read_trust(shared: &SharedTrustMode) -> TrustMode {
TrustMode::from(shared.load(Ordering::Relaxed))
}
pub fn set_trust(shared: &SharedTrustMode, mode: TrustMode) {
shared.store(mode as u8, Ordering::Relaxed);
}
pub fn cycle_trust(shared: &SharedTrustMode) -> TrustMode {
let current = read_trust(shared);
let next = current.next();
set_trust(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: TrustMode,
project_root: Option<&Path>,
) -> ToolApproval {
check_tool_inner(tool_name, args, mode, project_root, None, false)
}
pub fn check_tool_with_tracker(
tool_name: &str,
args: &serde_json::Value,
mode: TrustMode,
project_root: Option<&Path>,
file_tracker: Option<&FileTracker>,
) -> ToolApproval {
check_tool_inner(tool_name, args, mode, project_root, file_tracker, false)
}
pub fn check_tool_for_sub_agent(
tool_name: &str,
args: &serde_json::Value,
mode: TrustMode,
project_root: Option<&Path>,
) -> ToolApproval {
check_tool_inner(tool_name, args, mode, project_root, None, true)
}
pub fn check_tool_for_sub_agent_with_tracker(
tool_name: &str,
args: &serde_json::Value,
mode: TrustMode,
project_root: Option<&Path>,
file_tracker: Option<&FileTracker>,
) -> ToolApproval {
check_tool_inner(tool_name, args, mode, project_root, file_tracker, true)
}
fn check_tool_inner(
tool_name: &str,
args: &serde_json::Value,
mode: TrustMode,
project_root: Option<&Path>,
file_tracker: Option<&FileTracker>,
is_sub_agent: bool,
) -> ToolApproval {
let effect = resolve_tool_effect(tool_name, args);
if effect == ToolEffect::ReadOnly {
return ToolApproval::AutoApprove;
}
if mode == TrustMode::Plan {
return ToolApproval::Blocked;
}
if let Some(root) = project_root {
if is_outside_project(tool_name, args, root) {
return resolve_confirmation(effect, is_sub_agent);
}
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 resolve_confirmation(effect, is_sub_agent);
}
}
}
if tool_name == "Delete"
&& let Some(tracker) = file_tracker
&& let Some(root) = project_root
&& let Some(abs_path) = crate::file_tracker::resolve_file_path_from_args(args, root)
&& tracker.is_owned(&abs_path)
{
return ToolApproval::AutoApprove;
}
let raw_decision = match mode {
TrustMode::Plan => unreachable!(), TrustMode::Safe => match effect {
ToolEffect::ReadOnly => ToolApproval::AutoApprove,
ToolEffect::RemoteAction | ToolEffect::LocalMutation | ToolEffect::Destructive => {
ToolApproval::NeedsConfirmation
}
},
TrustMode::Auto => match effect {
ToolEffect::ReadOnly => ToolApproval::AutoApprove,
ToolEffect::Destructive => {
ToolApproval::NeedsConfirmation
}
ToolEffect::RemoteAction | ToolEffect::LocalMutation => {
if crate::sandbox::is_available() {
ToolApproval::AutoApprove
} else {
ToolApproval::NeedsConfirmation
}
}
},
};
if is_sub_agent && raw_decision == ToolApproval::NeedsConfirmation {
resolve_confirmation(effect, true)
} else {
raw_decision
}
}
fn resolve_confirmation(effect: ToolEffect, is_sub_agent: bool) -> ToolApproval {
if !is_sub_agent {
return ToolApproval::NeedsConfirmation;
}
match effect {
ToolEffect::Destructive => ToolApproval::Blocked,
_ => ToolApproval::AutoApprove,
}
}
pub fn resolve_tool_effect(tool_name: &str, args: &serde_json::Value) -> ToolEffect {
resolve_tool_effect_inner(tool_name, args, None)
}
pub fn resolve_tool_effect_with_registry(
tool_name: &str,
args: &serde_json::Value,
registry: &crate::tools::ToolRegistry,
) -> ToolEffect {
resolve_tool_effect_inner(tool_name, args, Some(registry))
}
fn resolve_tool_effect_inner(
tool_name: &str,
args: &serde_json::Value,
registry: Option<&crate::tools::ToolRegistry>,
) -> ToolEffect {
match registry {
Some(reg) => reg.catalog().classify_call(tool_name, args),
None => crate::tools::ToolCatalog::default_static().classify_call(tool_name, args),
}
}
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());
let outside = !resolved.starts_with(&canon_root);
if outside && crate::bash_path_lint::is_safe_external_path(&resolved) {
return false;
}
outside
}
None => false,
}
}
pub use crate::last_provider::LastProvider;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mode_cycle() {
assert_eq!(TrustMode::Safe.next(), TrustMode::Auto);
assert_eq!(TrustMode::Auto.next(), TrustMode::Safe);
assert_eq!(TrustMode::Plan.next(), TrustMode::Plan); }
#[test]
fn test_mode_ordering() {
assert!(TrustMode::Plan < TrustMode::Safe);
assert!(TrustMode::Safe < TrustMode::Auto);
}
#[test]
fn test_clamp() {
assert_eq!(
TrustMode::clamp(TrustMode::Plan, TrustMode::Auto),
TrustMode::Plan
);
assert_eq!(
TrustMode::clamp(TrustMode::Safe, TrustMode::Auto),
TrustMode::Safe
);
assert_eq!(
TrustMode::clamp(TrustMode::Auto, TrustMode::Safe),
TrustMode::Safe
);
assert_eq!(
TrustMode::clamp(TrustMode::Auto, TrustMode::Auto),
TrustMode::Auto
);
}
#[test]
fn derive_child_trust_fork_identity() {
for parent in [TrustMode::Plan, TrustMode::Safe, TrustMode::Auto] {
assert_eq!(
derive_child_trust(parent, parent),
parent,
"fork (parent==declared) must return parent verbatim"
);
}
}
#[test]
fn derive_child_trust_named_clamps_down() {
assert_eq!(
derive_child_trust(TrustMode::Safe, TrustMode::Auto),
TrustMode::Safe
);
assert_eq!(
derive_child_trust(TrustMode::Plan, TrustMode::Auto),
TrustMode::Plan
);
assert_eq!(
derive_child_trust(TrustMode::Plan, TrustMode::Safe),
TrustMode::Plan
);
}
#[test]
fn derive_child_trust_child_already_stricter_passes_through() {
assert_eq!(
derive_child_trust(TrustMode::Auto, TrustMode::Plan),
TrustMode::Plan
);
assert_eq!(
derive_child_trust(TrustMode::Auto, TrustMode::Safe),
TrustMode::Safe
);
assert_eq!(
derive_child_trust(TrustMode::Safe, TrustMode::Plan),
TrustMode::Plan
);
}
#[test]
fn derive_child_trust_is_commutative_in_min_but_not_in_meaning() {
for a in [TrustMode::Plan, TrustMode::Safe, TrustMode::Auto] {
for b in [TrustMode::Plan, TrustMode::Safe, TrustMode::Auto] {
assert_eq!(derive_child_trust(a, b), derive_child_trust(b, a));
}
}
}
#[test]
fn test_mode_from_str() {
assert_eq!(TrustMode::parse("auto"), Some(TrustMode::Auto));
assert_eq!(TrustMode::parse("safe"), Some(TrustMode::Safe));
assert_eq!(TrustMode::parse("plan"), Some(TrustMode::Plan));
assert_eq!(TrustMode::parse("yolo"), Some(TrustMode::Auto));
assert_eq!(TrustMode::parse("confirm"), Some(TrustMode::Safe));
assert_eq!(TrustMode::parse("strict"), Some(TrustMode::Safe));
assert_eq!(TrustMode::parse("normal"), Some(TrustMode::Safe));
assert_eq!(TrustMode::parse("readonly"), Some(TrustMode::Plan));
assert_eq!(TrustMode::parse("read-only"), Some(TrustMode::Plan));
assert_eq!(TrustMode::parse("accept"), Some(TrustMode::Auto));
assert_eq!(TrustMode::parse("nope"), None);
}
#[test]
fn test_mode_from_u8() {
assert_eq!(TrustMode::from(0), TrustMode::Plan);
assert_eq!(TrustMode::from(1), TrustMode::Safe);
assert_eq!(TrustMode::from(2), TrustMode::Auto);
assert_eq!(TrustMode::from(99), TrustMode::Safe); }
#[test]
fn test_shared_trust_cycle() {
let shared = new_shared_trust(TrustMode::Safe);
assert_eq!(read_trust(&shared), TrustMode::Safe);
let next = cycle_trust(&shared);
assert_eq!(next, TrustMode::Auto);
assert_eq!(read_trust(&shared), TrustMode::Auto);
}
#[test]
fn test_display() {
assert_eq!(TrustMode::Plan.to_string(), "plan");
assert_eq!(TrustMode::Safe.to_string(), "safe");
assert_eq!(TrustMode::Auto.to_string(), "auto");
}
#[test]
fn test_default() {
assert_eq!(TrustMode::default(), TrustMode::Safe);
}
const READ_ONLY_TOOLS: &[&str] = &[
"Read",
"List",
"Grep",
"Glob",
"MemoryRead",
"ListAgents",
"InvokeAgent",
"WebFetch",
"WebSearch",
"ListSkills",
"ActivateSkill",
"TodoWrite",
];
#[test]
fn test_read_tools_always_approved() {
for tool in READ_ONLY_TOOLS {
for mode in [TrustMode::Plan, TrustMode::Safe, TrustMode::Auto] {
assert_eq!(
check_tool(tool, &serde_json::json!({}), mode, None),
ToolApproval::AutoApprove,
"{tool} should auto-approve in {mode:?}"
);
}
}
}
#[test]
fn test_plan_blocks_all_writes() {
for tool in ["Write", "Edit", "Delete", "MemoryWrite"] {
assert_eq!(
check_tool(tool, &serde_json::json!({}), TrustMode::Plan, None),
ToolApproval::Blocked,
"{tool} should be blocked in Plan mode"
);
}
}
#[test]
fn test_safe_confirms_writes() {
for tool in ["Write", "Edit", "Delete", "MemoryWrite"] {
assert_eq!(
check_tool(tool, &serde_json::json!({}), TrustMode::Safe, None),
ToolApproval::NeedsConfirmation,
"{tool} should need confirmation in Safe mode"
);
}
}
#[test]
fn test_auto_approves_non_outside() {
let expected = if crate::sandbox::is_available() {
ToolApproval::AutoApprove
} else {
ToolApproval::NeedsConfirmation
};
for tool in ["Write", "Edit"] {
assert_eq!(
check_tool(tool, &serde_json::json!({}), TrustMode::Auto, None),
expected,
"{tool} in Auto mode"
);
}
assert_eq!(
check_tool("WebFetch", &serde_json::json!({}), TrustMode::Auto, None),
ToolApproval::AutoApprove,
);
}
#[test]
fn test_auto_destructive_needs_confirmation() {
assert_eq!(
check_tool("Delete", &serde_json::json!({}), TrustMode::Auto, None),
ToolApproval::NeedsConfirmation,
);
}
#[test]
fn test_auto_local_mutation_auto_approved_with_sandbox() {
let expected = if crate::sandbox::is_available() {
ToolApproval::AutoApprove
} else {
ToolApproval::NeedsConfirmation
};
assert_eq!(
check_tool(
"Write",
&serde_json::json!({"path": "foo.rs", "content": "x"}),
TrustMode::Auto,
None
),
expected,
);
}
#[test]
fn test_safe_bash_read_only_auto_approved() {
let args = serde_json::json!({"command": "git status"});
assert_eq!(
check_tool("Bash", &args, TrustMode::Safe, None),
ToolApproval::AutoApprove,
);
}
#[test]
fn test_gh_read_only_auto_approved() {
for cmd in [
"gh issue view 42",
"gh pr view 99",
"gh pr list",
"gh issue list",
] {
let args = serde_json::json!({"command": cmd});
assert_eq!(
check_tool("Bash", &args, TrustMode::Safe, None),
ToolApproval::AutoApprove,
"{cmd} should auto-approve even in Safe mode"
);
}
}
#[test]
fn test_gh_destructive_needs_confirmation_in_safe() {
for cmd in [
"gh pr merge 42 --squash",
"gh issue delete 42",
"gh repo delete owner/repo",
] {
let args = serde_json::json!({"command": cmd});
assert_eq!(
check_tool("Bash", &args, TrustMode::Safe, None),
ToolApproval::NeedsConfirmation,
"{cmd} should need confirmation in Safe mode"
);
}
}
#[test]
fn test_gh_mutation_auto_approved_in_auto() {
for cmd in [
"gh issue create --title 'bug'",
"gh issue edit 42",
"gh pr create",
] {
let args = serde_json::json!({"command": cmd});
assert_eq!(
check_tool("Bash", &args, TrustMode::Auto, None),
ToolApproval::AutoApprove,
"{cmd} should auto-approve in Auto mode"
);
assert_eq!(
check_tool("Bash", &args, TrustMode::Safe, None),
ToolApproval::NeedsConfirmation,
"{cmd} should need confirmation in Safe mode"
);
}
}
#[test]
fn test_dev_workflow_bash_needs_confirmation_in_safe() {
let args = serde_json::json!({"command": "cargo test --release"});
assert_eq!(
check_tool("Bash", &args, TrustMode::Safe, None),
ToolApproval::NeedsConfirmation,
);
}
#[test]
fn test_dangerous_bash_needs_confirmation_in_safe() {
let args = serde_json::json!({"command": "rm -rf target/"});
assert_eq!(
check_tool("Bash", &args, TrustMode::Safe, None),
ToolApproval::NeedsConfirmation,
);
}
#[test]
fn test_plan_blocks_bash() {
let args = serde_json::json!({"command": "cargo test"});
assert_eq!(
check_tool("Bash", &args, TrustMode::Plan, None),
ToolApproval::Blocked,
);
}
#[test]
fn test_invoke_agent_auto_approved() {
let args = serde_json::json!({"agent_name": "reviewer", "prompt": "review this"});
for mode in [TrustMode::Plan, TrustMode::Safe, TrustMode::Auto] {
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, TrustMode::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, TrustMode::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, TrustMode::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 /etc && ls"});
assert_eq!(
check_tool("Bash", &args, TrustMode::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, TrustMode::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, TrustMode::Auto, None),
ToolApproval::AutoApprove,
);
}
#[test]
fn test_write_to_tmp_auto_approved() {
let root = Path::new("/home/user/project");
let args = serde_json::json!({"path": "/tmp/issue-draft.md"});
assert_eq!(
check_tool("Write", &args, TrustMode::Auto, Some(root)),
ToolApproval::AutoApprove,
"/tmp writes should auto-approve"
);
}
#[test]
fn test_bash_cd_tmp_auto_approved() {
let root = Path::new("/home/user/project");
let args = serde_json::json!({"command": "cd /tmp && ls"});
assert_eq!(
check_tool("Bash", &args, TrustMode::Auto, Some(root)),
ToolApproval::AutoApprove,
"cd /tmp should auto-approve"
);
}
#[test]
fn test_write_to_var_folders_auto_approved() {
let root = Path::new("/home/user/project");
let args = serde_json::json!({"path": "/var/folders/xx/yy/T/scratch.md"});
assert_eq!(
check_tool("Write", &args, TrustMode::Auto, Some(root)),
ToolApproval::AutoApprove,
"/var/folders/* writes should auto-approve in Auto (#1232 §8b) \
— it's macOS's scratch root, env-independent"
);
}
#[test]
fn test_write_to_koda_cache_auto_approved() {
let root = Path::new("/home/user/project");
let home = std::env::var("HOME").expect("HOME must be set in test env");
let path = format!("{home}/.cache/koda/test-scratch.json");
let args = serde_json::json!({ "path": path });
assert_eq!(
check_tool("Write", &args, TrustMode::Auto, Some(root)),
ToolApproval::AutoApprove,
"~/.cache/koda/ writes should auto-approve in Auto (#1232 §8b)"
);
}
#[test]
fn test_write_to_etc_still_blocked() {
let root = Path::new("/home/user/project");
let args = serde_json::json!({"path": "/etc/hosts"});
assert_eq!(
check_tool("Write", &args, TrustMode::Auto, Some(root)),
ToolApproval::NeedsConfirmation,
"/etc writes should still need confirmation"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_delete_owned_file_auto_approved() {
let dir = tempfile::TempDir::new().unwrap();
let db = crate::db::Database::open(&dir.path().join("test.db"))
.await
.unwrap();
let mut tracker = FileTracker::new("test-sess", db).await;
let root = Path::new("/home/user/project");
let owned_path = root.join("temp_output.md");
tracker.track_created(owned_path).await;
let args = serde_json::json!({"path": "temp_output.md"});
assert_eq!(
check_tool_with_tracker("Delete", &args, TrustMode::Auto, Some(root), Some(&tracker),),
ToolApproval::AutoApprove,
"Delete of Koda-owned file should auto-approve"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_delete_unowned_file_needs_confirmation_in_safe() {
let dir = tempfile::TempDir::new().unwrap();
let db = crate::db::Database::open(&dir.path().join("test.db"))
.await
.unwrap();
let tracker = FileTracker::new("test-sess", db).await;
let root = Path::new("/home/user/project");
let args = serde_json::json!({"path": "user_file.rs"});
assert_eq!(
check_tool_with_tracker("Delete", &args, TrustMode::Safe, Some(root), Some(&tracker),),
ToolApproval::NeedsConfirmation,
"Delete of unowned file should need confirmation in Safe mode"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_delete_owned_file_safe_mode_auto_approved() {
let dir = tempfile::TempDir::new().unwrap();
let db = crate::db::Database::open(&dir.path().join("test.db"))
.await
.unwrap();
let mut tracker = FileTracker::new("test-sess", db).await;
let root = Path::new("/home/user/project");
let owned_path = root.join("scratch.txt");
tracker.track_created(owned_path).await;
let args = serde_json::json!({"path": "scratch.txt"});
assert_eq!(
check_tool_with_tracker("Delete", &args, TrustMode::Safe, Some(root), Some(&tracker),),
ToolApproval::AutoApprove,
"Delete of Koda-owned file should auto-approve even in Safe mode"
);
}
#[test]
fn test_no_tracker_safe_mode_delete_needs_confirmation() {
let root = Path::new("/home/user/project");
let args = serde_json::json!({"path": "some_file.rs"});
assert_eq!(
check_tool_with_tracker("Delete", &args, TrustMode::Safe, Some(root), None),
ToolApproval::NeedsConfirmation,
"Without tracker, Delete should need confirmation in Safe"
);
}
#[test]
fn coerce_top_level_plan_falls_back_to_safe_with_warning() {
let (coerced, warning) = coerce_for_top_level(TrustMode::Plan);
assert_eq!(coerced, TrustMode::Safe, "Plan must coerce to Safe");
assert!(
warning.is_some(),
"Plan coercion must yield a warning so the user sees what happened"
);
let msg = warning.unwrap();
assert!(
msg.contains("Plan") && msg.contains("Safe"),
"warning must mention both Plan and Safe so the user knows what changed; got: {msg}"
);
}
#[test]
fn coerce_top_level_safe_passes_through_unchanged_no_warning() {
let (coerced, warning) = coerce_for_top_level(TrustMode::Safe);
assert_eq!(coerced, TrustMode::Safe);
assert!(
warning.is_none(),
"Safe must not warn (it's the coerce target, not the source)"
);
}
#[test]
fn coerce_top_level_auto_passes_through_unchanged_no_warning() {
let (coerced, warning) = coerce_for_top_level(TrustMode::Auto);
assert_eq!(coerced, TrustMode::Auto);
assert!(
warning.is_none(),
"Auto is a valid top-level mode; no warning"
);
}
#[test]
fn coerce_top_level_is_idempotent() {
for mode in [TrustMode::Plan, TrustMode::Safe, TrustMode::Auto] {
let (first, _) = coerce_for_top_level(mode);
let (second, second_warn) = coerce_for_top_level(first);
assert_eq!(
first, second,
"coerce must be idempotent for input {mode:?}"
);
assert!(
second_warn.is_none(),
"coerce of an already-coerced value must not warn (input {mode:?})"
);
}
}
#[test]
fn coerce_top_level_does_not_affect_derive_child_trust() {
let child_inherits = derive_child_trust(TrustMode::Plan, TrustMode::Auto);
assert_eq!(
child_inherits,
TrustMode::Plan,
"sub-agent must keep Plan; coercion is top-level-only"
);
}
#[test]
fn require_sandbox_auto_with_sandbox_passes_through() {
let result = require_sandbox_for_auto(TrustMode::Auto, true);
assert_eq!(result.unwrap(), TrustMode::Auto);
}
#[test]
fn require_sandbox_auto_without_sandbox_errors_with_actionable_message() {
let err = require_sandbox_for_auto(TrustMode::Auto, false).unwrap_err();
assert!(
err.contains("sandbox"),
"error must mention sandbox; got: {err}"
);
assert!(
err.contains("--mode safe"),
"error must point at --mode safe as the escape hatch; got: {err}"
);
}
#[test]
fn require_sandbox_safe_passes_regardless_of_sandbox() {
assert_eq!(
require_sandbox_for_auto(TrustMode::Safe, true).unwrap(),
TrustMode::Safe
);
assert_eq!(
require_sandbox_for_auto(TrustMode::Safe, false).unwrap(),
TrustMode::Safe
);
}
#[test]
fn require_sandbox_plan_passes_regardless_of_sandbox() {
assert_eq!(
require_sandbox_for_auto(TrustMode::Plan, true).unwrap(),
TrustMode::Plan
);
assert_eq!(
require_sandbox_for_auto(TrustMode::Plan, false).unwrap(),
TrustMode::Plan
);
}
#[test]
fn derive_default_trust_picks_strongest_supported_mode() {
assert_eq!(derive_default_trust(true), TrustMode::Auto);
assert_eq!(derive_default_trust(false), TrustMode::Safe);
}
#[test]
fn require_sandbox_does_not_silently_coerce_to_safe() {
let result = require_sandbox_for_auto(TrustMode::Auto, false);
assert!(
result.is_err(),
"Auto + unavailable must Err; silent coerce-to-Safe is the bug we're preventing. Got: {result:?}"
);
}
#[test]
fn sub_agent_plan_blocks_writes() {
for tool in ["Write", "Edit", "Delete", "Bash"] {
let args = if tool == "Bash" {
serde_json::json!({"command": "cargo build"})
} else {
serde_json::json!({"path": "foo.rs", "content": "x"})
};
assert_eq!(
check_tool_for_sub_agent(tool, &args, TrustMode::Plan, None),
ToolApproval::Blocked,
"sub-agent at Plan must block {tool}"
);
}
}
#[test]
fn sub_agent_plan_allows_reads() {
for tool in ["Read", "Grep", "Glob"] {
assert_eq!(
check_tool_for_sub_agent(tool, &serde_json::json!({}), TrustMode::Plan, None),
ToolApproval::AutoApprove,
"sub-agent at Plan must allow read tool {tool}"
);
}
assert_eq!(
check_tool_for_sub_agent(
"Bash",
&serde_json::json!({"command": "git status"}),
TrustMode::Plan,
None
),
ToolApproval::AutoApprove,
"sub-agent at Plan must allow read-only Bash"
);
}
#[test]
fn sub_agent_safe_auto_approves_writes() {
let args = serde_json::json!({"path": "foo.rs", "content": "x"});
for tool in ["Write", "Edit"] {
assert_eq!(
check_tool_for_sub_agent(tool, &args, TrustMode::Safe, None),
ToolApproval::AutoApprove,
"sub-agent at Safe must auto-approve {tool} (was the #1250 bug)"
);
}
}
#[test]
fn sub_agent_safe_auto_approves_mutating_bash() {
for cmd in ["cargo build", "npm install", "pytest", "git commit -m wip"] {
let args = serde_json::json!({"command": cmd});
assert_eq!(
check_tool_for_sub_agent("Bash", &args, TrustMode::Safe, None),
ToolApproval::AutoApprove,
"sub-agent at Safe must auto-approve mutating Bash: {cmd}"
);
}
}
#[test]
fn sub_agent_safe_blocks_destructive_bash() {
for cmd in [
"rm -rf /",
"git reset --hard origin/main",
"git push --force",
] {
let args = serde_json::json!({"command": cmd});
assert_eq!(
check_tool_for_sub_agent("Bash", &args, TrustMode::Safe, None),
ToolApproval::Blocked,
"sub-agent at Safe must BLOCK destructive Bash: {cmd}"
);
}
}
#[test]
fn sub_agent_auto_auto_approves_writes() {
let args = serde_json::json!({"path": "foo.rs", "content": "x"});
for tool in ["Write", "Edit"] {
assert_eq!(
check_tool_for_sub_agent(tool, &args, TrustMode::Auto, None),
ToolApproval::AutoApprove,
"sub-agent at Auto must auto-approve {tool}"
);
}
}
#[test]
fn sub_agent_auto_blocks_destructive() {
for cmd in [
"rm -rf /",
"git reset --hard origin/main",
"git push --force",
] {
let args = serde_json::json!({"command": cmd});
assert_eq!(
check_tool_for_sub_agent("Bash", &args, TrustMode::Auto, None),
ToolApproval::Blocked,
"sub-agent at Auto must STILL BLOCK destructive Bash: {cmd}"
);
}
}
#[test]
fn sub_agent_safe_auto_approves_remote_action() {
for cmd in ["curl https://example.com", "gh pr create -t x -b y"] {
let args = serde_json::json!({"command": cmd});
assert_eq!(
check_tool_for_sub_agent("Bash", &args, TrustMode::Safe, None),
ToolApproval::AutoApprove,
"sub-agent at Safe must auto-approve remote action: {cmd}"
);
}
}
#[test]
fn top_level_vs_sub_agent_safe_write_diverges() {
let args = serde_json::json!({"path": "foo.rs", "content": "x"});
assert_eq!(
check_tool("Write", &args, TrustMode::Safe, None),
ToolApproval::NeedsConfirmation,
"top-level Safe × Write must prompt human"
);
assert_eq!(
check_tool_for_sub_agent("Write", &args, TrustMode::Safe, None),
ToolApproval::AutoApprove,
"sub-agent Safe × Write must auto-approve (no human to ask)"
);
}
#[test]
fn top_level_vs_sub_agent_auto_destructive_diverges() {
let args = serde_json::json!({"command": "rm -rf /"});
assert_eq!(
check_tool("Bash", &args, TrustMode::Auto, None),
ToolApproval::NeedsConfirmation,
"top-level Auto × destructive Bash must prompt human (#1250 tightening)"
);
assert_eq!(
check_tool_for_sub_agent("Bash", &args, TrustMode::Auto, None),
ToolApproval::Blocked,
"sub-agent Auto × destructive Bash must BLOCK (no human to ask)"
);
}
#[test]
fn sub_agent_read_tools_always_approved() {
for mode in [TrustMode::Plan, TrustMode::Safe, TrustMode::Auto] {
for tool in ["Read", "Grep", "Glob"] {
assert_eq!(
check_tool_for_sub_agent(tool, &serde_json::json!({}), mode, None),
ToolApproval::AutoApprove,
"sub-agent {mode:?} must allow read tool {tool}"
);
}
}
}
}