Skip to main content

recursive/permissions/
mod.rs

1//! Static tool permission configuration.
2//!
3//! Provides a layered permission system where rules from different sources
4//! (user config, project config, session) are merged with well-defined
5//! semantics:
6//!
7//! - **deny**: any layer denies → denied (union)
8//! - **allow**: any layer allows → allowed (union)
9//! - **interactive**: any layer marks as interactive → interactive (union)
10//!
11//! The session-level [`PermissionMode`] provides additional runtime behaviour
12//! on top of the static rules (plan-mode write blocking, bypass, dont-ask, etc.).
13//!
14//! The legacy [`PermissionsConfig`] type alias provides backward compatibility
15//! for existing callers.
16
17pub mod auto_classifier;
18
19use serde::{Deserialize, Serialize};
20use std::sync::Arc;
21use tokio::sync::RwLock;
22
23/// The reason why a permission decision was made.
24///
25/// Carries structured information about which rule, mode, hook, or
26/// safety check triggered the decision. Useful for debugging, audit
27/// logging, and user-facing error messages.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum DecisionReason {
30    /// A rule from a specific source matched a pattern.
31    Rule { source: RuleSource, pattern: String },
32    /// The default permission mode triggered the decision.
33    Mode(PermissionMode),
34    /// A runtime permission hook made the decision.
35    Hook { name: String },
36    /// A safety check on a file path triggered the decision.
37    SafetyCheck { path: String },
38}
39
40/// The result of a permission check.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum Permission {
43    /// The tool is allowed to run, with the reason why.
44    Allowed(DecisionReason),
45    /// The tool is denied, with the reason and a human-readable message.
46    Denied(DecisionReason, String),
47    /// No rule in any layer explicitly matched this tool.
48    ///
49    /// `invoke_with_audit` treats this as "allowed" for non-interactive
50    /// tools, and delegates to the registered [`PermissionHook`] for
51    /// tools in the interactive list (if a hook is present).
52    Unknown,
53}
54
55impl Permission {
56    /// Returns `true` if this is an `Allowed` decision.
57    pub fn is_allowed(&self) -> bool {
58        matches!(self, Permission::Allowed(_))
59    }
60
61    /// Returns `true` if this is a `Denied` decision.
62    pub fn is_denied(&self) -> bool {
63        matches!(self, Permission::Denied(_, _))
64    }
65}
66
67/// The session-wide permission mode, determining runtime behaviour for
68/// tool dispatch.
69///
70/// This is separate from the per-tool allow/deny/interactive lists in
71/// the layered config — the mode acts as a top-level policy on top of
72/// those static rules.
73///
74/// ## Serde
75///
76/// This enum supports both the **new** camelCase names and the **old**
77/// snake_case names for backward compatibility:
78///
79/// | New                  | Old aliases           |
80/// |----------------------|-----------------------|
81/// | `default`            | `allow`               |
82/// | `acceptEdits`        | —                     |
83/// | `bypassPermissions`  | —                     |
84/// | `dontAsk`            | `deny`, `interactive` |
85/// | `plan` (string)      | `plan`                |
86///
87/// The `plan` variant also accepts an object form:
88/// `{"prePlanMode": "default", "bypassAvailable": false}`.
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Default)]
90#[serde(rename_all = "camelCase")]
91pub enum PermissionMode {
92    /// Normal mode: follow static allow/deny rules, defer to Passthrough
93    /// for unknown tools.
94    #[serde(alias = "allow")]
95    #[default]
96    Default,
97    /// Auto-allow write tools within the workspace.
98    AcceptEdits,
99    /// Skip all rule checks; every tool is allowed.
100    BypassPermissions,
101    /// Deny any tool that is in the interactive list.
102    #[serde(alias = "deny")]
103    #[serde(alias = "interactive")]
104    DontAsk,
105    /// Plan mode: blocks write tools (except `exit_plan_mode`).
106    /// `pre_plan_mode` stores the mode to restore on exit.
107    /// `bypass_available` allows writes to bypass plan-mode blocking
108    /// when the agent was in `BypassPermissions` before entering plan.
109    Plan {
110        /// The mode that was active before entering plan mode.
111        pre_plan_mode: Box<PermissionMode>,
112        /// Whether write tools are allowed during plan mode
113        /// (only true when pre-plan mode was BypassPermissions).
114        bypass_available: bool,
115    },
116
117    /// Auto mode: delegate every tool-call decision to an LLM classifier.
118    ///
119    /// The classifier receives the tool name, argument summary, and recent
120    /// conversation context and returns `{"block": true|false, "reason": "..."}`.
121    /// A denial tracker prevents runaway loops (3 consecutive / 10 total
122    /// denials → all subsequent calls denied).
123    Auto,
124}
125
126// Custom Deserialize to handle both old and new string names as well as
127// the object form for the Plan variant.
128impl<'de> Deserialize<'de> for PermissionMode {
129    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
130    where
131        D: serde::Deserializer<'de>,
132    {
133        use serde::de::{self, MapAccess, Visitor};
134        use std::fmt;
135
136        struct PermissionModeVisitor;
137
138        impl<'de> Visitor<'de> for PermissionModeVisitor {
139            type Value = PermissionMode;
140
141            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
142                formatter.write_str(
143                    "a permission mode string (\"default\", \"acceptEdits\", \
144                     \"bypassPermissions\", \"dontAsk\", \"plan\", \"auto\", \
145                     or legacy \"allow\"/\"deny\"/\"interactive\") \
146                     or a Plan object {\"prePlanMode\": \"...\", \"bypassAvailable\": bool}",
147                )
148            }
149
150            fn visit_str<E: de::Error>(self, s: &str) -> Result<PermissionMode, E> {
151                match s {
152                    "default" | "allow" => Ok(PermissionMode::Default),
153                    "acceptEdits" | "accept_edits" => Ok(PermissionMode::AcceptEdits),
154                    "bypassPermissions" | "bypass_permissions" => {
155                        Ok(PermissionMode::BypassPermissions)
156                    }
157                    "dontAsk" | "dont_ask" | "deny" | "interactive" => Ok(PermissionMode::DontAsk),
158                    "auto" => Ok(PermissionMode::Auto),
159                    "plan" => Ok(PermissionMode::Plan {
160                        pre_plan_mode: Box::new(PermissionMode::Default),
161                        bypass_available: false,
162                    }),
163                    _ => Err(de::Error::unknown_variant(
164                        s,
165                        &[
166                            "default",
167                            "acceptEdits",
168                            "bypassPermissions",
169                            "auto",
170                            "dontAsk",
171                            "plan",
172                        ],
173                    )),
174                }
175            }
176
177            fn visit_map<M: MapAccess<'de>>(self, mut map: M) -> Result<PermissionMode, M::Error> {
178                // Object form: {"pre_plan_mode": "...", "bypass_available": bool}
179                // Also accepts camelCase keys: "prePlanMode", "bypassAvailable"
180                let mut pre_plan_mode: Option<PermissionMode> = None;
181                let mut bypass_available: Option<bool> = None;
182
183                while let Some(key) = map.next_key::<String>()? {
184                    match key.as_str() {
185                        "prePlanMode" | "pre_plan_mode" => {
186                            if pre_plan_mode.is_some() {
187                                return Err(de::Error::duplicate_field("pre_plan_mode"));
188                            }
189                            pre_plan_mode = Some(map.next_value()?);
190                        }
191                        "bypassAvailable" | "bypass_available" => {
192                            if bypass_available.is_some() {
193                                return Err(de::Error::duplicate_field("bypass_available"));
194                            }
195                            bypass_available = Some(map.next_value()?);
196                        }
197                        other => {
198                            return Err(de::Error::unknown_field(
199                                other,
200                                &[
201                                    "pre_plan_mode",
202                                    "prePlanMode",
203                                    "bypass_available",
204                                    "bypassAvailable",
205                                ],
206                            ));
207                        }
208                    }
209                }
210
211                let pre_plan_mode =
212                    pre_plan_mode.ok_or_else(|| de::Error::missing_field("pre_plan_mode"))?;
213                let bypass_available = bypass_available.unwrap_or(false);
214                Ok(PermissionMode::Plan {
215                    pre_plan_mode: Box::new(pre_plan_mode),
216                    bypass_available,
217                })
218            }
219        }
220
221        deserializer.deserialize_any(PermissionModeVisitor)
222    }
223}
224
225// ── Layered permission system ──────────────────────────────────────────────
226
227/// The source/origin of a permission layer.
228///
229/// Priority (highest first): Session > Project > User.
230#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize)]
231pub enum RuleSource {
232    /// Highest priority — set at runtime via API (Goal 196).
233    Session,
234    /// Medium priority — from `.recursive/config.toml` in the project.
235    Project,
236    /// Lowest priority — from `~/.recursive/config.toml`.
237    #[default]
238    User,
239}
240
241/// A single layer of permission rules from one source.
242#[derive(Debug, Clone, Default, Deserialize)]
243pub struct PermissionLayer {
244    /// Which source this layer comes from.
245    pub source: RuleSource,
246    /// Tools that are explicitly allowed. Empty = allow all.
247    pub allow: Vec<String>,
248    /// Tools that are explicitly denied. Takes priority over `allow`.
249    pub deny: Vec<String>,
250    /// Tools that require interactive confirmation before running.
251    pub interactive: Vec<String>,
252}
253
254/// Layered permission configuration.
255///
256/// Layers are ordered by priority (highest first). The merging semantics
257/// for `check_static` are:
258/// - **deny**: any layer denies → denied (union)
259/// - **allow**: any layer allows → allowed (union)
260/// - **interactive**: any layer marks as interactive → interactive (union)
261///
262/// The [`mode`](Self::mode) field provides session-level behaviour on top
263/// of these static rules.
264#[derive(Debug, Clone, Default, Deserialize)]
265pub struct LayeredPermissionsConfig {
266    /// Session-wide permission mode.
267    #[serde(default)]
268    pub mode: PermissionMode,
269    /// Ordered layers (highest priority first).
270    #[serde(default)]
271    pub layers: Vec<PermissionLayer>,
272}
273
274impl LayeredPermissionsConfig {
275    /// Check whether a tool is allowed or denied, taking the current
276    /// [`PermissionMode`] into account.
277    ///
278    /// Checks are applied in priority order (highest first):
279    ///
280    /// 0. **Safety check (Goal 196)** — if `content` resolves to a file
281    ///    path under a protected location (`.git`, `.ssh`, etc.), deny
282    ///    with `DecisionReason::SafetyCheck`. This is enforced even under
283    ///    `BypassPermissions`. Read-only tools are exempt.
284    /// 1. **Plan mode** — blocks write tools unless `bypass_available`
285    ///    (always exempts `exit_plan_mode`).
286    /// 2. **BypassPermissions** — skips all rules, allows everything.
287    /// 3. **DontAsk** — denies tools in the interactive list.
288    /// 4. **AcceptEdits** — auto-allows write tools.
289    /// 5. **Deny/allow rules** — static deny (union) then allow (union).
290    /// 6. **Default** — falls through with `Unknown` (no rule matched).
291    ///    `invoke_with_audit` treats `Unknown` as allowed for non-interactive
292    ///    tools and delegates to the registered `PermissionHook` for
293    ///    interactive tools in non-headless contexts (Goal-212).
294    ///
295    /// `content` is the call-time content the tool will operate on. For
296    /// `write_file` / `read_file` it should be the file path; for
297    /// `apply_patch` it should be the full V4A patch body. Other tools
298    /// can pass `None`. See [`extract_file_path_from_content`].
299    pub fn check_static(
300        &self,
301        tool_name: &str,
302        is_readonly: bool,
303        content: Option<&str>,
304    ) -> Permission {
305        // 0. Safety check (Goal 196): protected paths cannot be written
306        // to, even under BypassPermissions. Read-only tools are exempt.
307        if !is_readonly {
308            if let Some(path) = extract_file_path_from_content(tool_name, content) {
309                for protected in PROTECTED_PATHS {
310                    if path_contains_protected(&path, protected) {
311                        return Permission::Denied(
312                            DecisionReason::SafetyCheck { path: path.clone() },
313                            format!(
314                                "writing to `{path}` is protected and requires explicit confirmation"
315                            ),
316                        );
317                    }
318                }
319            }
320        }
321
322        // 1. Plan mode: block write tools (exit_plan_mode exempted)
323        if let PermissionMode::Plan {
324            bypass_available, ..
325        } = &self.mode
326        {
327            if !is_readonly && tool_name != "exit_plan_mode" && !bypass_available {
328                return Permission::Denied(
329                    DecisionReason::Mode(self.mode.clone()),
330                    "write tools are blocked in plan mode".to_string(),
331                );
332            }
333            // bypass_available: write ops continue to rule checks
334        }
335
336        // 2. BypassPermissions: skip all rules
337        if matches!(self.mode, PermissionMode::BypassPermissions) {
338            return Permission::Allowed(DecisionReason::Mode(self.mode.clone()));
339        }
340
341        // 3. DontAsk: deny interactive tools
342        if matches!(self.mode, PermissionMode::DontAsk) && self.any_interactive(tool_name) {
343            return Permission::Denied(
344                DecisionReason::Mode(self.mode.clone()),
345                format!("tool `{tool_name}` requires interaction but mode is dontAsk"),
346            );
347        }
348
349        // 4. AcceptEdits: auto-allow write tools
350        if matches!(self.mode, PermissionMode::AcceptEdits) && !is_readonly {
351            return Permission::Allowed(DecisionReason::Mode(self.mode.clone()));
352        }
353
354        // 5. Static deny/allow rules (union semantics)
355        for pattern in self.all_deny() {
356            if matches_pattern(pattern, tool_name) {
357                return Permission::Denied(
358                    DecisionReason::Rule {
359                        source: RuleSource::User,
360                        pattern: pattern.to_string(),
361                    },
362                    format!("tool `{tool_name}` matches deny pattern `{pattern}`"),
363                );
364            }
365        }
366        for pattern in self.all_allow() {
367            if matches_pattern(pattern, tool_name) {
368                return Permission::Allowed(DecisionReason::Rule {
369                    source: RuleSource::User,
370                    pattern: pattern.to_string(),
371                });
372            }
373        }
374
375        // 6. Default: defer to upper layer
376        Permission::Unknown
377    }
378
379    /// Check whether a tool requires plan mode.
380    ///
381    /// Returns `true` if the session mode is `Plan`.
382    /// Returns `false` if the tool is denied.
383    pub fn is_plan_mode(&self, tool_name: &str) -> bool {
384        let _ = tool_name; // kept for API compatibility (Goal 194 will use it)
385                           // Plan mode is determined by the session mode directly, not via
386                           // check_static (which would trigger write-block for non-readonly tools).
387        matches!(self.mode, PermissionMode::Plan { .. })
388    }
389
390    /// Check whether a tool requires interactive confirmation.
391    ///
392    /// Returns `true` if any layer marks the tool as interactive.
393    /// Returns `false` if the tool is denied (denied tools never prompt).
394    pub fn is_interactive(&self, tool_name: &str) -> bool {
395        // Denied tools are never interactive
396        if matches!(
397            self.check_static(tool_name, false, None),
398            Permission::Denied(_, _)
399        ) {
400            return false;
401        }
402        self.any_interactive(tool_name)
403    }
404
405    /// Returns `true` if the tool is in the interactive list of any layer.
406    pub fn any_interactive(&self, tool_name: &str) -> bool {
407        self.all_interactive()
408            .any(|p| matches_pattern(p, tool_name))
409    }
410
411    /// Iterate over all deny patterns across all layers.
412    pub fn all_deny(&self) -> impl Iterator<Item = &str> {
413        self.layers
414            .iter()
415            .flat_map(|l| l.deny.iter())
416            .map(|s| s.as_str())
417    }
418
419    /// Iterate over all allow patterns across all layers.
420    pub fn all_allow(&self) -> impl Iterator<Item = &str> {
421        self.layers
422            .iter()
423            .flat_map(|l| l.allow.iter())
424            .map(|s| s.as_str())
425    }
426
427    /// Iterate over all interactive patterns across all layers.
428    pub fn all_interactive(&self) -> impl Iterator<Item = &str> {
429        self.layers
430            .iter()
431            .flat_map(|l| l.interactive.iter())
432            .map(|s| s.as_str())
433    }
434
435    // ── Goal-197: Runtime session rule management ───────────────────────
436
437    /// Add a permission rule to the session layer at runtime.
438    ///
439    /// The session layer is always the highest-priority layer (index 0).
440    /// If it doesn't exist yet, it is created on first use.
441    ///
442    /// Rules added via this method take effect immediately for all
443    /// subsequent `check_static` calls, without requiring a restart.
444    /// They are **not** persisted to disk — they live only in memory
445    /// for the current session.
446    pub fn add_session_rule(&mut self, behavior: RuleBehavior, pattern: String) {
447        let layer = self.session_layer_mut();
448        match behavior {
449            RuleBehavior::Allow => layer.allow.push(pattern),
450            RuleBehavior::Deny => layer.deny.push(pattern),
451            RuleBehavior::Interactive => layer.interactive.push(pattern),
452        }
453    }
454
455    /// Remove a permission rule from the session layer.
456    ///
457    /// The `pattern` is matched against existing rules as a substring.
458    /// All rules whose string representation *contains* `pattern` are
459    /// removed from the corresponding behaviour list.
460    pub fn remove_session_rule(&mut self, behavior: RuleBehavior, pattern: &str) {
461        let layer = self.session_layer_mut();
462        let list = match behavior {
463            RuleBehavior::Allow => &mut layer.allow,
464            RuleBehavior::Deny => &mut layer.deny,
465            RuleBehavior::Interactive => &mut layer.interactive,
466        };
467        list.retain(|r| !r.contains(pattern));
468    }
469
470    /// Return a reference to the session layer.
471    ///
472    /// Panics if the session layer does not exist — call
473    /// `session_layer_mut()` first to ensure it is created.
474    pub fn session_rules(&self) -> &PermissionLayer {
475        self.layers
476            .iter()
477            .find(|l| l.source == RuleSource::Session)
478            .expect("session layer always present after first mutation")
479    }
480
481    /// Return a mutable reference to the session layer, creating it if
482    /// it doesn't exist yet. The session layer is always placed at the
483    /// front of the layers list (highest priority).
484    fn session_layer_mut(&mut self) -> &mut PermissionLayer {
485        if !self.layers.iter().any(|l| l.source == RuleSource::Session) {
486            self.layers.insert(
487                0,
488                PermissionLayer {
489                    source: RuleSource::Session,
490                    ..Default::default()
491                },
492            );
493        }
494        self.layers
495            .iter_mut()
496            .find(|l| l.source == RuleSource::Session)
497            .expect("session layer just created")
498    }
499}
500
501// ── Backward compatibility ─────────────────────────────────────────────────
502
503/// Legacy single-layer permission config.
504///
505/// Retained for backward compatibility. New code should use
506/// [`LayeredPermissionsConfig`] directly.
507#[derive(Debug, Clone, Deserialize)]
508#[serde(default)]
509#[derive(Default)]
510pub struct OldPermissionsConfig {
511    /// Tools that are explicitly allowed. Empty = allow all.
512    #[serde(default)]
513    pub allow: Vec<String>,
514    /// Tools that are explicitly denied. Takes priority over `allow`.
515    #[serde(default)]
516    pub deny: Vec<String>,
517    /// Tools that require interactive confirmation before running.
518    #[serde(default)]
519    pub interactive: Vec<String>,
520    /// Tools that require plan mode before use.
521    #[serde(default)]
522    pub plan: Vec<String>,
523    /// Default permission mode for tools not covered by the lists above.
524    /// Accepts both old ("allow", "deny", "interactive", "plan") and
525    /// new ("default", "acceptEdits", "bypassPermissions", "dontAsk")
526    /// value names.
527    #[serde(default)]
528    pub mode: PermissionMode,
529}
530
531impl From<OldPermissionsConfig> for LayeredPermissionsConfig {
532    fn from(old: OldPermissionsConfig) -> Self {
533        let mut layers = Vec::new();
534        if !old.allow.is_empty() || !old.deny.is_empty() || !old.interactive.is_empty() {
535            layers.push(PermissionLayer {
536                source: RuleSource::User,
537                allow: old.allow,
538                deny: old.deny,
539                interactive: old.interactive,
540            });
541        }
542        LayeredPermissionsConfig {
543            mode: old.mode,
544            layers,
545        }
546    }
547}
548
549/// Type alias for backward compatibility.
550///
551/// Most existing code uses `PermissionsConfig`; this alias maps to the
552/// new layered type so existing callers continue to compile.
553pub type PermissionsConfig = LayeredPermissionsConfig;
554
555/// Thread-safe shared reference to a layered permissions configuration.
556///
557/// Wraps the config in an `Arc<RwLock<...>>` so that multiple components
558/// (ToolRegistry, plan mode tools, HTTP handlers) can share a single
559/// config instance. Runtime rule changes via `add_session_rule` /
560/// `remove_session_rule` are immediately visible to all readers on their
561/// next `.read().await`.
562pub type SharedPermissions = Arc<RwLock<LayeredPermissionsConfig>>;
563
564/// Behaviour to associate with a session-level permission rule.
565#[derive(Debug, Clone, PartialEq, Eq)]
566pub enum RuleBehavior {
567    /// Explicitly allow the tool matching this pattern.
568    Allow,
569    /// Explicitly deny the tool matching this pattern.
570    Deny,
571    /// Mark the tool as requiring interactive confirmation.
572    Interactive,
573}
574
575// ── Goal-196: Safety path protection ────────────────────────────────────────
576
577/// Hard-coded list of paths that write tools must never touch, regardless
578/// of permission mode. Read-only operations are exempt.
579///
580/// This is a defense-in-depth check: even `BypassPermissions` mode is
581/// subject to it. The list is intentionally simple and explicit; tooling
582/// around it is intentionally not user-configurable in this iteration.
583///
584/// Component-based matching (via [`path_contains_protected`]) means a file
585/// like `legitimate.git_info` is *not* flagged, but `sub/.git/config` is.
586/// The `.env` entry matches `.env` exactly, not `.env.example` (which is
587/// safe and common in repos); a project-root `.env` is correctly flagged
588/// because it's a top-level component.
589const PROTECTED_PATHS: &[&str] = &[
590    ".git",
591    ".recursive",
592    ".ssh",
593    ".gnupg",
594    ".bashrc",
595    ".zshrc",
596    ".profile",
597    ".bash_profile",
598    ".bash_logout",
599    ".env",
600];
601
602/// Extract a file path from `content` for tools that operate on a file.
603///
604/// - For `write_file` and `read_file`, `content` *is* the file path
605///   (the caller is expected to have passed `args["path"]` as `content`).
606/// - For `apply_patch`, `content` is the full V4A patch body; this
607///   helper extracts the **first** file path mentioned in the body
608///   (i.e. the first `*** Update/Add/Delete File:` header). This is
609///   intentionally conservative: if any file in the patch is protected,
610///   we deny.
611/// - All other tools return `None`.
612fn extract_file_path_from_content(tool_name: &str, content: Option<&str>) -> Option<String> {
613    let content = content?;
614    match tool_name {
615        "write_file" | "read_file" => Some(content.to_string()),
616        "apply_patch" => {
617            for line in content.lines() {
618                for prefix in ["*** Update File: ", "*** Add File: ", "*** Delete File: "] {
619                    if let Some(rest) = line.strip_prefix(prefix) {
620                        return Some(rest.trim().to_string());
621                    }
622                }
623            }
624            None
625        }
626        _ => None,
627    }
628}
629
630/// Returns `true` if any component of `path` is exactly `protected`.
631///
632/// Uses [`std::path::Path::components`] rather than a substring check,
633/// so `legitimate.git_info` is **not** flagged against `.git`, but
634/// `sub/dir/.git/config` is correctly detected.
635fn path_contains_protected(path: &str, protected: &str) -> bool {
636    std::path::Path::new(path)
637        .components()
638        .any(|c| c.as_os_str().to_string_lossy().as_ref() == protected)
639}
640
641// ── Helpers ────────────────────────────────────────────────────────────────
642
643/// Match a tool name against a pattern that may end with `*`.
644///
645/// - `"run_shell"` matches exactly `"run_shell"`
646/// - `"run_*"` matches any name starting with `"run_"`
647/// - `"*"` matches everything
648fn matches_pattern(pattern: &str, name: &str) -> bool {
649    if let Some(prefix) = pattern.strip_suffix('*') {
650        if prefix.is_empty() {
651            // Bare `*` matches everything
652            return true;
653        }
654        name.starts_with(prefix)
655    } else {
656        name == pattern
657    }
658}
659
660#[cfg(test)]
661mod tests {
662    use super::*;
663
664    // ── PermissionMode serde ──────────────────────────────────────────────
665
666    #[test]
667    fn test_permission_mode_default_is_default() {
668        assert_eq!(PermissionMode::default(), PermissionMode::Default);
669    }
670
671    #[test]
672    fn test_permission_mode_deserialize_default() {
673        let mode: PermissionMode = serde_json::from_str("\"default\"").unwrap();
674        assert_eq!(mode, PermissionMode::Default);
675    }
676
677    #[test]
678    fn test_permission_mode_deserialize_accept_edits() {
679        let mode: PermissionMode = serde_json::from_str("\"acceptEdits\"").unwrap();
680        assert_eq!(mode, PermissionMode::AcceptEdits);
681    }
682
683    #[test]
684    fn test_permission_mode_deserialize_bypass() {
685        let mode: PermissionMode = serde_json::from_str("\"bypassPermissions\"").unwrap();
686        assert_eq!(mode, PermissionMode::BypassPermissions);
687    }
688
689    #[test]
690    fn test_permission_mode_deserialize_dont_ask() {
691        let mode: PermissionMode = serde_json::from_str("\"dontAsk\"").unwrap();
692        assert_eq!(mode, PermissionMode::DontAsk);
693    }
694
695    #[test]
696    fn test_permission_mode_deserialize_plan_string() {
697        let mode: PermissionMode = serde_json::from_str("\"plan\"").unwrap();
698        assert_eq!(
699            mode,
700            PermissionMode::Plan {
701                pre_plan_mode: Box::new(PermissionMode::Default),
702                bypass_available: false,
703            }
704        );
705    }
706
707    #[test]
708    fn test_permission_mode_deserialize_plan_object() {
709        let json = r#"{"prePlanMode": "acceptEdits", "bypassAvailable": true}"#;
710        let mode: PermissionMode = serde_json::from_str(json).unwrap();
711        assert_eq!(
712            mode,
713            PermissionMode::Plan {
714                pre_plan_mode: Box::new(PermissionMode::AcceptEdits),
715                bypass_available: true,
716            }
717        );
718    }
719
720    #[test]
721    fn test_permission_mode_deserialize_plan_object_default_bypass() {
722        let json = r#"{"prePlanMode": "default"}"#;
723        let mode: PermissionMode = serde_json::from_str(json).unwrap();
724        assert_eq!(
725            mode,
726            PermissionMode::Plan {
727                pre_plan_mode: Box::new(PermissionMode::Default),
728                bypass_available: false,
729            }
730        );
731    }
732
733    // ── Backward-compat aliases ───────────────────────────────────────────
734
735    #[test]
736    fn test_permission_mode_old_allow_is_default() {
737        let mode: PermissionMode = serde_json::from_str("\"allow\"").unwrap();
738        assert_eq!(mode, PermissionMode::Default);
739    }
740
741    #[test]
742    fn test_permission_mode_old_deny_is_dont_ask() {
743        let mode: PermissionMode = serde_json::from_str("\"deny\"").unwrap();
744        assert_eq!(mode, PermissionMode::DontAsk);
745    }
746
747    #[test]
748    fn test_permission_mode_old_interactive_is_dont_ask() {
749        let mode: PermissionMode = serde_json::from_str("\"interactive\"").unwrap();
750        assert_eq!(mode, PermissionMode::DontAsk);
751    }
752
753    #[test]
754    fn test_permission_mode_snake_case_accept_edits() {
755        let mode: PermissionMode = serde_json::from_str("\"accept_edits\"").unwrap();
756        assert_eq!(mode, PermissionMode::AcceptEdits);
757    }
758
759    #[test]
760    fn test_permission_mode_snake_case_bypass() {
761        let mode: PermissionMode = serde_json::from_str("\"bypass_permissions\"").unwrap();
762        assert_eq!(mode, PermissionMode::BypassPermissions);
763    }
764
765    #[test]
766    fn test_permission_mode_snake_case_dont_ask() {
767        let mode: PermissionMode = serde_json::from_str("\"dont_ask\"").unwrap();
768        assert_eq!(mode, PermissionMode::DontAsk);
769    }
770
771    // ── Mode round-trip (serialize) ───────────────────────────────────────
772
773    #[test]
774    fn test_mode_serialize_default() {
775        let json = serde_json::to_string(&PermissionMode::Default).unwrap();
776        assert_eq!(json, "\"default\"");
777    }
778
779    #[test]
780    fn test_mode_serialize_accept_edits() {
781        let json = serde_json::to_string(&PermissionMode::AcceptEdits).unwrap();
782        assert_eq!(json, "\"acceptEdits\"");
783    }
784
785    #[test]
786    fn test_mode_serialize_bypass() {
787        let json = serde_json::to_string(&PermissionMode::BypassPermissions).unwrap();
788        assert_eq!(json, "\"bypassPermissions\"");
789    }
790
791    #[test]
792    fn test_mode_serialize_dont_ask() {
793        let json = serde_json::to_string(&PermissionMode::DontAsk).unwrap();
794        assert_eq!(json, "\"dontAsk\"");
795    }
796
797    #[test]
798    fn test_mode_serialize_plan() {
799        let mode = PermissionMode::Plan {
800            pre_plan_mode: Box::new(PermissionMode::AcceptEdits),
801            bypass_available: true,
802        };
803        let json = serde_json::to_string(&mode).unwrap();
804        assert!(json.contains("\"pre_plan_mode\":\"acceptEdits\""));
805        assert!(json.contains("\"bypass_available\":true"));
806    }
807
808    // ── LayeredPermissionsConfig: mode-based checks (Goal 193) ────────────
809
810    /// plan_mode_blocks_write: mode=Plan, is_readonly=false → Denied(Mode)
811    #[test]
812    fn test_plan_mode_blocks_write() {
813        let config = LayeredPermissionsConfig {
814            mode: PermissionMode::Plan {
815                pre_plan_mode: Box::new(PermissionMode::Default),
816                bypass_available: false,
817            },
818            ..Default::default()
819        };
820        let result = config.check_static("write_file", false, None);
821        assert!(result.is_denied());
822        if let Permission::Denied(reason, msg) = result {
823            assert!(matches!(
824                reason,
825                DecisionReason::Mode(PermissionMode::Plan { .. })
826            ));
827            assert!(msg.contains("plan mode"));
828        } else {
829            panic!("expected Denied");
830        }
831    }
832
833    /// plan_mode_allows_exit: mode=Plan, tool="exit_plan_mode" — exempted
834    #[test]
835    fn test_plan_mode_allows_exit() {
836        let config = LayeredPermissionsConfig {
837            mode: PermissionMode::Plan {
838                pre_plan_mode: Box::new(PermissionMode::Default),
839                bypass_available: false,
840            },
841            ..Default::default()
842        };
843        // exit_plan_mode is exempted from plan-mode write blocking
844        let result = config.check_static("exit_plan_mode", false, None);
845        assert!(!result.is_denied());
846        // Falls through to Passthrough
847        assert!(matches!(result, Permission::Unknown));
848    }
849
850    /// plan_mode_bypass_write_continues: mode=Plan{bypass_available:true},
851    /// write tool continues past plan check
852    #[test]
853    fn test_plan_mode_bypass_write_continues() {
854        let config = LayeredPermissionsConfig {
855            mode: PermissionMode::Plan {
856                pre_plan_mode: Box::new(PermissionMode::BypassPermissions),
857                bypass_available: true,
858            },
859            ..Default::default()
860        };
861        // With bypass_available, write tool is not blocked at plan step;
862        // falls through to Passthrough (no rules configured).
863        let result = config.check_static("write_file", false, None);
864        assert!(!result.is_denied());
865        assert!(matches!(result, Permission::Unknown));
866    }
867
868    /// bypass_skips_deny_rules: mode=BypassPermissions, tool in deny list → Allowed
869    #[test]
870    fn test_bypass_skips_deny_rules() {
871        let config = LayeredPermissionsConfig {
872            mode: PermissionMode::BypassPermissions,
873            layers: vec![PermissionLayer {
874                source: RuleSource::User,
875                deny: vec!["run_shell".into()],
876                ..Default::default()
877            }],
878        };
879        let result = config.check_static("run_shell", false, None);
880        assert!(result.is_allowed());
881        if let Permission::Allowed(reason) = result {
882            assert!(matches!(
883                reason,
884                DecisionReason::Mode(PermissionMode::BypassPermissions)
885            ));
886        } else {
887            panic!("expected Allowed");
888        }
889    }
890
891    /// dontask_converts_interactive: mode=DontAsk, tool in interactive list → Denied
892    #[test]
893    fn test_dontask_converts_interactive() {
894        let config = LayeredPermissionsConfig {
895            mode: PermissionMode::DontAsk,
896            layers: vec![PermissionLayer {
897                source: RuleSource::User,
898                interactive: vec!["run_shell".into()],
899                ..Default::default()
900            }],
901        };
902        let result = config.check_static("run_shell", false, None);
903        assert!(result.is_denied());
904        if let Permission::Denied(reason, msg) = result {
905            assert!(matches!(
906                reason,
907                DecisionReason::Mode(PermissionMode::DontAsk)
908            ));
909            assert!(msg.contains("dontAsk"));
910        } else {
911            panic!("expected Denied");
912        }
913    }
914
915    /// accept_edits_allows_write: mode=AcceptEdits, is_readonly=false → Allowed
916    #[test]
917    fn test_accept_edits_allows_write() {
918        let config = LayeredPermissionsConfig {
919            mode: PermissionMode::AcceptEdits,
920            ..Default::default()
921        };
922        let result = config.check_static("write_file", false, None);
923        assert!(result.is_allowed());
924        if let Permission::Allowed(reason) = result {
925            assert!(matches!(
926                reason,
927                DecisionReason::Mode(PermissionMode::AcceptEdits)
928            ));
929        } else {
930            panic!("expected Allowed");
931        }
932    }
933
934    /// deny_rule_takes_effect: mode=Default, tool in deny list → Denied(Rule)
935    #[test]
936    fn test_deny_rule_takes_effect() {
937        let config = LayeredPermissionsConfig {
938            mode: PermissionMode::Default,
939            layers: vec![PermissionLayer {
940                source: RuleSource::User,
941                deny: vec!["run_shell".into()],
942                ..Default::default()
943            }],
944        };
945        let result = config.check_static("run_shell", true, None);
946        assert!(result.is_denied());
947        if let Permission::Denied(reason, _msg) = result {
948            assert!(matches!(
949                reason,
950                DecisionReason::Rule {
951                    source: RuleSource::User,
952                    ..
953                }
954            ));
955        } else {
956            panic!("expected Denied");
957        }
958    }
959
960    /// allow_rule_takes_effect: mode=Default, tool in allow list → Allowed(Rule)
961    #[test]
962    fn test_allow_rule_takes_effect() {
963        let config = LayeredPermissionsConfig {
964            mode: PermissionMode::Default,
965            layers: vec![PermissionLayer {
966                source: RuleSource::User,
967                allow: vec!["read_file".into()],
968                ..Default::default()
969            }],
970        };
971        let result = config.check_static("read_file", true, None);
972        assert!(result.is_allowed());
973        if let Permission::Allowed(reason) = result {
974            assert!(matches!(
975                reason,
976                DecisionReason::Rule {
977                    source: RuleSource::User,
978                    ..
979                }
980            ));
981        } else {
982            panic!("expected Allowed");
983        }
984    }
985
986    // ── check_static: read-only tools in plan mode ──────────────────────
987
988    #[test]
989    fn test_plan_mode_allows_readonly() {
990        let config = LayeredPermissionsConfig {
991            mode: PermissionMode::Plan {
992                pre_plan_mode: Box::new(PermissionMode::Default),
993                bypass_available: false,
994            },
995            ..Default::default()
996        };
997        // read-only tools are not blocked by plan mode
998        let result = config.check_static("read_file", true, None);
999        assert!(!result.is_denied());
1000    }
1001
1002    // ── any_interactive helper ───────────────────────────────────────────
1003
1004    #[test]
1005    fn test_any_interactive_detects_match() {
1006        let config = LayeredPermissionsConfig {
1007            layers: vec![PermissionLayer {
1008                source: RuleSource::User,
1009                interactive: vec!["run_*".into()],
1010                ..Default::default()
1011            }],
1012            ..Default::default()
1013        };
1014        assert!(config.any_interactive("run_shell"));
1015        assert!(!config.any_interactive("read_file"));
1016    }
1017
1018    // ── LayeredPermissionsConfig: basic layer tests ──────────────────────
1019
1020    #[test]
1021    fn test_empty_config_passthrough() {
1022        let config = LayeredPermissionsConfig::default();
1023        // Default mode with no layers: Passthrough
1024        let result = config.check_static("anything", false, None);
1025        assert!(matches!(result, Permission::Unknown));
1026    }
1027
1028    #[test]
1029    fn test_single_layer_deny() {
1030        let config = LayeredPermissionsConfig {
1031            layers: vec![PermissionLayer {
1032                source: RuleSource::User,
1033                deny: vec!["run_shell".into()],
1034                ..Default::default()
1035            }],
1036            ..Default::default()
1037        };
1038        assert!(config.check_static("run_shell", false, None).is_denied());
1039        assert!(!config.check_static("read_file", false, None).is_denied());
1040    }
1041
1042    #[test]
1043    fn test_single_layer_allow() {
1044        let config = LayeredPermissionsConfig {
1045            layers: vec![PermissionLayer {
1046                source: RuleSource::User,
1047                allow: vec!["read_file".into()],
1048                ..Default::default()
1049            }],
1050            ..Default::default()
1051        };
1052        assert!(config.check_static("read_file", false, None).is_allowed());
1053        assert!(matches!(
1054            config.check_static("run_shell", false, None),
1055            Permission::Unknown
1056        ));
1057    }
1058
1059    #[test]
1060    fn test_is_interactive_layer_match() {
1061        let config = LayeredPermissionsConfig {
1062            layers: vec![PermissionLayer {
1063                source: RuleSource::User,
1064                interactive: vec!["run_shell".into()],
1065                ..Default::default()
1066            }],
1067            ..Default::default()
1068        };
1069        assert!(config.is_interactive("run_shell"));
1070        assert!(!config.is_interactive("read_file"));
1071    }
1072
1073    // ── Multi-layer merging ───────────────────────────────────────────────
1074
1075    #[test]
1076    fn test_deny_wins_across_layers() {
1077        let config = LayeredPermissionsConfig {
1078            layers: vec![
1079                PermissionLayer {
1080                    source: RuleSource::Project,
1081                    deny: vec!["run_shell".into()],
1082                    ..Default::default()
1083                },
1084                PermissionLayer {
1085                    source: RuleSource::User,
1086                    allow: vec!["run_shell".into()],
1087                    ..Default::default()
1088                },
1089            ],
1090            ..Default::default()
1091        };
1092        assert!(config.check_static("run_shell", false, None).is_denied());
1093    }
1094
1095    #[test]
1096    fn test_allow_union_across_layers() {
1097        // User layer allows read_file → allowed
1098        let config = LayeredPermissionsConfig {
1099            layers: vec![
1100                PermissionLayer {
1101                    source: RuleSource::Project,
1102                    allow: vec!["write_file".into()],
1103                    ..Default::default()
1104                },
1105                PermissionLayer {
1106                    source: RuleSource::User,
1107                    allow: vec!["read_file".into()],
1108                    ..Default::default()
1109                },
1110            ],
1111            ..Default::default()
1112        };
1113        assert!(config.check_static("read_file", true, None).is_allowed());
1114        assert!(config.check_static("write_file", false, None).is_allowed());
1115    }
1116
1117    #[test]
1118    fn test_interactive_union() {
1119        let config = LayeredPermissionsConfig {
1120            layers: vec![
1121                PermissionLayer {
1122                    source: RuleSource::Project,
1123                    interactive: vec!["write_file".into()],
1124                    ..Default::default()
1125                },
1126                PermissionLayer {
1127                    source: RuleSource::User,
1128                    interactive: vec!["run_shell".into()],
1129                    ..Default::default()
1130                },
1131            ],
1132            ..Default::default()
1133        };
1134        assert!(config.is_interactive("run_shell"));
1135        assert!(config.is_interactive("write_file"));
1136        assert!(!config.is_interactive("read_file"));
1137    }
1138
1139    #[test]
1140    fn test_session_layer_present() {
1141        let config = LayeredPermissionsConfig {
1142            layers: vec![
1143                PermissionLayer {
1144                    source: RuleSource::Session,
1145                    ..Default::default()
1146                },
1147                PermissionLayer {
1148                    source: RuleSource::Project,
1149                    allow: vec!["read_file".into()],
1150                    ..Default::default()
1151                },
1152            ],
1153            ..Default::default()
1154        };
1155        // Session layer has empty allow → doesn't restrict
1156        assert!(config.check_static("read_file", true, None).is_allowed());
1157    }
1158
1159    #[test]
1160    fn test_deny_overrides_allow_same_layer() {
1161        let config = LayeredPermissionsConfig {
1162            layers: vec![PermissionLayer {
1163                source: RuleSource::User,
1164                allow: vec!["run_shell".into()],
1165                deny: vec!["run_shell".into()],
1166                ..Default::default()
1167            }],
1168            ..Default::default()
1169        };
1170        assert!(config.check_static("run_shell", false, None).is_denied());
1171    }
1172
1173    #[test]
1174    fn test_wildcard_matches_prefix() {
1175        let config = LayeredPermissionsConfig {
1176            layers: vec![PermissionLayer {
1177                source: RuleSource::User,
1178                allow: vec!["run_*".into()],
1179                ..Default::default()
1180            }],
1181            ..Default::default()
1182        };
1183        assert!(config.check_static("run_shell", false, None).is_allowed());
1184        assert!(config
1185            .check_static("run_background", false, None)
1186            .is_allowed());
1187        assert!(matches!(
1188            config.check_static("read_file", false, None),
1189            Permission::Unknown
1190        ));
1191    }
1192
1193    #[test]
1194    fn test_wildcard_exact() {
1195        let config = LayeredPermissionsConfig {
1196            layers: vec![PermissionLayer {
1197                source: RuleSource::User,
1198                allow: vec!["*".into()],
1199                ..Default::default()
1200            }],
1201            ..Default::default()
1202        };
1203        assert!(config.check_static("anything", false, None).is_allowed());
1204        assert!(config.check_static("", false, None).is_allowed());
1205    }
1206
1207    #[test]
1208    fn test_deny_with_wildcard() {
1209        let config = LayeredPermissionsConfig {
1210            layers: vec![PermissionLayer {
1211                source: RuleSource::User,
1212                allow: vec!["*".into()],
1213                deny: vec!["run_*".into()],
1214                ..Default::default()
1215            }],
1216            ..Default::default()
1217        };
1218        assert!(config.check_static("read_file", true, None).is_allowed());
1219        assert!(config.check_static("run_shell", false, None).is_denied());
1220    }
1221
1222    #[test]
1223    fn test_matches_pattern_exact() {
1224        assert!(matches_pattern("run_shell", "run_shell"));
1225        assert!(!matches_pattern("run_shell", "run_background"));
1226    }
1227
1228    #[test]
1229    fn test_matches_pattern_wildcard() {
1230        assert!(matches_pattern("run_*", "run_shell"));
1231        assert!(matches_pattern("run_*", "run_background"));
1232        assert!(!matches_pattern("run_*", "read_file"));
1233    }
1234
1235    #[test]
1236    fn test_matches_pattern_star_only() {
1237        assert!(matches_pattern("*", "anything"));
1238        assert!(matches_pattern("*", ""));
1239    }
1240
1241    #[test]
1242    fn test_is_plan_mode_when_mode_is_plan() {
1243        let config = LayeredPermissionsConfig {
1244            mode: PermissionMode::Plan {
1245                pre_plan_mode: Box::new(PermissionMode::Default),
1246                bypass_available: false,
1247            },
1248            ..Default::default()
1249        };
1250        assert!(config.is_plan_mode("anything"));
1251    }
1252
1253    #[test]
1254    fn test_is_plan_mode_false_when_default() {
1255        let config = LayeredPermissionsConfig::default();
1256        assert!(!config.is_plan_mode("anything"));
1257    }
1258
1259    // ── Backward compat: OldPermissionsConfig → LayeredPermissionsConfig ──
1260
1261    #[test]
1262    fn test_old_config_converts_to_layered() {
1263        let old = OldPermissionsConfig {
1264            allow: vec!["read_file".into()],
1265            deny: vec!["run_shell".into()],
1266            interactive: vec!["write_file".into()],
1267            plan: vec![],
1268            mode: PermissionMode::Default,
1269        };
1270        let layered: LayeredPermissionsConfig = old.into();
1271        assert_eq!(layered.layers.len(), 1);
1272        assert_eq!(layered.layers[0].source, RuleSource::User);
1273        assert_eq!(layered.layers[0].allow, vec!["read_file"]);
1274        assert_eq!(layered.layers[0].deny, vec!["run_shell"]);
1275        assert_eq!(layered.layers[0].interactive, vec!["write_file"]);
1276    }
1277
1278    #[test]
1279    fn test_old_config_empty_allow_produces_no_layer() {
1280        let old = OldPermissionsConfig::default();
1281        let layered: LayeredPermissionsConfig = old.into();
1282        assert_eq!(layered.layers.len(), 0);
1283    }
1284
1285    // ── all_deny / all_allow / all_interactive ────────────────────────────
1286
1287    #[test]
1288    fn test_all_deny_union() {
1289        let config = LayeredPermissionsConfig {
1290            layers: vec![
1291                PermissionLayer {
1292                    source: RuleSource::Project,
1293                    deny: vec!["write_file".into()],
1294                    ..Default::default()
1295                },
1296                PermissionLayer {
1297                    source: RuleSource::User,
1298                    deny: vec!["run_shell".into()],
1299                    ..Default::default()
1300                },
1301            ],
1302            ..Default::default()
1303        };
1304        let denies: Vec<&str> = config.all_deny().collect();
1305        assert!(denies.contains(&"write_file"));
1306        assert!(denies.contains(&"run_shell"));
1307        assert_eq!(denies.len(), 2);
1308    }
1309
1310    #[test]
1311    fn test_all_allow_union() {
1312        let config = LayeredPermissionsConfig {
1313            layers: vec![
1314                PermissionLayer {
1315                    source: RuleSource::Project,
1316                    allow: vec!["read_file".into()],
1317                    ..Default::default()
1318                },
1319                PermissionLayer {
1320                    source: RuleSource::User,
1321                    allow: vec!["write_file".into()],
1322                    ..Default::default()
1323                },
1324            ],
1325            ..Default::default()
1326        };
1327        let allows: Vec<&str> = config.all_allow().collect();
1328        assert!(allows.contains(&"read_file"));
1329        assert!(allows.contains(&"write_file"));
1330        assert_eq!(allows.len(), 2);
1331    }
1332
1333    #[test]
1334    fn test_all_interactive_union() {
1335        let config = LayeredPermissionsConfig {
1336            layers: vec![
1337                PermissionLayer {
1338                    source: RuleSource::Project,
1339                    interactive: vec!["run_shell".into()],
1340                    ..Default::default()
1341                },
1342                PermissionLayer {
1343                    source: RuleSource::User,
1344                    interactive: vec!["write_file".into()],
1345                    ..Default::default()
1346                },
1347            ],
1348            ..Default::default()
1349        };
1350        let interactives: Vec<&str> = config.all_interactive().collect();
1351        assert!(interactives.contains(&"run_shell"));
1352        assert!(interactives.contains(&"write_file"));
1353        assert_eq!(interactives.len(), 2);
1354    }
1355
1356    // ── DecisionReason + Permission helpers ──────────────────────────────
1357
1358    #[test]
1359    fn permission_is_allowed_helper() {
1360        let reason = DecisionReason::Mode(PermissionMode::Default);
1361        assert!(Permission::Allowed(reason.clone()).is_allowed());
1362        assert!(!Permission::Allowed(reason).is_denied());
1363    }
1364
1365    #[test]
1366    fn permission_is_denied_helper() {
1367        let reason = DecisionReason::Mode(PermissionMode::DontAsk);
1368        assert!(Permission::Denied(reason.clone(), "blocked".into()).is_denied());
1369        assert!(!Permission::Denied(reason, "blocked".into()).is_allowed());
1370    }
1371
1372    #[test]
1373    fn passthrough_is_neither() {
1374        assert!(!Permission::Unknown.is_allowed());
1375        assert!(!Permission::Unknown.is_denied());
1376    }
1377
1378    #[test]
1379    fn decision_reason_rule_debug() {
1380        let reason = DecisionReason::Rule {
1381            source: RuleSource::User,
1382            pattern: "run_shell".into(),
1383        };
1384        let debug = format!("{:?}", reason);
1385        assert!(debug.contains("Rule"));
1386        assert!(debug.contains("User"));
1387        assert!(debug.contains("run_shell"));
1388    }
1389
1390    #[test]
1391    fn decision_reason_mode_debug() {
1392        let reason = DecisionReason::Mode(PermissionMode::DontAsk);
1393        let debug = format!("{:?}", reason);
1394        assert!(debug.contains("DontAsk"));
1395    }
1396
1397    #[test]
1398    fn decision_reason_hook_debug() {
1399        let reason = DecisionReason::Hook {
1400            name: "my_hook".into(),
1401        };
1402        let debug = format!("{:?}", reason);
1403        assert!(debug.contains("Hook"));
1404        assert!(debug.contains("my_hook"));
1405    }
1406
1407    #[test]
1408    fn decision_reason_safety_check_debug() {
1409        let reason = DecisionReason::SafetyCheck {
1410            path: "/etc/passwd".into(),
1411        };
1412        let debug = format!("{:?}", reason);
1413        assert!(debug.contains("SafetyCheck"));
1414        assert!(debug.contains("/etc/passwd"));
1415    }
1416
1417    #[test]
1418    fn check_static_deny_returns_rule_reason() {
1419        let config = LayeredPermissionsConfig {
1420            layers: vec![PermissionLayer {
1421                source: RuleSource::User,
1422                deny: vec!["run_shell".into()],
1423                ..Default::default()
1424            }],
1425            ..Default::default()
1426        };
1427        let result = config.check_static("run_shell", false, None);
1428        assert!(result.is_denied());
1429        if let Permission::Denied(reason, msg) = result {
1430            assert!(matches!(
1431                reason,
1432                DecisionReason::Rule {
1433                    source: RuleSource::User,
1434                    ..
1435                }
1436            ));
1437            assert!(msg.contains("run_shell"));
1438        } else {
1439            panic!("expected Denied");
1440        }
1441    }
1442
1443    // ── Goal-196: Safety path protection tests ───────────────────────────
1444
1445    #[test]
1446    fn protected_path_denied_in_default_mode() {
1447        let config = LayeredPermissionsConfig::default();
1448        let result = config.check_static("write_file", false, Some(".git/config"));
1449        assert!(result.is_denied());
1450        if let Permission::Denied(DecisionReason::SafetyCheck { path }, msg) = &result {
1451            assert_eq!(path, ".git/config");
1452            assert!(msg.contains("protected"));
1453        } else {
1454            panic!("expected Denied(SafetyCheck), got {result:?}");
1455        }
1456    }
1457
1458    #[test]
1459    fn protected_path_denied_in_bypass_mode() {
1460        let config = LayeredPermissionsConfig {
1461            mode: PermissionMode::BypassPermissions,
1462            ..Default::default()
1463        };
1464        let result = config.check_static("write_file", false, Some(".ssh/id_rsa"));
1465        assert!(result.is_denied());
1466        if let Permission::Denied(DecisionReason::SafetyCheck { path }, msg) = &result {
1467            assert_eq!(path, ".ssh/id_rsa");
1468            assert!(msg.contains("protected"));
1469        } else {
1470            panic!("expected Denied(SafetyCheck), got {result:?}");
1471        }
1472    }
1473
1474    #[test]
1475    fn protected_path_readonly_allowed() {
1476        let config = LayeredPermissionsConfig::default();
1477        // Read-only tools are exempt from the safety check
1478        let result = config.check_static("read_file", true, Some(".git/config"));
1479        assert!(matches!(result, Permission::Unknown));
1480    }
1481
1482    #[test]
1483    fn non_protected_path_not_blocked() {
1484        let config = LayeredPermissionsConfig::default();
1485        let result = config.check_static("write_file", false, Some("src/main.rs"));
1486        assert!(matches!(result, Permission::Unknown));
1487    }
1488
1489    #[test]
1490    fn nested_protected_path_detected() {
1491        let config = LayeredPermissionsConfig::default();
1492        let result =
1493            config.check_static("write_file", false, Some("some/dir/.recursive/config.toml"));
1494        assert!(result.is_denied());
1495        if let Permission::Denied(DecisionReason::SafetyCheck { path }, _) = &result {
1496            assert_eq!(path, "some/dir/.recursive/config.toml");
1497        } else {
1498            panic!("expected Denied(SafetyCheck), got {result:?}");
1499        }
1500    }
1501
1502    #[test]
1503    fn path_contains_protected_fn() {
1504        // Direct match
1505        assert!(path_contains_protected(".git/config", ".git"));
1506        assert!(path_contains_protected("a/.git/config", ".git"));
1507        // Nested match
1508        assert!(path_contains_protected(
1509            "some/dir/.recursive/config.toml",
1510            ".recursive"
1511        ));
1512        // No false positive on legitimate.git_info
1513        assert!(!path_contains_protected("legitimate.git_info/x", ".git"));
1514        // .env matches exactly, not .env.example
1515        assert!(path_contains_protected(".env", ".env"));
1516        assert!(!path_contains_protected(".env.example", ".env"));
1517        // Empty path
1518        assert!(!path_contains_protected("", ".git"));
1519        // Root-level match
1520        assert!(path_contains_protected(".ssh/authorized_keys", ".ssh"));
1521    }
1522
1523    // ── Goal-197: Session rule management tests ─────────────────────────
1524
1525    #[test]
1526    fn add_session_allow_rule() {
1527        let mut config = LayeredPermissionsConfig::default();
1528        config.add_session_rule(RuleBehavior::Allow, "run_shell".into());
1529        let session = config.session_rules();
1530        assert_eq!(session.source, RuleSource::Session);
1531        assert!(session.allow.contains(&"run_shell".to_string()));
1532        assert!(session.deny.is_empty());
1533        assert!(session.interactive.is_empty());
1534    }
1535
1536    #[test]
1537    fn add_session_deny_rule() {
1538        let mut config = LayeredPermissionsConfig::default();
1539        config.add_session_rule(RuleBehavior::Deny, "run_shell".into());
1540        let session = config.session_rules();
1541        assert!(session.deny.contains(&"run_shell".to_string()));
1542    }
1543
1544    #[test]
1545    fn add_session_interactive_rule() {
1546        let mut config = LayeredPermissionsConfig::default();
1547        config.add_session_rule(RuleBehavior::Interactive, "run_shell".into());
1548        let session = config.session_rules();
1549        assert!(session.interactive.contains(&"run_shell".to_string()));
1550    }
1551
1552    #[test]
1553    fn remove_session_rule() {
1554        let mut config = LayeredPermissionsConfig::default();
1555        config.add_session_rule(RuleBehavior::Allow, "run_shell".into());
1556        config.add_session_rule(RuleBehavior::Allow, "write_file".into());
1557        assert_eq!(config.session_rules().allow.len(), 2);
1558
1559        config.remove_session_rule(RuleBehavior::Allow, "run_shell");
1560        assert_eq!(config.session_rules().allow.len(), 1);
1561        assert!(config
1562            .session_rules()
1563            .allow
1564            .contains(&"write_file".to_string()));
1565    }
1566
1567    #[test]
1568    fn session_layer_created_on_first_use() {
1569        let mut config = LayeredPermissionsConfig::default();
1570        // Before any mutation, layers is empty
1571        assert!(config.layers.is_empty());
1572        // Adding a rule creates the session layer
1573        config.add_session_rule(RuleBehavior::Allow, "read_file".into());
1574        assert_eq!(config.layers.len(), 1);
1575        assert_eq!(config.layers[0].source, RuleSource::Session);
1576    }
1577
1578    #[test]
1579    fn session_deny_takes_precedence_over_user_allow() {
1580        let mut config = LayeredPermissionsConfig {
1581            mode: PermissionMode::Default,
1582            layers: vec![PermissionLayer {
1583                source: RuleSource::User,
1584                allow: vec!["run_shell".into()],
1585                ..Default::default()
1586            }],
1587        };
1588        // Session deny should override user allow
1589        config.add_session_rule(RuleBehavior::Deny, "run_shell".into());
1590        let result = config.check_static("run_shell", false, None);
1591        assert!(result.is_denied());
1592    }
1593
1594    #[test]
1595    fn shared_permissions_arc_clone_sees_mutation() {
1596        let config = LayeredPermissionsConfig::default();
1597        let shared = Arc::new(RwLock::new(config));
1598        let clone = Arc::clone(&shared);
1599
1600        // Mutate through the original
1601        {
1602            let mut guard = shared.try_write().unwrap();
1603            guard.add_session_rule(RuleBehavior::Allow, "run_shell".into());
1604        }
1605
1606        // Clone sees the change
1607        let guard = clone.try_read().unwrap();
1608        assert!(guard
1609            .session_rules()
1610            .allow
1611            .contains(&"run_shell".to_string()));
1612    }
1613}