Skip to main content

koda_core/
trust.rs

1//! Unified trust mode — the single permission knob for Koda.
2//!
3//! Replaces the old `ApprovalMode × SandboxMode` two-layer system with one
4//! enum that controls both sandbox configuration and approval behavior.
5//! Users see one concept, one CLI flag (`--mode`), one status bar word.
6//!
7//! ## Trust modes
8//!
9//! | Mode | Sandbox | Approval | Use case |
10//! |------|---------|----------|----------|
11//! | **Plan** | project, read-only | deny all non-read | Investigation agents |
12//! | **Safe** | project, read+write | confirm side effects | User default |
13//! | **Auto** | project, read+write | auto-approve all | Autonomous coding |
14//!
15//! ## Behavior matrix
16//!
17//! | Behavior | Plan | Safe | Auto |
18//! |---|---|---|---|
19//! | ReadOnly tools | ✅ auto | ✅ auto | ✅ auto |
20//! | RemoteAction | ❌ deny | ⚠️ confirm | ✅ auto |
21//! | LocalMutation | ❌ deny | ⚠️ confirm | ✅ auto |
22//! | Destructive | ❌ deny | ⚠️ confirm | ✅ auto |
23//! | Outside project | ❌ deny | ⚠️ confirm | ⚠️ confirm |
24//!
25//! ## Design principle
26//!
27//! The sandbox is the safety boundary, not the approval prompt.
28//! Auto trusts the agent within the project sandbox — the kernel enforces the
29//! perimeter. Safe adds approval as a second layer (belt and suspenders).
30//! Credential dirs are always blocked regardless of mode.
31
32use crate::bash_safety::classify_bash_command;
33use crate::file_tracker::FileTracker;
34use crate::tools::ToolEffect;
35use path_clean::PathClean;
36use std::path::Path;
37use std::sync::Arc;
38use std::sync::atomic::{AtomicU8, Ordering};
39
40// ── TrustMode ─────────────────────────────────────────────
41
42/// The unified trust mode: Plan (read-only), Safe (confirm), Auto (autonomous).
43///
44/// Derives `Ord` so that `std::cmp::min(parent, child)` implements clamping:
45/// a child agent can never exceed its parent's trust level.
46///
47/// # Examples
48///
49/// ```
50/// use koda_core::trust::TrustMode;
51///
52/// let mode = TrustMode::Safe;
53/// assert_eq!(mode.as_str(), "safe");
54/// assert_eq!(mode.next(), TrustMode::Auto);
55///
56/// // Clamping: child can't exceed parent
57/// assert_eq!(TrustMode::clamp(TrustMode::Plan, TrustMode::Auto), TrustMode::Plan);
58/// ```
59#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
60#[repr(u8)]
61pub enum TrustMode {
62    /// Read-only: deny all non-read tool calls. For investigation agents.
63    Plan = 0,
64    /// Supervised: project-sandboxed writes, confirm all side effects. User default.
65    #[default]
66    Safe = 1,
67    /// Autonomous: project-sandboxed writes, auto-approve all including destructive.
68    Auto = 2,
69}
70
71impl TrustMode {
72    /// Cycle between user-facing modes: Safe ↔ Auto.
73    ///
74    /// Plan is agent-only and never toggled by the user.
75    pub fn next(self) -> Self {
76        match self {
77            Self::Plan => Self::Plan, // agent-only, no toggle
78            Self::Safe => Self::Auto,
79            Self::Auto => Self::Safe,
80        }
81    }
82
83    /// Stable string representation for persistence and wire protocol.
84    pub fn as_str(self) -> &'static str {
85        match self {
86            Self::Plan => "plan",
87            Self::Safe => "safe",
88            Self::Auto => "auto",
89        }
90    }
91
92    /// Short label for display (same as `as_str` for now).
93    pub fn label(self) -> &'static str {
94        self.as_str()
95    }
96
97    /// Human-readable description of this mode.
98    pub fn description(self) -> &'static str {
99        match self {
100            Self::Plan => "read-only, deny all writes",
101            Self::Safe => "confirm every side effect",
102            Self::Auto => "auto-approve, confirm outside-project only",
103        }
104    }
105
106    /// Parse a trust mode from a user-provided or config string.
107    pub fn parse(s: &str) -> Option<Self> {
108        match s.to_lowercase().as_str() {
109            "auto" | "yolo" | "accept" => Some(Self::Auto),
110            "safe" | "confirm" | "strict" | "normal" => Some(Self::Safe),
111            "plan" | "readonly" | "read-only" => Some(Self::Plan),
112            _ => None,
113        }
114    }
115
116    /// Clamp a child's trust mode to never exceed the parent's.
117    ///
118    /// Since `TrustMode` derives `Ord` with `Plan < Safe < Auto`,
119    /// this is simply `std::cmp::min(parent, child)`.
120    pub fn clamp(parent: TrustMode, child: TrustMode) -> TrustMode {
121        std::cmp::min(parent, child)
122    }
123}
124
125// ── Child trust derivation (#1022 B19) ─────────────────────────────────
126
127/// The single, authoritative way to compute a child agent's trust mode.
128///
129/// **Per DESIGN.md § "Trust never widens"**: this helper is the *only*
130/// way fork, named, and bg sub-agent dispatch paths derive child
131/// trust. Three call sites converging on one function ensures the
132/// invariant cannot drift between paths.
133///
134/// # `parent_runtime` MUST be the runtime mode
135///
136/// **#1022 B19**: pre-fix, `sub_agent_dispatch` clamped against
137/// `parent_config.trust` — the *startup* value of the trust mode.
138/// `cycle_trust`/`set_trust` mutate the `SharedTrustMode` atomic but
139/// **never** the `KodaConfig.trust` field. So a user who started in
140/// `Auto` and hit `/safe` would still get sub-agents clamped against
141/// the stale `Auto`, allowing the child to run with broader
142/// privileges than the parent's *current* mode. Real escalation.
143///
144/// The runtime trust mode is the `mode: TrustMode` parameter
145/// threaded through `execute_one_tool` → `execute_sub_agent`. That
146/// value is read from the `SharedTrustMode` atomic at the start of
147/// each turn and is the only source of truth for "what trust level
148/// is this turn running at".
149///
150/// Passing `parent_config.trust` here is the antipattern this helper
151/// exists to prevent.
152///
153/// # `declared` is the child's own declaration
154///
155/// For named sub-agents this is `cfg.trust` loaded from the agent's
156/// JSON. For `fork` (which has no separate declaration) the call
157/// site passes `parent_runtime` again — the helper then collapses
158/// to identity, but the symmetry across all three paths is what
159/// makes the invariant easy to audit.
160///
161/// # Examples
162///
163/// ```
164/// use koda_core::trust::{derive_child_trust, TrustMode};
165///
166/// // Named child declares Auto, parent runtime is Safe → child
167/// // clamps down to Safe (declared narrows toward parent).
168/// assert_eq!(
169///     derive_child_trust(TrustMode::Safe, TrustMode::Auto),
170///     TrustMode::Safe,
171/// );
172///
173/// // Named child declares Plan, parent runtime is Auto → child
174/// // stays Plan (declared is already stricter).
175/// assert_eq!(
176///     derive_child_trust(TrustMode::Auto, TrustMode::Plan),
177///     TrustMode::Plan,
178/// );
179///
180/// // Fork case: declared = parent_runtime → identity.
181/// assert_eq!(
182///     derive_child_trust(TrustMode::Safe, TrustMode::Safe),
183///     TrustMode::Safe,
184/// );
185/// ```
186pub fn derive_child_trust(parent_runtime: TrustMode, declared: TrustMode) -> TrustMode {
187    // Implementation is `clamp` — the named function exists to make
188    // "trust derivation" greppable and to carry the documentation
189    // about which `parent` to pass. **Do not inline back into
190    // `TrustMode::clamp` at call sites** — that re-opens the door
191    // for `parent_config.trust` to creep back in.
192    TrustMode::clamp(parent_runtime, declared)
193}
194
195impl From<u8> for TrustMode {
196    fn from(v: u8) -> Self {
197        match v {
198            0 => Self::Plan,
199            1 => Self::Safe,
200            2 => Self::Auto,
201            // Fail-safe: unknown values default to Safe (most restrictive
202            // interactive mode) rather than Auto (#860).
203            _ => Self::Safe,
204        }
205    }
206}
207
208impl std::fmt::Display for TrustMode {
209    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
210        f.write_str(self.as_str())
211    }
212}
213
214// ── Shared trust state ────────────────────────────────────
215
216/// Thread-safe shared trust mode, readable from prompt formatter and input handlers.
217pub type SharedTrustMode = Arc<AtomicU8>;
218
219/// Create a new atomic shared trust mode initialized to `mode`.
220pub fn new_shared_trust(mode: TrustMode) -> SharedTrustMode {
221    Arc::new(AtomicU8::new(mode as u8))
222}
223
224/// Read the current trust mode from shared state.
225pub fn read_trust(shared: &SharedTrustMode) -> TrustMode {
226    TrustMode::from(shared.load(Ordering::Relaxed))
227}
228
229/// Atomically set the trust mode.
230pub fn set_trust(shared: &SharedTrustMode, mode: TrustMode) {
231    shared.store(mode as u8, Ordering::Relaxed);
232}
233
234/// Cycle to the next trust mode (Safe ↔ Auto) and return it.
235pub fn cycle_trust(shared: &SharedTrustMode) -> TrustMode {
236    let current = read_trust(shared);
237    let next = current.next();
238    set_trust(shared, next);
239    next
240}
241
242// ── Tool Approval Decision ──────────────────────────────────
243
244/// What the trust system decides for a given tool call.
245#[derive(Debug, Clone, PartialEq)]
246pub enum ToolApproval {
247    /// Execute without asking.
248    AutoApprove,
249    /// Show confirmation dialog.
250    NeedsConfirmation,
251    /// Blocked (Plan mode or delegation scope violation).
252    Blocked,
253}
254
255/// Decide whether a tool call should be auto-approved, confirmed, or blocked.
256///
257/// Decision matrix:
258///
259/// | ToolEffect     | Plan    | Safe          | Auto          |
260/// |----------------|---------|---------------|---------------|
261/// | ReadOnly       | ✅ auto  | ✅ auto        | ✅ auto        |
262/// | RemoteAction   | ❌ block | ⚠️ confirm     | ✅ auto        |
263/// | LocalMutation  | ❌ block | ⚠️ confirm     | ✅ auto        |
264/// | Destructive    | ❌ block | ⚠️ confirm     | ✅ auto        |
265///
266/// Additional hardcoded floors:
267/// - Writes outside project root → NeedsConfirmation (even in Auto) (#218)
268/// - Bash path escapes → NeedsConfirmation
269/// - Delete of Koda-owned file → AutoApprove (#465)
270pub fn check_tool(
271    tool_name: &str,
272    args: &serde_json::Value,
273    mode: TrustMode,
274    project_root: Option<&Path>,
275) -> ToolApproval {
276    check_tool_with_tracker(tool_name, args, mode, project_root, None)
277}
278
279/// Like [`check_tool`] but with an optional file tracker for ownership checks.
280///
281/// When a `FileTracker` is provided and the tool is `Delete` targeting a file
282/// that Koda created in this session, the destructive classification is
283/// downgraded to auto-approve (net-zero effect: Koda created it, Koda removes it).
284pub fn check_tool_with_tracker(
285    tool_name: &str,
286    args: &serde_json::Value,
287    mode: TrustMode,
288    project_root: Option<&Path>,
289    file_tracker: Option<&FileTracker>,
290) -> ToolApproval {
291    let effect = resolve_tool_effect(tool_name, args);
292
293    // Read-only tools always auto-approve in every mode
294    if effect == ToolEffect::ReadOnly {
295        return ToolApproval::AutoApprove;
296    }
297
298    // Plan mode: deny everything except read-only
299    if mode == TrustMode::Plan {
300        return ToolApproval::Blocked;
301    }
302
303    // Hardcoded floor: writes outside project root always need confirmation (#218)
304    if let Some(root) = project_root {
305        if is_outside_project(tool_name, args, root) {
306            return ToolApproval::NeedsConfirmation;
307        }
308        // Bash path lint: check for cd/path escapes
309        if tool_name == "Bash" {
310            let command = args
311                .get("command")
312                .or(args.get("cmd"))
313                .and_then(|v| v.as_str())
314                .unwrap_or("");
315            let lint = crate::bash_path_lint::lint_bash_paths(command, root);
316            if lint.has_warnings() {
317                return ToolApproval::NeedsConfirmation;
318            }
319        }
320    }
321
322    // File lifecycle: Koda-owned files bypass destructive gate (#465)
323    if tool_name == "Delete"
324        && let Some(tracker) = file_tracker
325        && let Some(root) = project_root
326        && let Some(abs_path) = crate::file_tracker::resolve_file_path_from_args(args, root)
327        && tracker.is_owned(&abs_path)
328    {
329        return ToolApproval::AutoApprove;
330    }
331
332    // Apply the ToolEffect × TrustMode matrix
333    match mode {
334        TrustMode::Plan => unreachable!(), // handled above
335        TrustMode::Safe => match effect {
336            ToolEffect::ReadOnly => ToolApproval::AutoApprove,
337            ToolEffect::RemoteAction | ToolEffect::LocalMutation | ToolEffect::Destructive => {
338                ToolApproval::NeedsConfirmation
339            }
340        },
341        TrustMode::Auto => match effect {
342            ToolEffect::ReadOnly => ToolApproval::AutoApprove,
343            ToolEffect::RemoteAction | ToolEffect::LocalMutation | ToolEffect::Destructive => {
344                // Safety net: if the kernel sandbox is unavailable, Auto mode
345                // loses its perimeter. Downgrade destructive/mutation ops to
346                // NeedsConfirmation so the user still gets a prompt (#860).
347                if crate::sandbox::is_available() {
348                    ToolApproval::AutoApprove
349                } else {
350                    ToolApproval::NeedsConfirmation
351                }
352            }
353        },
354    }
355}
356
357/// Resolve the effective [`ToolEffect`] for a tool call.
358///
359/// For Bash, refines the generic `LocalMutation` classification by
360/// parsing the actual command string.
361///
362/// For MCP tools, falls back to `RemoteAction` unless a ToolRegistry
363/// is provided via [`resolve_tool_effect_with_registry`].
364pub fn resolve_tool_effect(tool_name: &str, args: &serde_json::Value) -> ToolEffect {
365    resolve_tool_effect_inner(tool_name, args, None)
366}
367
368/// Like [`resolve_tool_effect`] but uses the ToolRegistry for MCP-aware
369/// classification (#662).
370pub fn resolve_tool_effect_with_registry(
371    tool_name: &str,
372    args: &serde_json::Value,
373    registry: &crate::tools::ToolRegistry,
374) -> ToolEffect {
375    resolve_tool_effect_inner(tool_name, args, Some(registry))
376}
377
378fn resolve_tool_effect_inner(
379    tool_name: &str,
380    args: &serde_json::Value,
381    registry: Option<&crate::tools::ToolRegistry>,
382) -> ToolEffect {
383    // MCP tools: use registry annotations when available.
384    if crate::mcp::is_mcp_tool_name(tool_name) {
385        if let Some(reg) = registry {
386            return reg.classify_tool_with_mcp(tool_name);
387        }
388        return crate::tools::ToolEffect::RemoteAction;
389    }
390
391    let base = crate::tools::classify_tool(tool_name);
392
393    if tool_name == "Bash" {
394        let command = args
395            .get("command")
396            .or(args.get("cmd"))
397            .and_then(|v| v.as_str())
398            .unwrap_or("");
399        return classify_bash_command(command);
400    }
401
402    base
403}
404
405/// Whether a file tool targets a path outside the project root (#218).
406/// Hardcoded floor: always NeedsConfirmation regardless of mode.
407///
408/// Temp directories (`/tmp`, `$TMPDIR`) are explicitly allowed (#560).
409fn is_outside_project(tool_name: &str, args: &serde_json::Value, project_root: &Path) -> bool {
410    let path_arg = match tool_name {
411        "Write" | "Edit" | "Delete" => args
412            .get("path")
413            .or(args.get("file_path"))
414            .and_then(|v| v.as_str()),
415        _ => None,
416    };
417    match path_arg {
418        Some(p) => {
419            let requested = Path::new(p);
420            let abs_path = if requested.is_absolute() {
421                requested.to_path_buf()
422            } else {
423                project_root.join(requested)
424            };
425            // Canonicalize for symlink resolution (macOS /var → /private/var).
426            // For new files, canonicalize the parent dir and append the filename.
427            let resolved = abs_path.canonicalize().unwrap_or_else(|_| {
428                if let Some(parent) = abs_path.parent()
429                    && let Ok(canon_parent) = parent.canonicalize()
430                    && let Some(name) = abs_path.file_name()
431                {
432                    return canon_parent.join(name);
433                }
434                abs_path.clean()
435            });
436            let canon_root = project_root
437                .canonicalize()
438                .unwrap_or_else(|_| project_root.to_path_buf());
439            let outside = !resolved.starts_with(&canon_root);
440            // Allow temp directories (#560)
441            if outside && crate::bash_path_lint::is_safe_external_path(&resolved) {
442                return false;
443            }
444            outside
445        }
446        None => false,
447    }
448}
449
450// ── Re-exports for backward compatibility ─────────────────
451
452/// Re-export settings types for provider persistence.
453pub use crate::last_provider::LastProvider;
454
455// ── Tests ─────────────────────────────────────────────────
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460
461    // ── Mode tests ──
462
463    #[test]
464    fn test_mode_cycle() {
465        assert_eq!(TrustMode::Safe.next(), TrustMode::Auto);
466        assert_eq!(TrustMode::Auto.next(), TrustMode::Safe);
467        assert_eq!(TrustMode::Plan.next(), TrustMode::Plan); // agent-only, no toggle
468    }
469
470    #[test]
471    fn test_mode_ordering() {
472        assert!(TrustMode::Plan < TrustMode::Safe);
473        assert!(TrustMode::Safe < TrustMode::Auto);
474    }
475
476    #[test]
477    fn test_clamp() {
478        assert_eq!(
479            TrustMode::clamp(TrustMode::Plan, TrustMode::Auto),
480            TrustMode::Plan
481        );
482        assert_eq!(
483            TrustMode::clamp(TrustMode::Safe, TrustMode::Auto),
484            TrustMode::Safe
485        );
486        assert_eq!(
487            TrustMode::clamp(TrustMode::Auto, TrustMode::Safe),
488            TrustMode::Safe
489        );
490        assert_eq!(
491            TrustMode::clamp(TrustMode::Auto, TrustMode::Auto),
492            TrustMode::Auto
493        );
494    }
495
496    // ── #1022 B19: derive_child_trust contract ──
497    //
498    // The helper *is* `clamp` underneath, so the matrix duplicates
499    // the `test_clamp` cases. That duplication is intentional:
500    //
501    // - `test_clamp` pins the math.
502    // - These tests pin the **named contract** — if a future refactor
503    //   inlines `clamp` back into call sites, these tests still pass
504    //   (clamp didn't break) but the structural lint test in
505    //   `koda-cli/tests/regression_test.rs` catches the call-site
506    //   regression. Tests target different layers; both must hold.
507    //
508    // The naming (`fork_identity`, `named_clamps_down`,
509    // `child_already_stricter_passes_through`) documents the
510    // architectural cases each call site exercises.
511
512    #[test]
513    fn derive_child_trust_fork_identity() {
514        // Fork passes (mode, mode) — helper collapses to identity.
515        // Documents that the symmetry is preserved.
516        for parent in [TrustMode::Plan, TrustMode::Safe, TrustMode::Auto] {
517            assert_eq!(
518                derive_child_trust(parent, parent),
519                parent,
520                "fork (parent==declared) must return parent verbatim"
521            );
522        }
523    }
524
525    #[test]
526    fn derive_child_trust_named_clamps_down() {
527        // Named child declares Auto; parent runtime is Safe →
528        // child must clamp down to Safe. This is the load-bearing
529        // case — a child that wanted broader privileges than its
530        // parent's runtime mode is forcibly narrowed.
531        assert_eq!(
532            derive_child_trust(TrustMode::Safe, TrustMode::Auto),
533            TrustMode::Safe
534        );
535        assert_eq!(
536            derive_child_trust(TrustMode::Plan, TrustMode::Auto),
537            TrustMode::Plan
538        );
539        assert_eq!(
540            derive_child_trust(TrustMode::Plan, TrustMode::Safe),
541            TrustMode::Plan
542        );
543    }
544
545    #[test]
546    fn derive_child_trust_child_already_stricter_passes_through() {
547        // Named child declares Plan with parent Auto → child stays
548        // Plan. Clamp never *widens*, only narrows.
549        assert_eq!(
550            derive_child_trust(TrustMode::Auto, TrustMode::Plan),
551            TrustMode::Plan
552        );
553        assert_eq!(
554            derive_child_trust(TrustMode::Auto, TrustMode::Safe),
555            TrustMode::Safe
556        );
557        assert_eq!(
558            derive_child_trust(TrustMode::Safe, TrustMode::Plan),
559            TrustMode::Plan
560        );
561    }
562
563    #[test]
564    fn derive_child_trust_is_commutative_in_min_but_not_in_meaning() {
565        // Math is symmetric: min(a, b) == min(b, a).
566        // But the *contract* is asymmetric: arg 1 is parent runtime,
567        // arg 2 is declared. This test pins that the math is
568        // commutative (so no path is order-sensitive in result),
569        // while the function name and docstring make the semantic
570        // asymmetry explicit. Catches a refactor that introduces
571        // *non-commutative* behavior (e.g. "if declared is Plan
572        // unconditionally allow it") which would silently change
573        // dispatch semantics.
574        for a in [TrustMode::Plan, TrustMode::Safe, TrustMode::Auto] {
575            for b in [TrustMode::Plan, TrustMode::Safe, TrustMode::Auto] {
576                assert_eq!(derive_child_trust(a, b), derive_child_trust(b, a));
577            }
578        }
579    }
580
581    #[test]
582    fn test_mode_from_str() {
583        assert_eq!(TrustMode::parse("auto"), Some(TrustMode::Auto));
584        assert_eq!(TrustMode::parse("safe"), Some(TrustMode::Safe));
585        assert_eq!(TrustMode::parse("plan"), Some(TrustMode::Plan));
586        // Legacy aliases
587        assert_eq!(TrustMode::parse("yolo"), Some(TrustMode::Auto));
588        assert_eq!(TrustMode::parse("confirm"), Some(TrustMode::Safe));
589        assert_eq!(TrustMode::parse("strict"), Some(TrustMode::Safe));
590        assert_eq!(TrustMode::parse("normal"), Some(TrustMode::Safe));
591        assert_eq!(TrustMode::parse("readonly"), Some(TrustMode::Plan));
592        assert_eq!(TrustMode::parse("read-only"), Some(TrustMode::Plan));
593        assert_eq!(TrustMode::parse("accept"), Some(TrustMode::Auto));
594        assert_eq!(TrustMode::parse("nope"), None);
595    }
596
597    #[test]
598    fn test_mode_from_u8() {
599        assert_eq!(TrustMode::from(0), TrustMode::Plan);
600        assert_eq!(TrustMode::from(1), TrustMode::Safe);
601        assert_eq!(TrustMode::from(2), TrustMode::Auto);
602        assert_eq!(TrustMode::from(99), TrustMode::Safe); // fail-safe to Safe (#860)
603    }
604
605    #[test]
606    fn test_shared_trust_cycle() {
607        let shared = new_shared_trust(TrustMode::Safe);
608        assert_eq!(read_trust(&shared), TrustMode::Safe);
609        let next = cycle_trust(&shared);
610        assert_eq!(next, TrustMode::Auto);
611        assert_eq!(read_trust(&shared), TrustMode::Auto);
612    }
613
614    #[test]
615    fn test_display() {
616        assert_eq!(TrustMode::Plan.to_string(), "plan");
617        assert_eq!(TrustMode::Safe.to_string(), "safe");
618        assert_eq!(TrustMode::Auto.to_string(), "auto");
619    }
620
621    #[test]
622    fn test_default() {
623        assert_eq!(TrustMode::default(), TrustMode::Safe);
624    }
625
626    // ── Tool approval tests ──
627
628    const READ_ONLY_TOOLS: &[&str] = &[
629        "Read",
630        "List",
631        "Grep",
632        "Glob",
633        "MemoryRead",
634        "ListAgents",
635        "InvokeAgent",
636        "WebFetch",
637        "WebSearch",
638        "ListSkills",
639        "ActivateSkill",
640    ];
641
642    #[test]
643    fn test_read_tools_always_approved() {
644        for tool in READ_ONLY_TOOLS {
645            for mode in [TrustMode::Plan, TrustMode::Safe, TrustMode::Auto] {
646                assert_eq!(
647                    check_tool(tool, &serde_json::json!({}), mode, None),
648                    ToolApproval::AutoApprove,
649                    "{tool} should auto-approve in {mode:?}"
650                );
651            }
652        }
653    }
654
655    #[test]
656    fn test_plan_blocks_all_writes() {
657        for tool in ["Write", "Edit", "Delete", "MemoryWrite", "TodoWrite"] {
658            assert_eq!(
659                check_tool(tool, &serde_json::json!({}), TrustMode::Plan, None),
660                ToolApproval::Blocked,
661                "{tool} should be blocked in Plan mode"
662            );
663        }
664    }
665
666    #[test]
667    fn test_safe_confirms_writes() {
668        for tool in ["Write", "Edit", "Delete", "MemoryWrite", "TodoWrite"] {
669            assert_eq!(
670                check_tool(tool, &serde_json::json!({}), TrustMode::Safe, None),
671                ToolApproval::NeedsConfirmation,
672                "{tool} should need confirmation in Safe mode"
673            );
674        }
675    }
676
677    #[test]
678    fn test_auto_approves_non_outside() {
679        // On platforms with sandbox available, Auto mode auto-approves mutations.
680        // If sandbox is unavailable, mutations downgrade to NeedsConfirmation (#860).
681        let expected = if crate::sandbox::is_available() {
682            ToolApproval::AutoApprove
683        } else {
684            ToolApproval::NeedsConfirmation
685        };
686        for tool in ["Write", "Edit", "TodoWrite"] {
687            assert_eq!(
688                check_tool(tool, &serde_json::json!({}), TrustMode::Auto, None),
689                expected,
690                "{tool} in Auto mode"
691            );
692        }
693        // Read-only tools always auto-approve regardless of sandbox.
694        assert_eq!(
695            check_tool("WebFetch", &serde_json::json!({}), TrustMode::Auto, None),
696            ToolApproval::AutoApprove,
697        );
698    }
699
700    #[test]
701    fn test_auto_approves_destructive() {
702        // In Auto mode, destructive ops are auto-approved when sandbox is available.
703        // Without sandbox, they downgrade to NeedsConfirmation (#860).
704        let expected = if crate::sandbox::is_available() {
705            ToolApproval::AutoApprove
706        } else {
707            ToolApproval::NeedsConfirmation
708        };
709        assert_eq!(
710            check_tool("Delete", &serde_json::json!({}), TrustMode::Auto, None),
711            expected,
712        );
713    }
714
715    #[test]
716    fn test_safe_bash_read_only_auto_approved() {
717        let args = serde_json::json!({"command": "git status"});
718        assert_eq!(
719            check_tool("Bash", &args, TrustMode::Safe, None),
720            ToolApproval::AutoApprove,
721        );
722    }
723
724    /// gh read-only commands should auto-approve even in Safe mode (#518).
725    #[test]
726    fn test_gh_read_only_auto_approved() {
727        for cmd in [
728            "gh issue view 42",
729            "gh pr view 99",
730            "gh pr list",
731            "gh issue list",
732        ] {
733            let args = serde_json::json!({"command": cmd});
734            assert_eq!(
735                check_tool("Bash", &args, TrustMode::Safe, None),
736                ToolApproval::AutoApprove,
737                "{cmd} should auto-approve even in Safe mode"
738            );
739        }
740    }
741
742    /// gh destructive commands need confirmation in both Safe and Auto modes (#518).
743    #[test]
744    fn test_gh_destructive_needs_confirmation_in_safe() {
745        for cmd in [
746            "gh pr merge 42 --squash",
747            "gh issue delete 42",
748            "gh repo delete owner/repo",
749        ] {
750            let args = serde_json::json!({"command": cmd});
751            assert_eq!(
752                check_tool("Bash", &args, TrustMode::Safe, None),
753                ToolApproval::NeedsConfirmation,
754                "{cmd} should need confirmation in Safe mode"
755            );
756        }
757    }
758
759    /// gh mutation commands confirm in Safe, auto-approve in Auto (#518).
760    #[test]
761    fn test_gh_mutation_auto_approved_in_auto() {
762        for cmd in [
763            "gh issue create --title 'bug'",
764            "gh issue edit 42",
765            "gh pr create",
766        ] {
767            let args = serde_json::json!({"command": cmd});
768            assert_eq!(
769                check_tool("Bash", &args, TrustMode::Auto, None),
770                ToolApproval::AutoApprove,
771                "{cmd} should auto-approve in Auto mode"
772            );
773            assert_eq!(
774                check_tool("Bash", &args, TrustMode::Safe, None),
775                ToolApproval::NeedsConfirmation,
776                "{cmd} should need confirmation in Safe mode"
777            );
778        }
779    }
780
781    #[test]
782    fn test_dev_workflow_bash_needs_confirmation_in_safe() {
783        let args = serde_json::json!({"command": "cargo test --release"});
784        assert_eq!(
785            check_tool("Bash", &args, TrustMode::Safe, None),
786            ToolApproval::NeedsConfirmation,
787        );
788    }
789
790    #[test]
791    fn test_dangerous_bash_needs_confirmation_in_safe() {
792        let args = serde_json::json!({"command": "rm -rf target/"});
793        assert_eq!(
794            check_tool("Bash", &args, TrustMode::Safe, None),
795            ToolApproval::NeedsConfirmation,
796        );
797    }
798
799    #[test]
800    fn test_plan_blocks_bash() {
801        let args = serde_json::json!({"command": "cargo test"});
802        assert_eq!(
803            check_tool("Bash", &args, TrustMode::Plan, None),
804            ToolApproval::Blocked,
805        );
806    }
807
808    #[test]
809    fn test_invoke_agent_auto_approved() {
810        let args = serde_json::json!({"agent_name": "reviewer", "prompt": "review this"});
811        for mode in [TrustMode::Plan, TrustMode::Safe, TrustMode::Auto] {
812            assert_eq!(
813                check_tool("InvokeAgent", &args, mode, None),
814                ToolApproval::AutoApprove,
815            );
816        }
817    }
818
819    // ── Path scoping tests (#218) ──────────────────────────
820
821    #[test]
822    fn test_write_outside_project_needs_confirmation() {
823        let root = Path::new("/home/user/project");
824        let args = serde_json::json!({"path": "/etc/hosts"});
825        assert_eq!(
826            check_tool("Write", &args, TrustMode::Auto, Some(root)),
827            ToolApproval::NeedsConfirmation,
828        );
829    }
830
831    #[test]
832    fn test_write_inside_project_auto_approved() {
833        let root = Path::new("/home/user/project");
834        let args = serde_json::json!({"path": "src/main.rs"});
835        assert_eq!(
836            check_tool("Write", &args, TrustMode::Auto, Some(root)),
837            ToolApproval::AutoApprove,
838        );
839    }
840
841    #[test]
842    fn test_edit_with_dotdot_escape_needs_confirmation() {
843        let root = Path::new("/home/user/project");
844        let args = serde_json::json!({"path": "../../../etc/passwd"});
845        assert_eq!(
846            check_tool("Edit", &args, TrustMode::Auto, Some(root)),
847            ToolApproval::NeedsConfirmation,
848        );
849    }
850
851    #[test]
852    fn test_bash_cd_outside_needs_confirmation() {
853        let root = Path::new("/home/user/project");
854        let args = serde_json::json!({"command": "cd /etc && ls"});
855        assert_eq!(
856            check_tool("Bash", &args, TrustMode::Auto, Some(root)),
857            ToolApproval::NeedsConfirmation,
858        );
859    }
860
861    #[test]
862    fn test_bash_cd_inside_auto_approved() {
863        let root = Path::new("/home/user/project");
864        let args = serde_json::json!({"command": "cd src && ls"});
865        assert_eq!(
866            check_tool("Bash", &args, TrustMode::Auto, Some(root)),
867            ToolApproval::AutoApprove,
868        );
869    }
870
871    #[test]
872    fn test_no_project_root_skips_path_check() {
873        let args = serde_json::json!({"path": "/etc/hosts"});
874        assert_eq!(
875            check_tool("Write", &args, TrustMode::Auto, None),
876            ToolApproval::AutoApprove,
877        );
878    }
879
880    // ── Temp path allowlist (#560) ──
881
882    #[test]
883    fn test_write_to_tmp_auto_approved() {
884        let root = Path::new("/home/user/project");
885        let args = serde_json::json!({"path": "/tmp/issue-draft.md"});
886        assert_eq!(
887            check_tool("Write", &args, TrustMode::Auto, Some(root)),
888            ToolApproval::AutoApprove,
889            "/tmp writes should auto-approve"
890        );
891    }
892
893    #[test]
894    fn test_bash_cd_tmp_auto_approved() {
895        let root = Path::new("/home/user/project");
896        let args = serde_json::json!({"command": "cd /tmp && ls"});
897        assert_eq!(
898            check_tool("Bash", &args, TrustMode::Auto, Some(root)),
899            ToolApproval::AutoApprove,
900            "cd /tmp should auto-approve"
901        );
902    }
903
904    #[test]
905    fn test_write_to_etc_still_blocked() {
906        let root = Path::new("/home/user/project");
907        let args = serde_json::json!({"path": "/etc/hosts"});
908        assert_eq!(
909            check_tool("Write", &args, TrustMode::Auto, Some(root)),
910            ToolApproval::NeedsConfirmation,
911            "/etc writes should still need confirmation"
912        );
913    }
914
915    // ── File lifecycle (#465) tests ──
916
917    #[tokio::test]
918    async fn test_delete_owned_file_auto_approved() {
919        let dir = tempfile::TempDir::new().unwrap();
920        let db = crate::db::Database::open(&dir.path().join("test.db"))
921            .await
922            .unwrap();
923        let mut tracker = FileTracker::new("test-sess", db).await;
924        let root = Path::new("/home/user/project");
925        let owned_path = root.join("temp_output.md");
926        tracker.track_created(owned_path).await;
927
928        let args = serde_json::json!({"path": "temp_output.md"});
929        assert_eq!(
930            check_tool_with_tracker("Delete", &args, TrustMode::Auto, Some(root), Some(&tracker),),
931            ToolApproval::AutoApprove,
932            "Delete of Koda-owned file should auto-approve"
933        );
934    }
935
936    #[tokio::test]
937    async fn test_delete_unowned_file_needs_confirmation_in_safe() {
938        let dir = tempfile::TempDir::new().unwrap();
939        let db = crate::db::Database::open(&dir.path().join("test.db"))
940            .await
941            .unwrap();
942        let tracker = FileTracker::new("test-sess", db).await;
943        let root = Path::new("/home/user/project");
944
945        let args = serde_json::json!({"path": "user_file.rs"});
946        assert_eq!(
947            check_tool_with_tracker("Delete", &args, TrustMode::Safe, Some(root), Some(&tracker),),
948            ToolApproval::NeedsConfirmation,
949            "Delete of unowned file should need confirmation in Safe mode"
950        );
951    }
952
953    #[tokio::test]
954    async fn test_delete_owned_file_safe_mode_auto_approved() {
955        let dir = tempfile::TempDir::new().unwrap();
956        let db = crate::db::Database::open(&dir.path().join("test.db"))
957            .await
958            .unwrap();
959        let mut tracker = FileTracker::new("test-sess", db).await;
960        let root = Path::new("/home/user/project");
961        let owned_path = root.join("scratch.txt");
962        tracker.track_created(owned_path).await;
963
964        let args = serde_json::json!({"path": "scratch.txt"});
965        assert_eq!(
966            check_tool_with_tracker("Delete", &args, TrustMode::Safe, Some(root), Some(&tracker),),
967            ToolApproval::AutoApprove,
968            "Delete of Koda-owned file should auto-approve even in Safe mode"
969        );
970    }
971
972    #[test]
973    fn test_no_tracker_safe_mode_delete_needs_confirmation() {
974        let root = Path::new("/home/user/project");
975        let args = serde_json::json!({"path": "some_file.rs"});
976        assert_eq!(
977            check_tool_with_tracker("Delete", &args, TrustMode::Safe, Some(root), None),
978            ToolApproval::NeedsConfirmation,
979            "Without tracker, Delete should need confirmation in Safe"
980        );
981    }
982}