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