Skip to main content

navi_core/
security.rs

1use crate::config::{PermissionMode, SecurityConfig};
2use crate::effect::{BlastRadius, EffectAnalyzer, PostDecision};
3use crate::event::{AgentEvent, ApprovalRequest, SubagentTranscriptItem};
4use crate::patch::PatchProposal;
5use crate::session::ProjectMemory;
6use crate::tool::{ToolDefinition, ToolInvocation, ToolKind, ToolResult};
7use anyhow::{Context, Result};
8use serde_json::Value;
9use std::path::{Component, Path, PathBuf};
10
11/// Validates tool invocations against security constraints: path restrictions,
12/// blocked commands, `.git` protection, and NAVI private storage.
13#[derive(Debug, Clone)]
14pub struct SecurityPolicy {
15    project_root: PathBuf,
16    data_dir: PathBuf,
17    config: SecurityConfig,
18}
19
20/// The outcome of a security validation check.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum SecurityDecision {
23    /// The invocation is allowed without user confirmation.
24    Allow,
25    /// The invocation requires explicit user approval due to the identified risk.
26    NeedsApproval(SecurityRisk),
27    /// The invocation is denied with an explanation.
28    Deny(String),
29}
30
31/// The kind of risk identified by a security check.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum SecurityRisk {
34    /// Any tool execution in restricted mode.
35    Tool,
36    /// A write operation that modifies the filesystem.
37    Write,
38    /// A shell command execution.
39    Command,
40    /// A guarded command that requires explicit approval outside YOLO mode
41    /// (e.g. destructive `git` operations such as `git push` / `git rebase`).
42    GuardedCommand,
43    /// Loading an external native plugin.
44    ExternalPlugin,
45}
46
47impl SecurityPolicy {
48    /// Creates a new policy from the project root, data directory, and security config.
49    pub fn new(project_root: PathBuf, data_dir: PathBuf, config: SecurityConfig) -> Result<Self> {
50        Ok(Self {
51            project_root: normalize_existing_or_parent(&project_root)
52                .with_context(|| format!("failed to resolve {}", project_root.display()))?,
53            data_dir: normalize_existing_or_parent(&data_dir)
54                .with_context(|| format!("failed to resolve {}", data_dir.display()))?,
55            config,
56        })
57    }
58
59    /// Validates a file path, checking project restrictions, `.git` protection,
60    /// and NAVI private storage.
61    pub fn validate_path(&self, path: &Path, write: bool) -> SecurityDecision {
62        let path = self.resolve_project_path(path);
63        let Ok(path) = normalize_existing_or_parent(&path) else {
64            return SecurityDecision::Deny(format!("failed to resolve {}", path.display()));
65        };
66
67        if self.paths_restricted_to_project()
68            && !path.starts_with(&self.project_root)
69            && !path.starts_with(self.data_dir.join("plugins"))
70        {
71            return SecurityDecision::Deny(format!(
72                "path {} is outside project {}",
73                path.display(),
74                self.project_root.display()
75            ));
76        }
77
78        if contains_component(&path, ".agent-memory") {
79            return SecurityDecision::Deny(format!(
80                "project-local .agent-memory is not supported; NAVI memory lives under {}",
81                self.data_dir.display()
82            ));
83        }
84
85        if self.is_data_dir_private_path(&path) {
86            return SecurityDecision::Deny(format!(
87                "path {} is inside NAVI private storage",
88                path.display()
89            ));
90        }
91
92        if write && self.config.protect_git_metadata && contains_component(&path, ".git") {
93            return SecurityDecision::Deny(format!(
94                "writes to git metadata are blocked: {}",
95                path.display()
96            ));
97        }
98
99        // Deny list: block reads of wasteful/sensitive paths.
100        if !write && self.is_path_denied(&path) {
101            return SecurityDecision::Deny(format!(
102                "path {} is on the deny list (wasteful or sensitive)",
103                path.display()
104            ));
105        }
106
107        if write {
108            SecurityDecision::NeedsApproval(SecurityRisk::Write)
109        } else {
110            SecurityDecision::Allow
111        }
112    }
113
114    /// Validates all paths in a patch proposal.
115    pub fn validate_patch(&self, patch: &PatchProposal) -> SecurityDecision {
116        for file in &patch.files {
117            match self.validate_path(file, true) {
118                SecurityDecision::Allow | SecurityDecision::NeedsApproval(SecurityRisk::Write) => {}
119                decision => return decision,
120            }
121        }
122        SecurityDecision::NeedsApproval(SecurityRisk::Write)
123    }
124
125    /// Validates a command against the blocked-commands list, guarded-commands
126    /// list, and approval config.
127    pub fn validate_command(&self, program: &str) -> SecurityDecision {
128        let command = command_name(program);
129        if self
130            .config
131            .blocked_commands
132            .iter()
133            .any(|blocked| blocked == command)
134        {
135            return SecurityDecision::Deny(format!("command `{command}` is blocked"));
136        }
137
138        if self.is_guarded_command(program, command) {
139            return SecurityDecision::NeedsApproval(SecurityRisk::GuardedCommand);
140        }
141
142        for target in extract_shell_path_mentions(program) {
143            if is_dynamic_shell_target(&target) {
144                continue;
145            }
146            let path = self.resolve_project_path(Path::new(&target));
147            let Ok(path) = normalize_existing_or_parent(&path) else {
148                continue;
149            };
150            if self.is_data_dir_private_path(&path) {
151                return SecurityDecision::Deny(format!(
152                    "command references NAVI private storage: {}",
153                    path.display()
154                ));
155            }
156        }
157
158        for target in extract_shell_write_targets(program) {
159            if is_dynamic_shell_target(&target) {
160                return SecurityDecision::Deny(format!(
161                    "command writes to an unresolved shell-expanded path: {target}"
162                ));
163            }
164            if let SecurityDecision::Deny(reason) = self.validate_path(Path::new(&target), true) {
165                return SecurityDecision::Deny(format!(
166                    "command writes to a denied path via shell redirection: {reason}"
167                ));
168            }
169        }
170
171        SecurityDecision::NeedsApproval(SecurityRisk::Command)
172    }
173
174    /// Validates a plugin library path, requiring approval unless external plugins
175    /// are explicitly allowed.
176    pub fn validate_plugin_path(&self, path: &Path) -> SecurityDecision {
177        let Ok(path) = normalize_existing_or_parent(path) else {
178            return SecurityDecision::Deny(format!("failed to resolve {}", path.display()));
179        };
180
181        if self.config.allow_external_plugins {
182            return SecurityDecision::NeedsApproval(SecurityRisk::ExternalPlugin);
183        }
184
185        let project_plugin_dir = self.project_root.join(".navi").join("plugins");
186        let data_plugin_dir = self.data_dir.join("plugins");
187        if path.starts_with(project_plugin_dir) || path.starts_with(data_plugin_dir) {
188            SecurityDecision::NeedsApproval(SecurityRisk::ExternalPlugin)
189        } else {
190            SecurityDecision::Deny(format!(
191                "plugin {} is outside trusted plugin directories",
192                path.display()
193            ))
194        }
195    }
196
197    /// Validates an MCP server id against the configured allowlist.
198    ///
199    /// When `allowlist` is non-empty, only server ids present in the list
200    /// are allowed. An empty allowlist permits all MCP servers.
201    pub fn validate_mcp_server(&self, server_id: &str) -> SecurityDecision {
202        if self.config.is_mcp_server_allowed(server_id) {
203            SecurityDecision::Allow
204        } else {
205            SecurityDecision::Deny(format!("MCP server `{server_id}` is not in the allowlist"))
206        }
207    }
208
209    /// Validates a tool invocation by dispatching to the appropriate validator
210    /// based on tool kind.
211    pub fn validate_tool_invocation(
212        &self,
213        definition: &ToolDefinition,
214        invocation: &ToolInvocation,
215    ) -> SecurityDecision {
216        if let Some(decision) = self.tool_rule_decision(definition, invocation) {
217            return decision;
218        }
219
220        let base_decision = match definition.kind {
221            ToolKind::Read => self
222                .path_from_invocation(invocation)
223                .map(|path| self.validate_path(&path, false))
224                .unwrap_or(SecurityDecision::Allow),
225            ToolKind::Write => {
226                if definition.name == "apply_patch" || definition.name == "write" {
227                    self.validate_apply_patch_invocation(invocation)
228                } else {
229                    self.path_from_invocation(invocation)
230                        .map(|path| self.validate_path(&path, true))
231                        .unwrap_or(SecurityDecision::NeedsApproval(SecurityRisk::Write))
232                }
233            }
234            ToolKind::Command => {
235                if let Some(cwd) = invocation.input.get("cwd").and_then(Value::as_str)
236                    && let SecurityDecision::Deny(reason) =
237                        self.validate_path(Path::new(cwd), false)
238                {
239                    SecurityDecision::Deny(format!("command cwd is denied: {reason}"))
240                } else if definition.name == "bash"
241                    && (invocation.input.get("task_id").is_some()
242                        || invocation.input.get("action").and_then(Value::as_str) == Some("list"))
243                {
244                    SecurityDecision::Allow
245                } else if definition.name == "browser" {
246                    // status/doctor are local probes; navigation needs approval by default.
247                    match invocation.input.get("action").and_then(Value::as_str) {
248                        Some("status" | "doctor") => SecurityDecision::Allow,
249                        _ => SecurityDecision::NeedsApproval(SecurityRisk::Command),
250                    }
251                } else if definition.name == "mark_feature_done" {
252                    self.validate_verification_steps(invocation)
253                } else {
254                    invocation
255                        .input
256                        .get("program")
257                        .or_else(|| invocation.input.get("command"))
258                        .and_then(Value::as_str)
259                        .map(|program| self.validate_command(program))
260                        .unwrap_or(SecurityDecision::NeedsApproval(SecurityRisk::Command))
261                }
262            }
263            ToolKind::Custom => SecurityDecision::NeedsApproval(SecurityRisk::ExternalPlugin),
264        };
265
266        self.apply_permission_mode(definition, base_decision)
267    }
268
269    fn tool_rule_decision(
270        &self,
271        definition: &ToolDefinition,
272        invocation: &ToolInvocation,
273    ) -> Option<SecurityDecision> {
274        let name = invocation.tool_name.as_str();
275        if matches_tool_rule(name, &self.config.deny_tools, &self.config.deny_tool_regex) {
276            return Some(SecurityDecision::Deny(format!(
277                "tool `{}` is denied by security.tool policy",
278                name
279            )));
280        }
281        if let Some(err) = first_invalid_regex(&self.config.deny_tool_regex)
282            .or_else(|| first_invalid_regex(&self.config.allow_tool_regex))
283            .or_else(|| first_invalid_regex(&self.config.ask_tool_regex))
284        {
285            return Some(err);
286        }
287
288        if matches_tool_rule(
289            name,
290            &self.config.allow_tools,
291            &self.config.allow_tool_regex,
292        ) {
293            return Some(self.safety_only_decision(definition, invocation, true));
294        }
295
296        if matches_tool_rule(name, &self.config.ask_tools, &self.config.ask_tool_regex) {
297            return Some(
298                match self.safety_only_decision(definition, invocation, false) {
299                    SecurityDecision::Deny(reason) => SecurityDecision::Deny(reason),
300                    SecurityDecision::Allow | SecurityDecision::NeedsApproval(_) => {
301                        SecurityDecision::NeedsApproval(risk_for_tool_kind(definition.kind))
302                    }
303                },
304            );
305        }
306
307        None
308    }
309
310    fn safety_only_decision(
311        &self,
312        definition: &ToolDefinition,
313        invocation: &ToolInvocation,
314        allow_after_safety: bool,
315    ) -> SecurityDecision {
316        let decision = match definition.kind {
317            ToolKind::Read => self
318                .path_from_invocation(invocation)
319                .map(|path| self.validate_path(&path, false))
320                .unwrap_or(SecurityDecision::Allow),
321            ToolKind::Write => {
322                if definition.name == "apply_patch" || definition.name == "write" {
323                    self.validate_apply_patch_invocation(invocation)
324                } else {
325                    self.path_from_invocation(invocation)
326                        .map(|path| self.validate_path(&path, true))
327                        .unwrap_or(SecurityDecision::NeedsApproval(SecurityRisk::Write))
328                }
329            }
330            ToolKind::Command => {
331                if let Some(cwd) = invocation.input.get("cwd").and_then(Value::as_str)
332                    && let SecurityDecision::Deny(reason) =
333                        self.validate_path(Path::new(cwd), false)
334                {
335                    return SecurityDecision::Deny(format!("command cwd is denied: {reason}"));
336                }
337                if definition.name == "bash"
338                    && (invocation.input.get("task_id").is_some()
339                        || invocation.input.get("action").and_then(Value::as_str) == Some("list"))
340                {
341                    SecurityDecision::Allow
342                } else if definition.name == "browser" {
343                    match invocation.input.get("action").and_then(Value::as_str) {
344                        Some("status" | "doctor") => SecurityDecision::Allow,
345                        _ => SecurityDecision::NeedsApproval(SecurityRisk::Command),
346                    }
347                } else if definition.name == "mark_feature_done" {
348                    self.validate_verification_steps(invocation)
349                } else {
350                    invocation
351                        .input
352                        .get("program")
353                        .or_else(|| invocation.input.get("command"))
354                        .and_then(Value::as_str)
355                        .map(|program| self.validate_command(program))
356                        .unwrap_or(SecurityDecision::NeedsApproval(SecurityRisk::Command))
357                }
358            }
359            ToolKind::Custom => SecurityDecision::NeedsApproval(SecurityRisk::ExternalPlugin),
360        };
361
362        match decision {
363            SecurityDecision::Deny(reason) => SecurityDecision::Deny(reason),
364            SecurityDecision::Allow | SecurityDecision::NeedsApproval(_) if allow_after_safety => {
365                SecurityDecision::Allow
366            }
367            other => other,
368        }
369    }
370
371    fn apply_permission_mode(
372        &self,
373        definition: &ToolDefinition,
374        decision: SecurityDecision,
375    ) -> SecurityDecision {
376        match decision {
377            SecurityDecision::Deny(reason) => SecurityDecision::Deny(reason),
378            SecurityDecision::NeedsApproval(SecurityRisk::GuardedCommand) => {
379                match self.config.permission_mode {
380                    PermissionMode::Yolo => SecurityDecision::Allow,
381                    _ => SecurityDecision::NeedsApproval(SecurityRisk::GuardedCommand),
382                }
383            }
384            SecurityDecision::Allow | SecurityDecision::NeedsApproval(_) => {
385                match self.config.permission_mode {
386                    PermissionMode::Restricted => {
387                        SecurityDecision::NeedsApproval(risk_for_tool_kind(definition.kind))
388                    }
389                    PermissionMode::AcceptEdits => match definition.kind {
390                        ToolKind::Read | ToolKind::Write => SecurityDecision::Allow,
391                        ToolKind::Command => SecurityDecision::NeedsApproval(SecurityRisk::Command),
392                        ToolKind::Custom => {
393                            SecurityDecision::NeedsApproval(SecurityRisk::ExternalPlugin)
394                        }
395                    },
396                    PermissionMode::Auto => SecurityDecision::Allow,
397                    PermissionMode::Yolo => SecurityDecision::Allow,
398                }
399            }
400        }
401    }
402
403    fn path_from_invocation(&self, invocation: &ToolInvocation) -> Option<PathBuf> {
404        invocation
405            .input
406            .get("path")
407            .or_else(|| invocation.input.get("file"))
408            .or_else(|| invocation.input.get("file_path"))
409            .and_then(Value::as_str)
410            .map(|path| self.resolve_project_path(Path::new(path)))
411    }
412
413    fn validate_apply_patch_invocation(&self, invocation: &ToolInvocation) -> SecurityDecision {
414        if invocation
415            .input
416            .get("path")
417            .and_then(Value::as_str)
418            .is_some()
419        {
420            return self
421                .path_from_invocation(invocation)
422                .map(|path| self.validate_path(&path, true))
423                .unwrap_or(SecurityDecision::NeedsApproval(SecurityRisk::Write));
424        }
425
426        let mut patches = Vec::new();
427        if let Some(patch) = invocation.input.get("patch").and_then(Value::as_str) {
428            patches.push(patch);
429        }
430        if let Some(values) = invocation.input.get("patches").and_then(Value::as_array) {
431            patches.extend(values.iter().filter_map(Value::as_str));
432        }
433        if patches.is_empty() {
434            return SecurityDecision::NeedsApproval(SecurityRisk::Write);
435        }
436        let paths = patches
437            .iter()
438            .flat_map(|patch| extract_apply_patch_paths(patch))
439            .collect::<Vec<_>>();
440        if paths.is_empty() {
441            return SecurityDecision::NeedsApproval(SecurityRisk::Write);
442        }
443        for path in paths {
444            match self.validate_path(Path::new(&path), true) {
445                SecurityDecision::Allow | SecurityDecision::NeedsApproval(SecurityRisk::Write) => {}
446                decision => return decision,
447            }
448        }
449        SecurityDecision::NeedsApproval(SecurityRisk::Write)
450    }
451
452    fn validate_verification_steps(&self, invocation: &ToolInvocation) -> SecurityDecision {
453        if let Some(steps) = invocation
454            .input
455            .get("verification_steps")
456            .and_then(Value::as_array)
457        {
458            for command in steps.iter().filter_map(Value::as_str) {
459                if let SecurityDecision::Deny(reason) = self.validate_command(command) {
460                    return SecurityDecision::Deny(reason);
461                }
462            }
463        }
464        SecurityDecision::NeedsApproval(SecurityRisk::Command)
465    }
466
467    /// Performs a post-execution effect check on the paths touched by a tool.
468    ///
469    /// Analyses created, modified, and deleted paths through the
470    /// [`EffectAnalyzer`] and produces a [`PostDecision`] that the harness
471    /// can act on (allow, ask, deny, or roll back).
472    ///
473    /// `tool_name` is used for contextual messaging. `paths` are the filesystem
474    /// paths the tool reported touching. `command` is the shell command string,
475    /// if any (used for context, not analysed here).
476    pub fn post_execution_effect_check(
477        &self,
478        tool_name: &str,
479        paths: &[PathBuf],
480        _command: Option<&str>,
481    ) -> PostDecision {
482        // Classify paths into created / modified / deleted.
483        // We don't have reliable create-vs-modify-vs-delete metadata from the
484        // generic path list, so we conservatively treat all as modified.
485        let report = EffectAnalyzer::analyze(&[], paths, &[]);
486
487        if report.key_files_affected.is_empty() {
488            return PostDecision::Allow;
489        }
490
491        match report.blast_radius {
492            BlastRadius::SecuritySensitive => {
493                let details = report.key_files_affected.join(", ");
494                PostDecision::Rollback(format!(
495                    "{} touched security-sensitive file(s): {details}. \
496                     Modification may expose secrets or credentials.",
497                    tool_name,
498                ))
499            }
500            BlastRadius::CiConfig => {
501                let details = report.key_files_affected.join(", ");
502                PostDecision::Ask(format!(
503                    "{} modified CI configuration: {details}. \
504                     Review before proceeding.",
505                    tool_name,
506                ))
507            }
508            BlastRadius::DependencyChange => {
509                let details = report.key_files_affected.join(", ");
510                PostDecision::Ask(format!(
511                    "{} modified dependency/lockfile(s): {details}. \
512                     This may affect builds across the team.",
513                    tool_name,
514                ))
515            }
516            BlastRadius::MultipleFiles | BlastRadius::SingleFile => PostDecision::Allow,
517        }
518    }
519
520    /// Returns the normalized project root used as the execution sandbox.
521    pub fn project_root(&self) -> &Path {
522        &self.project_root
523    }
524
525    /// Whether file-tool paths must stay inside the project root.
526    ///
527    /// Always enforced in [`PermissionMode::Restricted`]. Outside Restricted,
528    /// only the explicit `restrict_paths_to_project` config flag applies (default
529    /// off), so AcceptEdits / Auto / YOLO keep agent agency unless the user
530    /// opts into a project jail.
531    pub fn paths_restricted_to_project(&self) -> bool {
532        matches!(self.config.permission_mode, PermissionMode::Restricted)
533            || self.config.restrict_paths_to_project
534    }
535
536    /// Returns a reference to the security configuration.
537    pub fn config(&self) -> &SecurityConfig {
538        &self.config
539    }
540
541    /// Replaces the security configuration used by subsequent validations.
542    pub fn set_config(&mut self, config: SecurityConfig) {
543        self.config = config;
544    }
545
546    /// Returns the NAVI data directory used for persistent storage (sessions,
547    /// memory, plans, credentials, logs).
548    pub fn data_dir(&self) -> &Path {
549        &self.data_dir
550    }
551
552    fn is_data_dir_private_path(&self, path: &Path) -> bool {
553        path.starts_with(&self.data_dir) && !path.starts_with(self.data_dir.join("plugins"))
554    }
555
556    /// Whether `program` should be treated as a guarded command.
557    ///
558    /// For `git`, only destructive subcommands (push/rm/reset/rebase/...) are
559    /// guarded. Common operations like `status` / `add` / `commit` are not.
560    fn is_guarded_command(&self, program: &str, command: &str) -> bool {
561        if !self
562            .config
563            .guarded_commands
564            .iter()
565            .any(|guarded| guarded == command)
566        {
567            return false;
568        }
569
570        if command == "git" {
571            return is_destructive_git_command(program);
572        }
573
574        true
575    }
576
577    /// Resolves relative tool paths against the project root instead of the
578    /// process CWD. This keeps SDK/ACP embeddings from accidentally reading or
579    /// writing outside the requested project when NAVI is launched elsewhere.
580    pub fn resolve_project_path(&self, path: &Path) -> PathBuf {
581        if path.is_absolute() {
582            path.to_path_buf()
583        } else {
584            self.project_root.join(path)
585        }
586    }
587
588    /// Returns a copy of the invocation with security-visible path fields made
589    /// absolute under the project root.
590    pub fn normalize_invocation_paths(&self, invocation: &ToolInvocation) -> ToolInvocation {
591        let mut invocation = invocation.clone();
592        if let Value::Object(ref mut map) = invocation.input {
593            for key in ["path", "file", "file_path"] {
594                if let Some(Value::String(value)) = map.get_mut(key) {
595                    let resolved = self.resolve_project_path(Path::new(value));
596                    *value = resolved.display().to_string();
597                }
598            }
599        }
600        invocation
601    }
602
603    /// Check if a path matches any entry in the deny list.
604    ///
605    /// Supports:
606    /// - Directory name prefixes: `"node_modules"` matches `node_modules/foo/bar.js`
607    /// - Glob patterns: `"*.log"` matches `debug.log`
608    /// - Exact path suffixes: `"package-lock.json"` matches `foo/package-lock.json`
609    pub fn is_path_denied(&self, path: &Path) -> bool {
610        if self.config.deny_paths.is_empty() {
611            return false;
612        }
613        let path_str = path.to_string_lossy();
614        let path_lower = path_str.to_lowercase();
615
616        for pattern in &self.config.deny_paths {
617            let pattern_lower = pattern.to_lowercase();
618
619            // Glob pattern: starts with * (e.g. "*.log")
620            if let Some(suffix) = pattern_lower.strip_prefix('*') {
621                if path_lower.ends_with(suffix) {
622                    return true;
623                }
624                continue;
625            }
626
627            // Directory prefix: check if any component matches or path contains it.
628            if path.components().any(|c| {
629                if let Component::Normal(name) = c {
630                    let name_lower = name.to_string_lossy().to_lowercase();
631                    name_lower == pattern_lower
632                } else {
633                    false
634                }
635            }) {
636                return true;
637            }
638
639            // Suffix match: "package-lock.json" matches "foo/package-lock.json"
640            if path_lower.ends_with(&pattern_lower) {
641                return true;
642            }
643        }
644
645        false
646    }
647
648    /// Filter text output by removing lines that reference denied paths.
649    ///
650    /// Used by grep and fs_browser to prevent denied path references from
651    /// entering the LLM context.
652    pub fn filter_denied_lines(&self, text: &str) -> String {
653        if self.config.deny_paths.is_empty() {
654            return text.to_string();
655        }
656
657        let mut output = String::with_capacity(text.len());
658        for line in text.lines() {
659            if !self.line_references_denied_path(line) {
660                output.push_str(line);
661                output.push('\n');
662            }
663        }
664        output
665    }
666
667    /// Check if a single line references any denied path pattern.
668    fn line_references_denied_path(&self, line: &str) -> bool {
669        let line_lower = line.to_lowercase();
670        for pattern in &self.config.deny_paths {
671            let pattern_lower = pattern.to_lowercase();
672
673            if let Some(suffix) = pattern_lower.strip_prefix('*') {
674                // For glob patterns, check if the line contains the suffix.
675                if line_lower.contains(suffix) {
676                    return true;
677                }
678            } else {
679                // For exact patterns, check if the line contains the pattern.
680                if line_lower.contains(&pattern_lower) {
681                    return true;
682                }
683            }
684        }
685        false
686    }
687}
688
689/// Redacts secrets from all events in a session snapshot.
690pub fn redact_snapshot_events(events: &[AgentEvent]) -> Vec<AgentEvent> {
691    events.iter().map(redact_agent_event).collect()
692}
693
694/// Redacts secrets from a single agent event's text fields.
695pub fn redact_agent_event(event: &AgentEvent) -> AgentEvent {
696    match event {
697        AgentEvent::UserTaskSubmitted {
698            text,
699            content_parts,
700            submitted_at,
701        } => AgentEvent::UserTaskSubmitted {
702            text: redact_secrets(text),
703            content_parts: content_parts.clone(),
704            submitted_at: *submitted_at,
705        },
706        AgentEvent::ModelOutput { text, thinking } => AgentEvent::ModelOutput {
707            text: redact_secrets(text),
708            thinking: thinking.as_ref().map(|t| redact_secrets(t)),
709        },
710        AgentEvent::ModelDelta { text } => AgentEvent::ModelDelta {
711            text: redact_secrets(text),
712        },
713        AgentEvent::ModelThinkingDelta { text } => AgentEvent::ModelThinkingDelta {
714            text: redact_secrets(text),
715        },
716        AgentEvent::Error { message } => AgentEvent::Error {
717            message: redact_secrets(message),
718        },
719        AgentEvent::ToolRequested(invocation) => {
720            AgentEvent::ToolRequested(redact_tool_invocation(invocation))
721        }
722        AgentEvent::ToolCompleted(result) => AgentEvent::ToolCompleted(redact_tool_result(result)),
723        AgentEvent::SubagentActivity {
724            invocation_id,
725            message,
726        } => AgentEvent::SubagentActivity {
727            invocation_id: invocation_id.clone(),
728            message: redact_secrets(message),
729        },
730        AgentEvent::SubagentTranscript {
731            invocation_id,
732            item,
733        } => AgentEvent::SubagentTranscript {
734            invocation_id: invocation_id.clone(),
735            item: redact_subagent_transcript_item(item),
736        },
737        AgentEvent::HarnessTrace(value) => AgentEvent::HarnessTrace(redact_json_value(value)),
738        AgentEvent::HarnessStopped {
739            reason,
740            message,
741            tool_name,
742        } => AgentEvent::HarnessStopped {
743            reason: reason.clone(),
744            message: redact_secrets(message),
745            tool_name: tool_name.clone(),
746        },
747        AgentEvent::PatchProposed(patch) => AgentEvent::PatchProposed(redact_patch_proposal(patch)),
748        AgentEvent::ApprovalRequested(request) => {
749            AgentEvent::ApprovalRequested(redact_approval_request(request))
750        }
751        AgentEvent::CapabilityRecorded(entry) => {
752            let mut entry = entry.clone();
753            entry.justification = redact_secrets(&entry.justification);
754            AgentEvent::CapabilityRecorded(entry)
755        }
756        other => other.clone(),
757    }
758}
759
760fn redact_subagent_transcript_item(item: &SubagentTranscriptItem) -> SubagentTranscriptItem {
761    SubagentTranscriptItem {
762        kind: item.kind,
763        title: redact_secrets(&item.title),
764        detail: item.detail.as_ref().map(|detail| redact_secrets(detail)),
765        ok: item.ok,
766    }
767}
768
769/// Redacts secrets from a `ProjectMemory`'s entry summaries so that persisted
770/// memory snapshots don't leak credentials the model may have echoed back.
771pub fn redact_memory(memory: &ProjectMemory) -> ProjectMemory {
772    ProjectMemory {
773        project_hash: memory.project_hash.clone(),
774        entries: memory
775            .entries
776            .iter()
777            .map(|entry| crate::session::MemoryEntry {
778                created_at: entry.created_at,
779                summary: redact_secrets(&entry.summary),
780                session_id: entry.session_id.clone(),
781            })
782            .collect(),
783    }
784}
785
786fn redact_tool_invocation(invocation: &ToolInvocation) -> ToolInvocation {
787    ToolInvocation {
788        id: invocation.id.clone(),
789        tool_name: invocation.tool_name.clone(),
790        input: redact_json_value(&invocation.input),
791    }
792}
793
794fn redact_tool_result(result: &ToolResult) -> ToolResult {
795    ToolResult {
796        invocation_id: result.invocation_id.clone(),
797        ok: result.ok,
798        output: redact_json_value(&result.output),
799    }
800}
801
802fn redact_patch_proposal(patch: &PatchProposal) -> PatchProposal {
803    PatchProposal {
804        id: patch.id.clone(),
805        summary: redact_secrets(&patch.summary),
806        files: patch.files.clone(),
807        unified_diff: redact_secrets(&patch.unified_diff),
808    }
809}
810
811fn redact_approval_request(request: &ApprovalRequest) -> ApprovalRequest {
812    ApprovalRequest {
813        id: request.id.clone(),
814        summary: redact_secrets(&request.summary),
815        risk: request.risk.clone(),
816    }
817}
818
819fn redact_json_value(value: &Value) -> Value {
820    match value {
821        Value::String(text) => Value::String(redact_secrets(text)),
822        Value::Array(values) => Value::Array(values.iter().map(redact_json_value).collect()),
823        Value::Object(map) => Value::Object(
824            map.iter()
825                .map(|(key, value)| (key.clone(), redact_json_value(value)))
826                .collect(),
827        ),
828        other => other.clone(),
829    }
830}
831
832/// Replaces API keys, bearer tokens, and other secret patterns in text with
833/// `[REDACTED]`.
834pub fn redact_secrets(text: &str) -> String {
835    let mut output = String::with_capacity(text.len());
836    let mut token = String::new();
837
838    for ch in text.chars() {
839        if ch.is_whitespace() {
840            push_redacted_token(&mut output, &token);
841            token.clear();
842            output.push(ch);
843        } else {
844            token.push(ch);
845        }
846    }
847    push_redacted_token(&mut output, &token);
848
849    output
850}
851
852fn push_redacted_token(output: &mut String, token: &str) {
853    if token.is_empty() {
854        return;
855    }
856
857    if let Some((prefix, _secret)) = token.split_once('=')
858        && is_secret_assignment_name(prefix)
859    {
860        output.push_str(prefix);
861        output.push_str("=<redacted>");
862        return;
863    }
864
865    let trimmed = token.trim_matches(|ch: char| {
866        matches!(
867            ch,
868            '"' | '\'' | '`' | ',' | ';' | ':' | ')' | '(' | '[' | ']' | '{' | '}'
869        )
870    });
871
872    if looks_like_secret_token(trimmed) {
873        output.push_str(&token.replace(trimmed, "<redacted>"));
874    } else {
875        output.push_str(token);
876    }
877}
878
879fn looks_like_secret_token(token: &str) -> bool {
880    let lower = token.to_ascii_lowercase();
881    let known_prefix = [
882        "sk-",
883        "sk_",
884        "sk-proj-",
885        "xai-",
886        "anthropic_",
887        "ghp_",
888        "github_pat_",
889        "glpat-",
890        "hf_",
891    ]
892    .iter()
893    .any(|prefix| lower.starts_with(prefix));
894
895    known_prefix
896        && token
897            .chars()
898            .filter(|ch| ch.is_ascii_alphanumeric())
899            .count()
900            >= 16
901}
902
903fn is_secret_assignment_name(name: &str) -> bool {
904    let upper = name.to_ascii_uppercase();
905    upper.contains("API_KEY")
906        || upper.contains("ACCESS_TOKEN")
907        || upper.contains("AUTH_TOKEN")
908        || upper.contains("SECRET")
909        || upper == "TOKEN"
910}
911
912fn command_name(program: &str) -> &str {
913    let program = program.split_whitespace().next().unwrap_or(program);
914    Path::new(program)
915        .file_name()
916        .and_then(|name| name.to_str())
917        .unwrap_or(program)
918}
919
920/// Returns true when the command line is a destructive `git` operation that
921/// should require explicit approval outside YOLO mode.
922fn is_destructive_git_command(program: &str) -> bool {
923    let Some(subcommand) = git_primary_subcommand(program) else {
924        // Unknown / bare `git` — treat as guarded to be safe.
925        return true;
926    };
927
928    match subcommand.as_str() {
929        // Network / history rewrites / force-delete style operations.
930        "push" | "rm" | "reset" | "clean" | "rebase" | "filter-branch" | "filter-repo"
931        | "update-ref" | "replace" | "gc" | "prune" | "notes" | "am" => true,
932        // Subcommands that are only destructive with certain arguments.
933        "branch" => git_has_delete_flag(program),
934        "tag" => git_has_delete_flag(program),
935        "stash" => git_subcommand_is(program, &["drop", "clear"]),
936        "worktree" => git_subcommand_is(program, &["remove", "prune"]),
937        "remote" => git_subcommand_is(program, &["remove", "rm", "prune"]),
938        "reflog" => git_subcommand_is(program, &["delete", "expire"]),
939        // Common non-destructive operations (status/add/commit/log/diff/...).
940        _ => false,
941    }
942}
943
944/// Extracts the primary git subcommand, skipping global options such as
945/// `-C <path>`, `--git-dir=<path>`, and `-c name=value`.
946fn git_primary_subcommand(program: &str) -> Option<String> {
947    let tokens = shell_tokens(program);
948    let mut index = 0;
949
950    // First token should be git (possibly a path to git).
951    if tokens
952        .first()
953        .map(|token| command_token_name(token) != "git")
954        .unwrap_or(true)
955    {
956        return None;
957    }
958    index += 1;
959
960    while index < tokens.len() {
961        let token = tokens[index].as_str();
962        if token == "--" {
963            index += 1;
964            break;
965        }
966        if !token.starts_with('-') {
967            return Some(token.to_string());
968        }
969
970        // Options that take a following argument.
971        match token {
972            "-C" | "-c" | "--git-dir" | "--work-tree" | "--namespace" | "--config-env" => {
973                index += 2;
974            }
975            _ if token.starts_with("--git-dir=")
976                || token.starts_with("--work-tree=")
977                || token.starts_with("--namespace=")
978                || token.starts_with("--config-env=")
979                || token.starts_with("-c") =>
980            {
981                index += 1;
982            }
983            _ => index += 1,
984        }
985    }
986
987    tokens.get(index).cloned()
988}
989
990fn git_has_delete_flag(program: &str) -> bool {
991    shell_tokens(program).iter().any(|token| {
992        matches!(token.as_str(), "-d" | "-D" | "--delete" | "--delete-tag")
993            || token.starts_with("--delete=")
994    })
995}
996
997fn git_subcommand_is(program: &str, candidates: &[&str]) -> bool {
998    let tokens = shell_tokens(program);
999    // Find primary subcommand, then look at the next non-option token.
1000    let Some(primary) = git_primary_subcommand(program) else {
1001        return false;
1002    };
1003    let Some(primary_idx) = tokens.iter().position(|token| token == &primary) else {
1004        return false;
1005    };
1006    tokens
1007        .iter()
1008        .skip(primary_idx + 1)
1009        .find(|token| !token.starts_with('-') && *token != "--")
1010        .is_some_and(|token| candidates.iter().any(|candidate| candidate == token))
1011}
1012
1013fn contains_component(path: &Path, needle: &str) -> bool {
1014    path.components().any(|component| match component {
1015        Component::Normal(value) => value == needle,
1016        _ => false,
1017    })
1018}
1019
1020pub(crate) fn extract_shell_write_targets(command: &str) -> Vec<String> {
1021    let tokens = shell_tokens(command);
1022    let mut targets = Vec::new();
1023    let mut index = 0;
1024
1025    while index < tokens.len() {
1026        let token = &tokens[index];
1027        let command_name = command_token_name(token);
1028
1029        if command_name == "sed" {
1030            index = collect_sed_in_place_targets(&tokens, index + 1, &mut targets);
1031            continue;
1032        }
1033
1034        if command_name == "perl" {
1035            index = collect_perl_in_place_targets(&tokens, index + 1, &mut targets);
1036            continue;
1037        }
1038
1039        if is_output_redirection_operator(token) {
1040            if let Some(target) = tokens
1041                .get(index + 1)
1042                .and_then(|value| clean_shell_target(value))
1043            {
1044                push_unique_string(&mut targets, &target);
1045            }
1046            index += 2;
1047            continue;
1048        }
1049
1050        if let Some(target) = attached_output_redirection_target(token) {
1051            push_unique_string(&mut targets, &target);
1052            index += 1;
1053            continue;
1054        }
1055
1056        if command_token_name(token) == "tee" {
1057            index += 1;
1058            while index < tokens.len() && !is_shell_command_separator(&tokens[index]) {
1059                let arg = &tokens[index];
1060                if arg == "--" {
1061                    index += 1;
1062                    continue;
1063                }
1064                if !arg.starts_with('-')
1065                    && let Some(target) = clean_shell_target(arg)
1066                {
1067                    push_unique_string(&mut targets, &target);
1068                }
1069                index += 1;
1070            }
1071            continue;
1072        }
1073
1074        index += 1;
1075    }
1076
1077    targets
1078}
1079
1080fn collect_sed_in_place_targets(
1081    tokens: &[String],
1082    mut index: usize,
1083    targets: &mut Vec<String>,
1084) -> usize {
1085    let mut in_place = false;
1086    let mut script_seen = false;
1087
1088    while index < tokens.len() && !is_shell_command_separator(&tokens[index]) {
1089        let token = &tokens[index];
1090
1091        if token == "--" {
1092            index += 1;
1093            break;
1094        }
1095
1096        if token == "-i" || token.starts_with("-i") {
1097            in_place = true;
1098            index += 1;
1099            continue;
1100        }
1101
1102        if token == "-e" || token == "-f" {
1103            script_seen = true;
1104            index = index.saturating_add(2);
1105            continue;
1106        }
1107
1108        if token.starts_with("-e") || token.starts_with("-f") {
1109            script_seen = true;
1110            index += 1;
1111            continue;
1112        }
1113
1114        if token.starts_with('-') {
1115            index += 1;
1116            continue;
1117        }
1118
1119        if in_place
1120            && script_seen
1121            && let Some(target) = clean_shell_target(token)
1122        {
1123            push_unique_string(targets, &target);
1124        }
1125        script_seen = true;
1126        index += 1;
1127    }
1128
1129    while in_place && index < tokens.len() && !is_shell_command_separator(&tokens[index]) {
1130        if let Some(target) = clean_shell_target(&tokens[index]) {
1131            push_unique_string(targets, &target);
1132        }
1133        index += 1;
1134    }
1135
1136    index
1137}
1138
1139fn collect_perl_in_place_targets(
1140    tokens: &[String],
1141    mut index: usize,
1142    targets: &mut Vec<String>,
1143) -> usize {
1144    let mut in_place = false;
1145
1146    while index < tokens.len() && !is_shell_command_separator(&tokens[index]) {
1147        let token = &tokens[index];
1148
1149        if token == "--" {
1150            index += 1;
1151            break;
1152        }
1153
1154        if token.starts_with('-') {
1155            if token == "-e" {
1156                index = index.saturating_add(2);
1157                continue;
1158            }
1159            if token.starts_with("-e") {
1160                index += 1;
1161                continue;
1162            }
1163            if token == "-i" || token.starts_with("-i") || token.chars().skip(1).any(|ch| ch == 'i')
1164            {
1165                in_place = true;
1166            }
1167            index += 1;
1168            continue;
1169        }
1170
1171        if in_place && let Some(target) = clean_shell_target(token) {
1172            push_unique_string(targets, &target);
1173        }
1174        index += 1;
1175    }
1176
1177    while in_place && index < tokens.len() && !is_shell_command_separator(&tokens[index]) {
1178        if let Some(target) = clean_shell_target(&tokens[index]) {
1179            push_unique_string(targets, &target);
1180        }
1181        index += 1;
1182    }
1183
1184    index
1185}
1186
1187fn extract_shell_path_mentions(command: &str) -> Vec<String> {
1188    let mut paths = Vec::new();
1189    for token in shell_tokens(command) {
1190        if is_shell_command_separator(&token) || is_output_redirection_operator(&token) {
1191            continue;
1192        }
1193        if let Some(target) = attached_output_redirection_target(&token) {
1194            push_unique_string(&mut paths, &target);
1195            continue;
1196        }
1197        if let Some(path) = clean_shell_target(&token)
1198            && looks_like_path(&path)
1199        {
1200            push_unique_string(&mut paths, &path);
1201        }
1202    }
1203    paths
1204}
1205
1206fn shell_tokens(command: &str) -> Vec<String> {
1207    let mut tokens = Vec::new();
1208    let mut token = String::new();
1209    let mut chars = command.chars().peekable();
1210    let mut quote: Option<char> = None;
1211    let mut escaped = false;
1212
1213    while let Some(ch) = chars.next() {
1214        if escaped {
1215            token.push(ch);
1216            escaped = false;
1217            continue;
1218        }
1219
1220        if ch == '\\' && quote != Some('\'') {
1221            escaped = true;
1222            continue;
1223        }
1224
1225        if let Some(quote_ch) = quote {
1226            if ch == quote_ch {
1227                quote = None;
1228            } else {
1229                token.push(ch);
1230            }
1231            continue;
1232        }
1233
1234        match ch {
1235            '\'' | '"' => quote = Some(ch),
1236            ch if ch.is_whitespace() => {
1237                push_shell_token(&mut tokens, &mut token);
1238            }
1239            '>' | '<' => {
1240                push_shell_token(&mut tokens, &mut token);
1241                let mut operator = String::from(ch);
1242                while let Some(next) = chars.peek().copied() {
1243                    if next == '>' || next == '<' || next == '&' || next == '|' {
1244                        operator.push(next);
1245                        chars.next();
1246                    } else {
1247                        break;
1248                    }
1249                }
1250                tokens.push(operator);
1251            }
1252            '&' | '|' | ';' => {
1253                push_shell_token(&mut tokens, &mut token);
1254                let mut operator = String::from(ch);
1255                if let Some(next) = chars.peek().copied()
1256                    && next == ch
1257                {
1258                    operator.push(next);
1259                    chars.next();
1260                }
1261                tokens.push(operator);
1262            }
1263            _ => token.push(ch),
1264        }
1265    }
1266
1267    push_shell_token(&mut tokens, &mut token);
1268    tokens
1269}
1270
1271fn push_shell_token(tokens: &mut Vec<String>, token: &mut String) {
1272    if !token.is_empty() {
1273        tokens.push(std::mem::take(token));
1274    }
1275}
1276
1277fn is_output_redirection_operator(token: &str) -> bool {
1278    matches!(token, ">" | ">>" | ">|" | "&>" | "&>>")
1279        || token
1280            .strip_suffix('>')
1281            .or_else(|| token.strip_suffix(">>"))
1282            .is_some_and(|prefix| prefix.chars().all(|ch| ch.is_ascii_digit()))
1283}
1284
1285fn attached_output_redirection_target(token: &str) -> Option<String> {
1286    let operators = ["&>>", "&>", ">|", ">>", ">"];
1287    for operator in operators {
1288        if let Some(target) = token.strip_prefix(operator) {
1289            return clean_shell_target(target);
1290        }
1291    }
1292
1293    let digit_count = token.chars().take_while(|ch| ch.is_ascii_digit()).count();
1294    if digit_count == 0 {
1295        return None;
1296    }
1297    let rest = &token[digit_count..];
1298    for operator in [">>", ">", ">|"] {
1299        if let Some(target) = rest.strip_prefix(operator) {
1300            return clean_shell_target(target);
1301        }
1302    }
1303    None
1304}
1305
1306fn clean_shell_target(target: &str) -> Option<String> {
1307    let target = target.trim();
1308    if target.is_empty()
1309        || target == "-"
1310        || target == "/dev/null"
1311        || target.starts_with('&')
1312        || target.starts_with('(')
1313    {
1314        return None;
1315    }
1316    Some(expand_home_shell_target(target))
1317}
1318
1319fn expand_home_shell_target(target: &str) -> String {
1320    let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
1321        return target.to_string();
1322    };
1323    if let Some(rest) = target.strip_prefix("~/") {
1324        return home.join(rest).display().to_string();
1325    }
1326    if let Some(rest) = target.strip_prefix("$HOME/") {
1327        return home.join(rest).display().to_string();
1328    }
1329    if let Some(rest) = target.strip_prefix("${HOME}/") {
1330        return home.join(rest).display().to_string();
1331    }
1332    target.to_string()
1333}
1334
1335fn is_dynamic_shell_target(target: &str) -> bool {
1336    target.contains('$') || target.contains('`')
1337}
1338
1339fn is_shell_command_separator(token: &str) -> bool {
1340    matches!(token, "|" | "||" | "&&" | ";" | "&")
1341}
1342
1343fn looks_like_path(token: &str) -> bool {
1344    token.starts_with('/')
1345        || token.starts_with("./")
1346        || token.starts_with("../")
1347        || token.starts_with("~/")
1348        || token.starts_with("$HOME/")
1349        || token.starts_with("${HOME}/")
1350        || token.contains('/')
1351}
1352
1353fn command_token_name(token: &str) -> &str {
1354    Path::new(token)
1355        .file_name()
1356        .and_then(|name| name.to_str())
1357        .unwrap_or(token)
1358}
1359
1360fn normalize_existing_or_parent(path: &Path) -> Result<PathBuf> {
1361    if path.exists() {
1362        return path.canonicalize().map_err(Into::into);
1363    }
1364
1365    let mut missing = Vec::new();
1366    let mut current = path;
1367    while !current.exists() {
1368        let component = current.file_name().with_context(|| {
1369            format!(
1370                "path {} does not exist and has no existing parent",
1371                path.display()
1372            )
1373        })?;
1374        missing.push(component.to_os_string());
1375        current = current.parent().with_context(|| {
1376            format!(
1377                "path {} does not exist and has no existing parent",
1378                path.display()
1379            )
1380        })?;
1381    }
1382
1383    let mut normalized = current.canonicalize()?;
1384    for component in missing.iter().rev() {
1385        normalized.push(component);
1386    }
1387    Ok(normalized)
1388}
1389
1390pub(crate) fn extract_apply_patch_paths(patch: &str) -> Vec<String> {
1391    let mut paths = Vec::new();
1392    for line in patch.lines() {
1393        if let Some(path) = line.strip_prefix("*** Add File: ") {
1394            push_unique_string(&mut paths, path);
1395        } else if let Some(path) = line.strip_prefix("*** Delete File: ") {
1396            push_unique_string(&mut paths, path);
1397        } else if let Some(path) = line.strip_prefix("*** Update File: ") {
1398            push_unique_string(&mut paths, path);
1399        } else if let Some(path) = line.strip_prefix("*** Move to: ") {
1400            push_unique_string(&mut paths, path);
1401        } else if let Some(path) = line.strip_prefix("--- a/") {
1402            push_unique_string(&mut paths, path);
1403        } else if let Some(path) = line.strip_prefix("+++ b/") {
1404            push_unique_string(&mut paths, path);
1405        } else if let Some(path) = line.strip_prefix("--- ")
1406            && path != "/dev/null"
1407        {
1408            push_unique_string(&mut paths, path);
1409        } else if let Some(path) = line.strip_prefix("+++ ")
1410            && path != "/dev/null"
1411        {
1412            push_unique_string(&mut paths, path);
1413        }
1414    }
1415    paths
1416}
1417
1418fn push_unique_string(paths: &mut Vec<String>, path: &str) {
1419    let path = path.split('\t').next().unwrap_or(path).to_string();
1420    if path != "/dev/null" && !paths.contains(&path) {
1421        paths.push(path);
1422    }
1423}
1424
1425fn risk_for_tool_kind(kind: ToolKind) -> SecurityRisk {
1426    match kind {
1427        ToolKind::Read => SecurityRisk::Tool,
1428        ToolKind::Write => SecurityRisk::Write,
1429        ToolKind::Command => SecurityRisk::Command,
1430        ToolKind::Custom => SecurityRisk::ExternalPlugin,
1431    }
1432}
1433
1434fn matches_tool_rule(name: &str, names: &[String], patterns: &[String]) -> bool {
1435    names.iter().any(|candidate| candidate == name)
1436        || patterns
1437            .iter()
1438            .filter_map(|pattern| regex::Regex::new(pattern).ok())
1439            .any(|regex| regex.is_match(name))
1440}
1441
1442fn first_invalid_regex(patterns: &[String]) -> Option<SecurityDecision> {
1443    patterns
1444        .iter()
1445        .find_map(|pattern| match regex::Regex::new(pattern) {
1446            Ok(_) => None,
1447            Err(err) => Some(SecurityDecision::Deny(format!(
1448                "invalid tool permission regex `{pattern}`: {err}"
1449            ))),
1450        })
1451}
1452
1453#[cfg(test)]
1454mod tests {
1455    use super::*;
1456    use crate::config::SecurityConfig;
1457    use crate::patch::PatchProposal;
1458
1459    fn policy(project_root: PathBuf, data_dir: PathBuf) -> SecurityPolicy {
1460        SecurityPolicy::new(project_root, data_dir, SecurityConfig::default()).expect("policy")
1461    }
1462
1463    fn policy_with_config(
1464        project_root: PathBuf,
1465        data_dir: PathBuf,
1466        config: SecurityConfig,
1467    ) -> SecurityPolicy {
1468        SecurityPolicy::new(project_root, data_dir, config).expect("policy")
1469    }
1470
1471    fn tool_def(name: &str, kind: ToolKind) -> ToolDefinition {
1472        ToolDefinition {
1473            name: name.to_string(),
1474            description: String::new(),
1475            kind,
1476            input_schema: serde_json::json!({}),
1477            ..Default::default()
1478        }
1479    }
1480
1481    fn tool_invocation(name: &str, input: Value) -> ToolInvocation {
1482        ToolInvocation {
1483            id: format!("{name}-1"),
1484            tool_name: name.to_string(),
1485            input,
1486        }
1487    }
1488
1489    #[test]
1490    fn allows_paths_outside_project_outside_restricted() {
1491        let tempdir = tempfile::tempdir().expect("tempdir");
1492        let project = tempdir.path().join("project");
1493        let data = tempdir.path().join("data");
1494        std::fs::create_dir_all(&project).expect("project");
1495        std::fs::create_dir_all(&data).expect("data");
1496        // AcceptEdits / Auto / YOLO keep agency: no project path jail by default.
1497        let policy = policy_with_config(
1498            project,
1499            data,
1500            SecurityConfig {
1501                permission_mode: PermissionMode::Yolo,
1502                restrict_paths_to_project: false,
1503                ..SecurityConfig::default()
1504            },
1505        );
1506
1507        let decision = policy.validate_path(tempdir.path().join("outside.txt").as_path(), false);
1508
1509        assert_eq!(decision, SecurityDecision::Allow);
1510    }
1511
1512    #[test]
1513    fn restricted_mode_denies_paths_outside_project() {
1514        let tempdir = tempfile::tempdir().expect("tempdir");
1515        let project = tempdir.path().join("project");
1516        let data = tempdir.path().join("data");
1517        std::fs::create_dir_all(&project).expect("project");
1518        std::fs::create_dir_all(&data).expect("data");
1519        // Default permission mode is Restricted — path jail always applies.
1520        let policy = policy(project, data);
1521
1522        let decision = policy.validate_path(tempdir.path().join("outside.txt").as_path(), false);
1523
1524        assert!(
1525            matches!(decision, SecurityDecision::Deny(ref reason) if reason.contains("outside project")),
1526            "expected Deny(outside project), got {decision:?}"
1527        );
1528    }
1529
1530    #[test]
1531    fn absolute_path_inside_project_is_allowed() {
1532        let tempdir = tempfile::tempdir().expect("tempdir");
1533        let project = tempdir.path().join("project");
1534        let data = tempdir.path().join("data");
1535        std::fs::create_dir_all(project.join("src")).expect("project");
1536        std::fs::create_dir_all(&data).expect("data");
1537        let policy = policy(project.clone(), data);
1538        let absolute = project.join("src/lib.rs");
1539
1540        let decision = policy.validate_path(&absolute, false);
1541
1542        assert_eq!(decision, SecurityDecision::Allow);
1543    }
1544
1545    #[test]
1546    fn write_inside_project_needs_approval() {
1547        let tempdir = tempfile::tempdir().expect("tempdir");
1548        let project = tempdir.path().join("project");
1549        let data = tempdir.path().join("data");
1550        std::fs::create_dir_all(&project).expect("project");
1551        std::fs::create_dir_all(&data).expect("data");
1552        let policy = policy(project.clone(), data);
1553
1554        let decision = policy.validate_path(project.join("src/lib.rs").as_path(), true);
1555
1556        assert_eq!(
1557            decision,
1558            SecurityDecision::NeedsApproval(SecurityRisk::Write)
1559        );
1560    }
1561
1562    #[test]
1563    fn restricted_mode_requires_approval_for_read_tools() {
1564        let tempdir = tempfile::tempdir().expect("tempdir");
1565        let project = tempdir.path().join("project");
1566        let data = tempdir.path().join("data");
1567        std::fs::create_dir_all(project.join("src")).expect("project");
1568        std::fs::write(project.join("src/lib.rs"), "").expect("file");
1569        std::fs::create_dir_all(&data).expect("data");
1570        let policy = policy(project, data);
1571
1572        let decision = policy.validate_tool_invocation(
1573            &tool_def("read_file", ToolKind::Read),
1574            &tool_invocation("read_file", serde_json::json!({ "path": "src/lib.rs" })),
1575        );
1576
1577        assert_eq!(
1578            decision,
1579            SecurityDecision::NeedsApproval(SecurityRisk::Tool)
1580        );
1581    }
1582
1583    #[test]
1584    fn restricted_mode_requires_approval_for_apply_patch() {
1585        let tempdir = tempfile::tempdir().expect("tempdir");
1586        let project = tempdir.path().join("project");
1587        let data = tempdir.path().join("data");
1588        std::fs::create_dir_all(&project).expect("project");
1589        std::fs::write(project.join("README.md"), "Less then ideal\n").expect("file");
1590        std::fs::create_dir_all(&data).expect("data");
1591        let policy = policy(project, data);
1592
1593        let decision = policy.validate_tool_invocation(
1594            &tool_def("apply_patch", ToolKind::Write),
1595            &tool_invocation(
1596                "apply_patch",
1597                serde_json::json!({
1598                    "patch": "*** Begin Patch\n*** Update File: README.md\n@@\n-Less then ideal\n+Less than ideal\n*** End Patch\n"
1599                }),
1600            ),
1601        );
1602
1603        assert_eq!(
1604            decision,
1605            SecurityDecision::NeedsApproval(SecurityRisk::Write)
1606        );
1607    }
1608
1609    #[test]
1610    fn restricted_mode_requires_approval_for_process_actions() {
1611        let tempdir = tempfile::tempdir().expect("tempdir");
1612        let project = tempdir.path().join("project");
1613        let data = tempdir.path().join("data");
1614        std::fs::create_dir_all(&project).expect("project");
1615        std::fs::create_dir_all(&data).expect("data");
1616        let policy = policy(project, data);
1617        let def = tool_def("process", ToolKind::Command);
1618
1619        for input in [
1620            serde_json::json!({"action": "exec", "command": "python3 -i -q", "background": true}),
1621            serde_json::json!({"action": "stdin", "process_id": "proc_1", "stdin_data": "print(1)\n"}),
1622            serde_json::json!({"action": "wait", "process_id": "proc_1"}),
1623            serde_json::json!({"action": "cancel", "process_id": "proc_1"}),
1624        ] {
1625            let decision =
1626                policy.validate_tool_invocation(&def, &tool_invocation("process", input));
1627            assert_eq!(
1628                decision,
1629                SecurityDecision::NeedsApproval(SecurityRisk::Command)
1630            );
1631        }
1632    }
1633
1634    #[test]
1635    fn accept_edits_allows_writes_but_asks_for_commands() {
1636        let tempdir = tempfile::tempdir().expect("tempdir");
1637        let project = tempdir.path().join("project");
1638        let data = tempdir.path().join("data");
1639        std::fs::create_dir_all(project.join("src")).expect("project");
1640        std::fs::create_dir_all(&data).expect("data");
1641        let config = SecurityConfig {
1642            permission_mode: PermissionMode::AcceptEdits,
1643            ..SecurityConfig::default()
1644        };
1645        let policy = policy_with_config(project, data, config);
1646
1647        let write_decision = policy.validate_tool_invocation(
1648            &tool_def("write_file", ToolKind::Write),
1649            &tool_invocation("write_file", serde_json::json!({ "path": "src/lib.rs" })),
1650        );
1651        let command_decision = policy.validate_tool_invocation(
1652            &tool_def("bash", ToolKind::Command),
1653            &tool_invocation("bash", serde_json::json!({ "command": "cargo test" })),
1654        );
1655
1656        assert_eq!(write_decision, SecurityDecision::Allow);
1657        assert_eq!(
1658            command_decision,
1659            SecurityDecision::NeedsApproval(SecurityRisk::Command)
1660        );
1661    }
1662
1663    #[test]
1664    fn yolo_mode_allows_commands_after_safety_checks() {
1665        let tempdir = tempfile::tempdir().expect("tempdir");
1666        let project = tempdir.path().join("project");
1667        let data = tempdir.path().join("data");
1668        std::fs::create_dir_all(&project).expect("project");
1669        std::fs::create_dir_all(&data).expect("data");
1670        let config = SecurityConfig {
1671            permission_mode: PermissionMode::Yolo,
1672            ..SecurityConfig::default()
1673        };
1674        let policy = policy_with_config(project, data, config);
1675
1676        let decision = policy.validate_tool_invocation(
1677            &tool_def("bash", ToolKind::Command),
1678            &tool_invocation("bash", serde_json::json!({ "command": "cargo test" })),
1679        );
1680
1681        assert_eq!(decision, SecurityDecision::Allow);
1682    }
1683
1684    #[test]
1685    fn yolo_mode_still_denies_blocked_commands() {
1686        let tempdir = tempfile::tempdir().expect("tempdir");
1687        let project = tempdir.path().join("project");
1688        let data = tempdir.path().join("data");
1689        std::fs::create_dir_all(&project).expect("project");
1690        std::fs::create_dir_all(&data).expect("data");
1691        let config = SecurityConfig {
1692            permission_mode: PermissionMode::Yolo,
1693            ..SecurityConfig::default()
1694        };
1695        let policy = policy_with_config(project, data, config);
1696
1697        let decision = policy.validate_tool_invocation(
1698            &tool_def("bash", ToolKind::Command),
1699            &tool_invocation("bash", serde_json::json!({ "command": "sudo true" })),
1700        );
1701
1702        assert!(matches!(decision, SecurityDecision::Deny(_)));
1703    }
1704
1705    #[test]
1706    fn auto_mode_allows_non_guarded_commands() {
1707        let tempdir = tempfile::tempdir().expect("tempdir");
1708        let project = tempdir.path().join("project");
1709        let data = tempdir.path().join("data");
1710        std::fs::create_dir_all(&project).expect("project");
1711        std::fs::create_dir_all(&data).expect("data");
1712        let config = SecurityConfig {
1713            permission_mode: PermissionMode::Auto,
1714            ..SecurityConfig::default()
1715        };
1716        let policy = policy_with_config(project, data, config);
1717
1718        let decision = policy.validate_tool_invocation(
1719            &tool_def("bash", ToolKind::Command),
1720            &tool_invocation("bash", serde_json::json!({ "command": "cargo test" })),
1721        );
1722
1723        assert_eq!(decision, SecurityDecision::Allow);
1724    }
1725
1726    #[test]
1727    fn auto_mode_allows_non_destructive_git_commands() {
1728        let tempdir = tempfile::tempdir().expect("tempdir");
1729        let project = tempdir.path().join("project");
1730        let data = tempdir.path().join("data");
1731        std::fs::create_dir_all(&project).expect("project");
1732        std::fs::create_dir_all(&data).expect("data");
1733        let config = SecurityConfig {
1734            permission_mode: PermissionMode::Auto,
1735            ..SecurityConfig::default()
1736        };
1737        let policy = policy_with_config(project, data, config);
1738
1739        for command in [
1740            "git status",
1741            "git add .",
1742            "git commit -m test",
1743            "git diff",
1744            "git log --oneline",
1745            "git branch",
1746            "git checkout -b feature",
1747            "git switch main",
1748            "git restore src/main.rs",
1749            "git stash push -m wip",
1750            "git pull --ff-only",
1751            "git fetch origin",
1752        ] {
1753            let decision = policy.validate_tool_invocation(
1754                &tool_def("bash", ToolKind::Command),
1755                &tool_invocation("bash", serde_json::json!({ "command": command })),
1756            );
1757            assert_eq!(
1758                decision,
1759                SecurityDecision::Allow,
1760                "expected non-destructive git command to be allowed: {command}"
1761            );
1762        }
1763    }
1764
1765    #[test]
1766    fn auto_mode_requires_approval_for_guarded_commands() {
1767        let tempdir = tempfile::tempdir().expect("tempdir");
1768        let project = tempdir.path().join("project");
1769        let data = tempdir.path().join("data");
1770        std::fs::create_dir_all(&project).expect("project");
1771        std::fs::create_dir_all(&data).expect("data");
1772        let config = SecurityConfig {
1773            permission_mode: PermissionMode::Auto,
1774            ..SecurityConfig::default()
1775        };
1776        let policy = policy_with_config(project, data, config);
1777
1778        for command in [
1779            "git push origin main",
1780            "git rm -r src",
1781            "git reset --hard HEAD~1",
1782            "git clean -fd",
1783            "git rebase origin/main",
1784            "git branch -D old-feature",
1785            "git tag -d v1.0.0",
1786            "git stash drop",
1787            "git worktree remove ../wt",
1788            "git remote remove origin",
1789            "git reflog delete HEAD@{1}",
1790            "git -C /tmp/repo push origin main",
1791        ] {
1792            let decision = policy.validate_tool_invocation(
1793                &tool_def("bash", ToolKind::Command),
1794                &tool_invocation("bash", serde_json::json!({ "command": command })),
1795            );
1796            assert_eq!(
1797                decision,
1798                SecurityDecision::NeedsApproval(SecurityRisk::GuardedCommand),
1799                "expected destructive git command to require approval: {command}"
1800            );
1801        }
1802    }
1803
1804    #[test]
1805    fn auto_mode_allows_writes() {
1806        let tempdir = tempfile::tempdir().expect("tempdir");
1807        let project = tempdir.path().join("project");
1808        let data = tempdir.path().join("data");
1809        std::fs::create_dir_all(&project).expect("project");
1810        std::fs::create_dir_all(&data).expect("data");
1811        let config = SecurityConfig {
1812            permission_mode: PermissionMode::Auto,
1813            restrict_paths_to_project: true,
1814            ..SecurityConfig::default()
1815        };
1816        let policy = policy_with_config(project, data, config);
1817
1818        let decision = policy.validate_tool_invocation(
1819            &tool_def("write_file", ToolKind::Write),
1820            &tool_invocation(
1821                "write_file",
1822                serde_json::json!({ "path": "src/main.rs", "content": "fn main() {}" }),
1823            ),
1824        );
1825
1826        assert_eq!(decision, SecurityDecision::Allow);
1827    }
1828
1829    #[test]
1830    fn yolo_mode_allows_guarded_commands() {
1831        let tempdir = tempfile::tempdir().expect("tempdir");
1832        let project = tempdir.path().join("project");
1833        let data = tempdir.path().join("data");
1834        std::fs::create_dir_all(&project).expect("project");
1835        std::fs::create_dir_all(&data).expect("data");
1836        let config = SecurityConfig {
1837            permission_mode: PermissionMode::Yolo,
1838            ..SecurityConfig::default()
1839        };
1840        let policy = policy_with_config(project, data, config);
1841
1842        for command in [
1843            "git commit -m test",
1844            "git push origin main",
1845            "git rm -r src",
1846            "git rebase origin/main",
1847        ] {
1848            let decision = policy.validate_tool_invocation(
1849                &tool_def("bash", ToolKind::Command),
1850                &tool_invocation("bash", serde_json::json!({ "command": command })),
1851            );
1852            assert_eq!(
1853                decision,
1854                SecurityDecision::Allow,
1855                "expected YOLO to allow guarded/non-guarded git command: {command}"
1856            );
1857        }
1858    }
1859
1860    #[test]
1861    fn auto_mode_still_denies_blocked_commands() {
1862        let tempdir = tempfile::tempdir().expect("tempdir");
1863        let project = tempdir.path().join("project");
1864        let data = tempdir.path().join("data");
1865        std::fs::create_dir_all(&project).expect("project");
1866        std::fs::create_dir_all(&data).expect("data");
1867        let config = SecurityConfig {
1868            permission_mode: PermissionMode::Auto,
1869            ..SecurityConfig::default()
1870        };
1871        let policy = policy_with_config(project, data, config);
1872
1873        let decision = policy.validate_tool_invocation(
1874            &tool_def("bash", ToolKind::Command),
1875            &tool_invocation("bash", serde_json::json!({ "command": "sudo true" })),
1876        );
1877
1878        assert!(matches!(decision, SecurityDecision::Deny(_)));
1879    }
1880
1881    #[test]
1882    fn tool_deny_rule_wins_over_yolo_mode() {
1883        let tempdir = tempfile::tempdir().expect("tempdir");
1884        let project = tempdir.path().join("project");
1885        let data = tempdir.path().join("data");
1886        std::fs::create_dir_all(&project).expect("project");
1887        std::fs::create_dir_all(&data).expect("data");
1888        let config = SecurityConfig {
1889            permission_mode: PermissionMode::Yolo,
1890            deny_tools: vec!["bash".to_string()],
1891            ..SecurityConfig::default()
1892        };
1893        let policy = policy_with_config(project, data, config);
1894
1895        let decision = policy.validate_tool_invocation(
1896            &tool_def("bash", ToolKind::Command),
1897            &tool_invocation("bash", serde_json::json!({ "command": "cargo test" })),
1898        );
1899
1900        assert!(matches!(decision, SecurityDecision::Deny(_)));
1901    }
1902
1903    #[test]
1904    fn tool_allow_rule_can_accept_named_tool_in_restricted_mode() {
1905        let tempdir = tempfile::tempdir().expect("tempdir");
1906        let project = tempdir.path().join("project");
1907        let data = tempdir.path().join("data");
1908        std::fs::create_dir_all(project.join("src")).expect("project");
1909        std::fs::write(project.join("src/lib.rs"), "").expect("file");
1910        std::fs::create_dir_all(&data).expect("data");
1911        let config = SecurityConfig {
1912            allow_tools: vec!["read_file".to_string()],
1913            ..SecurityConfig::default()
1914        };
1915        let policy = policy_with_config(project, data, config);
1916
1917        let decision = policy.validate_tool_invocation(
1918            &tool_def("read_file", ToolKind::Read),
1919            &tool_invocation("read_file", serde_json::json!({ "path": "src/lib.rs" })),
1920        );
1921
1922        assert_eq!(decision, SecurityDecision::Allow);
1923    }
1924
1925    #[test]
1926    fn regex_tool_rules_can_force_approval_in_yolo_mode() {
1927        let tempdir = tempfile::tempdir().expect("tempdir");
1928        let project = tempdir.path().join("project");
1929        let data = tempdir.path().join("data");
1930        std::fs::create_dir_all(&project).expect("project");
1931        std::fs::create_dir_all(&data).expect("data");
1932        let config = SecurityConfig {
1933            permission_mode: PermissionMode::Yolo,
1934            ask_tool_regex: vec!["^plugin__".to_string()],
1935            ..SecurityConfig::default()
1936        };
1937        let policy = policy_with_config(project, data, config);
1938
1939        let decision = policy.validate_tool_invocation(
1940            &tool_def("plugin__deploy", ToolKind::Custom),
1941            &tool_invocation("plugin__deploy", serde_json::json!({})),
1942        );
1943
1944        assert_eq!(
1945            decision,
1946            SecurityDecision::NeedsApproval(SecurityRisk::ExternalPlugin)
1947        );
1948    }
1949
1950    #[test]
1951    fn denies_writes_to_git_metadata() {
1952        let tempdir = tempfile::tempdir().expect("tempdir");
1953        let project = tempdir.path().join("project");
1954        let data = tempdir.path().join("data");
1955        std::fs::create_dir_all(project.join(".git")).expect("project");
1956        std::fs::create_dir_all(&data).expect("data");
1957        let policy = policy(project.clone(), data);
1958
1959        let decision = policy.validate_path(project.join(".git/config").as_path(), true);
1960
1961        assert!(matches!(decision, SecurityDecision::Deny(_)));
1962    }
1963
1964    #[test]
1965    fn validates_patch_files() {
1966        let tempdir = tempfile::tempdir().expect("tempdir");
1967        let project = tempdir.path().join("project");
1968        let data = tempdir.path().join("data");
1969        std::fs::create_dir_all(&project).expect("project");
1970        std::fs::create_dir_all(&data).expect("data");
1971        let policy = policy(project.clone(), data);
1972
1973        let patch = PatchProposal {
1974            id: "p1".to_string(),
1975            summary: "edit".to_string(),
1976            files: vec![project.join("Cargo.toml")],
1977            unified_diff: String::new(),
1978        };
1979
1980        assert_eq!(
1981            policy.validate_patch(&patch),
1982            SecurityDecision::NeedsApproval(SecurityRisk::Write)
1983        );
1984    }
1985
1986    #[test]
1987    fn denies_blocked_commands() {
1988        let tempdir = tempfile::tempdir().expect("tempdir");
1989        let project = tempdir.path().join("project");
1990        let data = tempdir.path().join("data");
1991        std::fs::create_dir_all(&project).expect("project");
1992        std::fs::create_dir_all(&data).expect("data");
1993        let policy = policy(project, data);
1994
1995        let decision = policy.validate_command("/bin/sudo");
1996
1997        assert!(matches!(decision, SecurityDecision::Deny(_)));
1998    }
1999
2000    #[test]
2001    fn allows_regular_project_memory_named_file() {
2002        let tempdir = tempfile::tempdir().expect("tempdir");
2003        let project = tempdir.path().join("project");
2004        let data = tempdir.path().join("data");
2005        std::fs::create_dir_all(project.join("docs")).expect("project");
2006        std::fs::create_dir_all(&data).expect("data");
2007        let policy = policy(project.clone(), data);
2008
2009        let decision = policy.validate_path(project.join("docs/MEMORY.md").as_path(), true);
2010
2011        assert_eq!(
2012            decision,
2013            SecurityDecision::NeedsApproval(SecurityRisk::Write)
2014        );
2015    }
2016
2017    #[test]
2018    fn denies_project_side_agent_memory_direct_access() {
2019        let tempdir = tempfile::tempdir().expect("tempdir");
2020        let project = tempdir.path().join("project");
2021        let data = tempdir.path().join("data");
2022        std::fs::create_dir_all(project.join(".agent-memory")).expect("memory");
2023        std::fs::create_dir_all(&data).expect("data");
2024        let policy = policy(project.clone(), data);
2025
2026        let decision =
2027            policy.validate_path(project.join(".agent-memory/MEMORY.md").as_path(), true);
2028
2029        assert!(matches!(decision, SecurityDecision::Deny(_)));
2030    }
2031
2032    #[test]
2033    fn denies_bash_redirection_to_project_side_agent_memory() {
2034        let tempdir = tempfile::tempdir().expect("tempdir");
2035        let project = tempdir.path().join("project");
2036        let data = tempdir.path().join("data");
2037        std::fs::create_dir_all(project.join(".agent-memory")).expect("memory");
2038        std::fs::create_dir_all(&data).expect("data");
2039        let policy = policy(project, data);
2040
2041        let decision = policy.validate_command("cat >> .agent-memory/MEMORY.md <<'EOF'\ntext\nEOF");
2042
2043        assert!(matches!(decision, SecurityDecision::Deny(_)));
2044    }
2045
2046    #[test]
2047    fn denies_bash_redirection_to_data_dir_memory() {
2048        let tempdir = tempfile::tempdir().expect("tempdir");
2049        let project = tempdir.path().join("project");
2050        let data = tempdir.path().join("data");
2051        std::fs::create_dir_all(&project).expect("project");
2052        std::fs::create_dir_all(data.join("memory/projects/abc")).expect("memory");
2053        let policy = policy(project, data.clone());
2054        let target = data.join("memory/projects/abc/MEMORY.md");
2055
2056        let decision =
2057            policy.validate_command(&format!("cat >> {} <<'EOF'\ntext\nEOF", target.display()));
2058
2059        assert!(matches!(decision, SecurityDecision::Deny(_)));
2060    }
2061
2062    #[test]
2063    fn denies_bash_reference_to_data_dir() {
2064        let tempdir = tempfile::tempdir().expect("tempdir");
2065        let project = tempdir.path().join("project");
2066        let data = tempdir.path().join("data");
2067        std::fs::create_dir_all(&project).expect("project");
2068        std::fs::create_dir_all(&data).expect("data");
2069        let policy = policy(project, data.clone());
2070
2071        let decision = policy.validate_command(&format!("ls {}", data.display()));
2072
2073        assert!(matches!(decision, SecurityDecision::Deny(_)));
2074    }
2075
2076    #[test]
2077    fn allows_bash_reference_to_data_dir_plugins() {
2078        let tempdir = tempfile::tempdir().expect("tempdir");
2079        let project = tempdir.path().join("project");
2080        let data = tempdir.path().join("data");
2081        let plugins = data.join("plugins");
2082        std::fs::create_dir_all(&project).expect("project");
2083        std::fs::create_dir_all(&plugins).expect("plugins");
2084        let policy = policy(project, data);
2085
2086        let decision = policy.validate_command(&format!("ls {}", plugins.display()));
2087
2088        assert_eq!(
2089            decision,
2090            SecurityDecision::NeedsApproval(SecurityRisk::Command)
2091        );
2092    }
2093
2094    #[test]
2095    fn allows_data_dir_plugins_path() {
2096        let tempdir = tempfile::tempdir().expect("tempdir");
2097        let project = tempdir.path().join("project");
2098        let data = tempdir.path().join("data");
2099        let plugins = data.join("plugins");
2100        std::fs::create_dir_all(&project).expect("project");
2101        std::fs::create_dir_all(&plugins).expect("plugins");
2102        let policy = policy(project, data);
2103
2104        let decision = policy.validate_path(plugins.join("plugin.wasm").as_path(), true);
2105
2106        assert_eq!(
2107            decision,
2108            SecurityDecision::NeedsApproval(SecurityRisk::Write)
2109        );
2110    }
2111
2112    #[test]
2113    fn denies_tee_write_to_data_dir_memory() {
2114        let tempdir = tempfile::tempdir().expect("tempdir");
2115        let project = tempdir.path().join("project");
2116        let data = tempdir.path().join("data");
2117        std::fs::create_dir_all(&project).expect("project");
2118        std::fs::create_dir_all(data.join("memory/projects/abc")).expect("memory");
2119        let policy = policy(project, data.clone());
2120        let target = data.join("memory/projects/abc/MEMORY.md");
2121
2122        let decision =
2123            policy.validate_command(&format!("printf text | tee -a {}", target.display()));
2124
2125        assert!(matches!(decision, SecurityDecision::Deny(_)));
2126    }
2127
2128    #[test]
2129    fn denies_bash_redirection_to_unresolved_dynamic_path() {
2130        let tempdir = tempfile::tempdir().expect("tempdir");
2131        let project = tempdir.path().join("project");
2132        let data = tempdir.path().join("data");
2133        std::fs::create_dir_all(&project).expect("project");
2134        std::fs::create_dir_all(&data).expect("data");
2135        let policy = policy(project, data);
2136
2137        let decision =
2138            policy.validate_command("cat >> \"$NAVI_MEMORY_DIR/MEMORY.md\" <<'EOF'\ntext\nEOF");
2139
2140        assert!(matches!(decision, SecurityDecision::Deny(_)));
2141    }
2142
2143    #[test]
2144    fn allows_bash_redirection_to_regular_project_memory_named_file() {
2145        let tempdir = tempfile::tempdir().expect("tempdir");
2146        let project = tempdir.path().join("project");
2147        let data = tempdir.path().join("data");
2148        std::fs::create_dir_all(project.join("docs")).expect("project");
2149        std::fs::create_dir_all(&data).expect("data");
2150        let policy = policy(project, data);
2151
2152        let decision = policy.validate_command("cat >> docs/MEMORY.md <<'EOF'\ntext\nEOF");
2153
2154        assert_eq!(
2155            decision,
2156            SecurityDecision::NeedsApproval(SecurityRisk::Command)
2157        );
2158    }
2159
2160    #[test]
2161    fn extracts_sed_in_place_write_targets() {
2162        let targets = extract_shell_write_targets("sed -i 's/old/new/' src/lib.rs");
2163
2164        assert_eq!(targets, vec!["src/lib.rs"]);
2165    }
2166
2167    #[test]
2168    fn extracts_perl_in_place_write_targets() {
2169        let targets = extract_shell_write_targets("perl -pi -e 's/old/new/' src/lib.rs");
2170
2171        assert_eq!(targets, vec!["src/lib.rs"]);
2172    }
2173
2174    #[test]
2175    fn redacts_secret_like_tokens_and_assignments() {
2176        let text =
2177            "OPENAI_API_KEY=sk-proj-1234567890abcdef and bearer sk-1234567890abcdef are present";
2178
2179        assert_eq!(
2180            redact_secrets(text),
2181            "OPENAI_API_KEY=<redacted> and bearer <redacted> are present"
2182        );
2183    }
2184
2185    #[test]
2186    fn redacts_model_output_thinking_field() {
2187        let event = AgentEvent::ModelOutput {
2188            text: "output".to_string(),
2189            thinking: Some("using OPENAI_API_KEY=sk-proj-1234567890abcdef".to_string()),
2190        };
2191        let redacted = redact_agent_event(&event);
2192        match redacted {
2193            AgentEvent::ModelOutput { thinking, .. } => {
2194                let thinking = thinking.unwrap();
2195                assert!(thinking.contains("OPENAI_API_KEY=<redacted>"));
2196                assert!(!thinking.contains("sk-proj-1234567890abcdef"));
2197            }
2198            _ => panic!("expected ModelOutput"),
2199        }
2200    }
2201
2202    #[test]
2203    fn redacts_error_event_message() {
2204        let event = AgentEvent::Error {
2205            message: "failed with token sk-proj-1234567890abcdef".to_string(),
2206        };
2207        let redacted = redact_agent_event(&event);
2208        match redacted {
2209            AgentEvent::Error { message } => {
2210                assert!(message.contains("<redacted>"));
2211                assert!(!message.contains("sk-proj-1234567890abcdef"));
2212            }
2213            _ => panic!("expected Error"),
2214        }
2215    }
2216
2217    #[test]
2218    fn redacts_model_delta_text() {
2219        let event = AgentEvent::ModelDelta {
2220            text: "key is OPENAI_API_KEY=sk-proj-1234567890abcdef".to_string(),
2221        };
2222        let redacted = redact_agent_event(&event);
2223        match redacted {
2224            AgentEvent::ModelDelta { text } => {
2225                assert!(text.contains("OPENAI_API_KEY=<redacted>"));
2226                assert!(!text.contains("sk-proj-1234567890abcdef"));
2227            }
2228            _ => panic!("expected ModelDelta"),
2229        }
2230    }
2231
2232    #[test]
2233    fn redacts_model_thinking_delta_text() {
2234        let event = AgentEvent::ModelThinkingDelta {
2235            text: "secret: anthropic_1234567890abcdef".to_string(),
2236        };
2237        let redacted = redact_agent_event(&event);
2238        match redacted {
2239            AgentEvent::ModelThinkingDelta { text } => {
2240                assert!(text.contains("<redacted>"));
2241                assert!(!text.contains("anthropic_1234567890abcdef"));
2242            }
2243            _ => panic!("expected ModelThinkingDelta"),
2244        }
2245    }
2246
2247    #[test]
2248    fn redacts_tool_requested_input() {
2249        let event = AgentEvent::ToolRequested(crate::tool::ToolInvocation {
2250            id: "c1".to_string(),
2251            tool_name: "read_file".to_string(),
2252            input: serde_json::json!({
2253                "path": "OPENAI_API_KEY=secret123",
2254                "nested": {"token": "ghp_1234567890abcdef1234"}
2255            }),
2256        });
2257        let redacted = redact_agent_event(&event);
2258        match redacted {
2259            AgentEvent::ToolRequested(invocation) => {
2260                let json = invocation.input.to_string();
2261                assert!(json.contains("OPENAI_API_KEY=<redacted>"));
2262                assert!(json.contains("<redacted>"));
2263                assert!(!json.contains("secret123"));
2264                assert!(!json.contains("ghp_1234567890abcdef1234"));
2265            }
2266            _ => panic!("expected ToolRequested"),
2267        }
2268    }
2269
2270    #[test]
2271    fn redacts_tool_completed_output() {
2272        let event = AgentEvent::ToolCompleted(crate::tool::ToolResult {
2273            invocation_id: "c1".to_string(),
2274            ok: true,
2275            output: serde_json::json!({"stdout": "token sk-proj-1234567890abcdef"}),
2276        });
2277        let redacted = redact_agent_event(&event);
2278        match redacted {
2279            AgentEvent::ToolCompleted(result) => {
2280                let json = result.output.to_string();
2281                assert!(json.contains("<redacted>"));
2282                assert!(!json.contains("sk-proj-1234567890abcdef"));
2283            }
2284            _ => panic!("expected ToolCompleted"),
2285        }
2286    }
2287
2288    #[test]
2289    fn redacts_snapshot_events_in_bulk() {
2290        let events = vec![
2291            AgentEvent::UserTaskSubmitted {
2292                text: "OPENAI_API_KEY=sk-proj-1234567890abcdef".to_string(),
2293                content_parts: vec![],
2294                submitted_at: None,
2295            },
2296            AgentEvent::ModelOutput {
2297                text: "ok".to_string(),
2298                thinking: Some("bearer ghp_1234567890abcdef1234".to_string()),
2299            },
2300            AgentEvent::Error {
2301                message: "error with token github_pat_1234567890abcdef12".to_string(),
2302            },
2303        ];
2304        let redacted = redact_snapshot_events(&events);
2305        let json = serde_json::to_string(&redacted).unwrap();
2306        assert!(!json.contains("sk-proj-1234567890abcdef"));
2307        assert!(!json.contains("ghp_1234567890abcdef1234"));
2308        assert!(!json.contains("github_pat_1234567890abcdef12"));
2309    }
2310
2311    // ── Regression tests ──────────────────────────────────────────────────────
2312
2313    fn non_restricted_policy(project_root: PathBuf, data_dir: PathBuf) -> SecurityPolicy {
2314        policy_with_config(
2315            project_root,
2316            data_dir,
2317            SecurityConfig {
2318                permission_mode: PermissionMode::Yolo,
2319                restrict_paths_to_project: false,
2320                ..SecurityConfig::default()
2321            },
2322        )
2323    }
2324
2325    #[cfg(unix)]
2326    #[test]
2327    fn regression_symlink_attack_allowed_when_not_restricted() {
2328        use std::os::unix::fs::symlink;
2329
2330        let tempdir = tempfile::tempdir().expect("tempdir");
2331        let project = tempdir.path().join("project");
2332        let data = tempdir.path().join("data");
2333        let outside = tempdir.path().join("outside");
2334        std::fs::create_dir_all(&project).expect("project");
2335        std::fs::create_dir_all(&data).expect("data");
2336        std::fs::create_dir_all(&outside).expect("outside");
2337        std::fs::write(outside.join("secret.txt"), "secret").expect("write");
2338
2339        // Symlink inside project points to outside file
2340        let link = project.join("link.txt");
2341        symlink(outside.join("secret.txt"), &link).expect("symlink");
2342
2343        let policy = non_restricted_policy(project.clone(), data);
2344        let decision = policy.validate_path(&link, false);
2345
2346        assert_eq!(decision, SecurityDecision::Allow);
2347    }
2348
2349    #[cfg(unix)]
2350    #[test]
2351    fn regression_symlink_attack_denied_in_restricted() {
2352        use std::os::unix::fs::symlink;
2353
2354        let tempdir = tempfile::tempdir().expect("tempdir");
2355        let project = tempdir.path().join("project");
2356        let data = tempdir.path().join("data");
2357        let outside = tempdir.path().join("outside");
2358        std::fs::create_dir_all(&project).expect("project");
2359        std::fs::create_dir_all(&data).expect("data");
2360        std::fs::create_dir_all(&outside).expect("outside");
2361        std::fs::write(outside.join("secret.txt"), "secret").expect("write");
2362
2363        let link = project.join("link.txt");
2364        symlink(outside.join("secret.txt"), &link).expect("symlink");
2365
2366        let policy = policy(project, data);
2367        let decision = policy.validate_path(&link, false);
2368
2369        assert!(
2370            matches!(decision, SecurityDecision::Deny(_)),
2371            "Restricted must deny symlink escape, got {decision:?}"
2372        );
2373    }
2374
2375    #[test]
2376    fn regression_path_traversal_allowed_when_not_restricted() {
2377        let tempdir = tempfile::tempdir().expect("tempdir");
2378        let project = tempdir.path().join("project");
2379        let data = tempdir.path().join("data");
2380        std::fs::create_dir_all(&project).expect("project");
2381        std::fs::create_dir_all(&data).expect("data");
2382        let policy = non_restricted_policy(project.clone(), data);
2383
2384        let traversal = project.join("../../../etc/passwd");
2385        let decision = policy.validate_path(&traversal, false);
2386
2387        assert_eq!(decision, SecurityDecision::Allow);
2388    }
2389
2390    #[test]
2391    fn regression_path_traversal_denied_in_restricted() {
2392        let tempdir = tempfile::tempdir().expect("tempdir");
2393        let project = tempdir.path().join("project");
2394        let data = tempdir.path().join("data");
2395        std::fs::create_dir_all(&project).expect("project");
2396        std::fs::create_dir_all(&data).expect("data");
2397        let policy = policy(project.clone(), data);
2398
2399        let traversal = project.join("../../../etc/passwd");
2400        let decision = policy.validate_path(&traversal, false);
2401
2402        assert!(
2403            matches!(decision, SecurityDecision::Deny(_)),
2404            "Restricted must deny traversal, got {decision:?}"
2405        );
2406    }
2407
2408    #[test]
2409    fn regression_command_full_path_extracts_basename() {
2410        let tempdir = tempfile::tempdir().expect("tempdir");
2411        let project = tempdir.path().join("project");
2412        let data = tempdir.path().join("data");
2413        std::fs::create_dir_all(&project).expect("project");
2414        std::fs::create_dir_all(&data).expect("data");
2415        let policy = policy(project, data);
2416
2417        // /usr/bin/sudo should extract "sudo" and deny it
2418        let decision = policy.validate_command("/usr/bin/sudo");
2419        assert!(
2420            matches!(decision, SecurityDecision::Deny(_)),
2421            "full-path blocked command must be denied, got: {decision:?}"
2422        );
2423    }
2424
2425    #[test]
2426    fn regression_command_sudo_with_args_denied() {
2427        let tempdir = tempfile::tempdir().expect("tempdir");
2428        let project = tempdir.path().join("project");
2429        let data = tempdir.path().join("data");
2430        std::fs::create_dir_all(&project).expect("project");
2431        std::fs::create_dir_all(&data).expect("data");
2432        let policy = policy(project, data);
2433
2434        let decision = policy.validate_command("sudo rm -rf /");
2435        assert!(
2436            matches!(decision, SecurityDecision::Deny(_)),
2437            "sudo with args must be denied, got: {decision:?}"
2438        );
2439    }
2440
2441    #[test]
2442    fn regression_data_dir_path_denied() {
2443        let tempdir = tempfile::tempdir().expect("tempdir");
2444        let project = tempdir.path().join("project");
2445        let data = tempdir.path().join("data");
2446        std::fs::create_dir_all(&project).expect("project");
2447        std::fs::create_dir_all(&data).expect("data");
2448        let policy = policy(project, data.clone());
2449
2450        let decision = policy.validate_path(data.join("sessions/test.json").as_path(), false);
2451        assert!(
2452            matches!(decision, SecurityDecision::Deny(_)),
2453            "NAVI data dir must be denied, got: {decision:?}"
2454        );
2455    }
2456
2457    #[test]
2458    fn regression_validate_patch_mixed_files_allowed_when_not_restricted() {
2459        let tempdir = tempfile::tempdir().expect("tempdir");
2460        let project = tempdir.path().join("project");
2461        let data = tempdir.path().join("data");
2462        std::fs::create_dir_all(&project).expect("project");
2463        std::fs::create_dir_all(&data).expect("data");
2464        let policy = non_restricted_policy(project.clone(), data);
2465
2466        // One valid file, one outside file - both allowed when not restricted
2467        let patch = PatchProposal {
2468            id: "p1".to_string(),
2469            summary: "edit".to_string(),
2470            files: vec![
2471                project.join("src/lib.rs"),
2472                tempdir.path().join("outside.txt"),
2473            ],
2474            unified_diff: String::new(),
2475        };
2476
2477        assert_eq!(
2478            policy.validate_patch(&patch),
2479            SecurityDecision::NeedsApproval(SecurityRisk::Write)
2480        );
2481    }
2482
2483    #[test]
2484    fn regression_validate_patch_mixed_files_denied_in_restricted() {
2485        let tempdir = tempfile::tempdir().expect("tempdir");
2486        let project = tempdir.path().join("project");
2487        let data = tempdir.path().join("data");
2488        std::fs::create_dir_all(&project).expect("project");
2489        std::fs::create_dir_all(&data).expect("data");
2490        let policy = policy(project.clone(), data);
2491
2492        let patch = PatchProposal {
2493            id: "p1".to_string(),
2494            summary: "edit".to_string(),
2495            files: vec![
2496                project.join("src/lib.rs"),
2497                tempdir.path().join("outside.txt"),
2498            ],
2499            unified_diff: String::new(),
2500        };
2501
2502        assert!(
2503            matches!(policy.validate_patch(&patch), SecurityDecision::Deny(_)),
2504            "Restricted must deny patches that touch outside-project files"
2505        );
2506    }
2507
2508    #[test]
2509    fn regression_redact_github_token() {
2510        // GITHUB_TOKEN is not in is_secret_assignment_name (which checks for
2511        // API_KEY, ACCESS_TOKEN, AUTH_TOKEN, SECRET, or exact TOKEN).
2512        // The value ghp_xxx IS caught by looks_like_secret_token.
2513        let text = "ghp_1234567890abcdef1234567890abcdef12";
2514        let redacted = redact_secrets(text);
2515        assert!(
2516            redacted.contains("<redacted>"),
2517            "ghp_ token value must be redacted"
2518        );
2519        assert!(
2520            !redacted.contains("ghp_1234567890abcdef1234567890abcdef12"),
2521            "token value must not appear"
2522        );
2523    }
2524
2525    #[test]
2526    fn regression_redact_hf_token() {
2527        let text = "HF_TOKEN=hf_1234567890abcdef1234567890abcdef12";
2528        let redacted = redact_secrets(text);
2529        assert!(redacted.contains("<redacted>"), "HF_TOKEN must be redacted");
2530    }
2531
2532    #[test]
2533    fn regression_redact_short_sk_not_clobbered() {
2534        // Short sk- values that don't look like real tokens should NOT be redacted
2535        let text = "use sk-abc for testing";
2536        let redacted = redact_secrets(text);
2537        assert_eq!(redacted, text, "short sk- value must not be redacted");
2538    }
2539
2540    #[test]
2541    fn regression_redact_empty_string() {
2542        assert_eq!(redact_secrets(""), "");
2543    }
2544
2545    #[test]
2546    fn regression_redact_nested_json_array() {
2547        let value = serde_json::json!({
2548            "items": [
2549                {"key": "OPENAI_API_KEY=sk-proj-1234567890abcdef"},
2550                {"key": "normal value"}
2551            ]
2552        });
2553        let redacted = redact_json_value(&value);
2554        let items = redacted["items"].as_array().unwrap();
2555        assert!(items[0]["key"].as_str().unwrap().contains("<redacted>"));
2556        assert_eq!(items[1]["key"].as_str().unwrap(), "normal value");
2557    }
2558
2559    #[test]
2560    fn regression_mark_feature_done_denies_blocked_verification_steps() {
2561        let tempdir = tempfile::tempdir().expect("tempdir");
2562        let project = tempdir.path().join("project");
2563        let data = tempdir.path().join("data");
2564        std::fs::create_dir_all(&project).expect("project");
2565        std::fs::create_dir_all(&data).expect("data");
2566        let policy = policy(project, data);
2567
2568        let decision = policy.validate_tool_invocation(
2569            &ToolDefinition {
2570                name: "mark_feature_done".to_string(),
2571                description: String::new(),
2572                kind: ToolKind::Command,
2573                input_schema: serde_json::json!({}),
2574                ..Default::default()
2575            },
2576            &ToolInvocation {
2577                id: "done".to_string(),
2578                tool_name: "mark_feature_done".to_string(),
2579                input: serde_json::json!({
2580                    "feature_id": "danger",
2581                    "verification_steps": ["sudo rm -rf /"]
2582                }),
2583            },
2584        );
2585
2586        assert!(matches!(decision, SecurityDecision::Deny(_)));
2587    }
2588
2589    #[test]
2590    fn regression_command_cwd_outside_project_denied() {
2591        let tempdir = tempfile::tempdir().expect("tempdir");
2592        let project = tempdir.path().join("project");
2593        let data = tempdir.path().join("data");
2594        let outside = tempdir.path().join("outside");
2595        std::fs::create_dir_all(&project).expect("project");
2596        std::fs::create_dir_all(&data).expect("data");
2597        std::fs::create_dir_all(&outside).expect("outside");
2598        let policy = SecurityPolicy::new(
2599            project,
2600            data,
2601            SecurityConfig {
2602                restrict_paths_to_project: true,
2603                ..SecurityConfig::default()
2604            },
2605        )
2606        .expect("policy");
2607
2608        let decision = policy.validate_tool_invocation(
2609            &ToolDefinition {
2610                name: "verifier".to_string(),
2611                description: String::new(),
2612                kind: ToolKind::Command,
2613                input_schema: serde_json::json!({}),
2614                ..Default::default()
2615            },
2616            &ToolInvocation {
2617                id: "verify".to_string(),
2618                tool_name: "verifier".to_string(),
2619                input: serde_json::json!({
2620                    "action": "run",
2621                    "command": "pwd",
2622                    "cwd": outside
2623                }),
2624            },
2625        );
2626
2627        assert!(matches!(decision, SecurityDecision::Deny(_)));
2628    }
2629
2630    #[test]
2631    fn regression_apply_patch_structured_git_path_denied() {
2632        let tempdir = tempfile::tempdir().expect("tempdir");
2633        let project = tempdir.path().join("project");
2634        let data = tempdir.path().join("data");
2635        std::fs::create_dir_all(project.join(".git")).expect("project");
2636        std::fs::create_dir_all(&data).expect("data");
2637        let policy = policy(project, data);
2638
2639        let decision = policy.validate_tool_invocation(
2640            &ToolDefinition {
2641                name: "apply_patch".to_string(),
2642                description: String::new(),
2643                kind: ToolKind::Write,
2644                input_schema: serde_json::json!({}),
2645                ..Default::default()
2646            },
2647            &ToolInvocation {
2648                id: "patch".to_string(),
2649                tool_name: "apply_patch".to_string(),
2650                input: serde_json::json!({
2651                    "patch": "*** Begin Patch\n*** Add File: .git/config\n+bad\n*** End Patch\n"
2652                }),
2653            },
2654        );
2655
2656        assert!(matches!(decision, SecurityDecision::Deny(_)));
2657    }
2658
2659    #[test]
2660    fn regression_unified_write_direct_git_path_denied() {
2661        let tempdir = tempfile::tempdir().expect("tempdir");
2662        let project = tempdir.path().join("project");
2663        let data = tempdir.path().join("data");
2664        std::fs::create_dir_all(project.join(".git")).expect("project");
2665        std::fs::create_dir_all(&data).expect("data");
2666        let policy = policy(project, data);
2667
2668        let decision = policy.validate_tool_invocation(
2669            &ToolDefinition {
2670                name: "write".to_string(),
2671                description: String::new(),
2672                kind: ToolKind::Write,
2673                input_schema: serde_json::json!({}),
2674                ..Default::default()
2675            },
2676            &ToolInvocation {
2677                id: "write".to_string(),
2678                tool_name: "write".to_string(),
2679                input: serde_json::json!({
2680                    "path": ".git/config",
2681                    "content": "bad"
2682                }),
2683            },
2684        );
2685
2686        assert!(matches!(decision, SecurityDecision::Deny(_)));
2687    }
2688
2689    #[test]
2690    fn regression_apply_patch_structured_traversal_allowed_when_not_restricted() {
2691        let tempdir = tempfile::tempdir().expect("tempdir");
2692        let project = tempdir.path().join("project");
2693        let data = tempdir.path().join("data");
2694        std::fs::create_dir_all(&project).expect("project");
2695        std::fs::create_dir_all(&data).expect("data");
2696        let policy = non_restricted_policy(project, data);
2697
2698        let decision = policy.validate_tool_invocation(
2699            &ToolDefinition {
2700                name: "apply_patch".to_string(),
2701                description: String::new(),
2702                kind: ToolKind::Write,
2703                input_schema: serde_json::json!({}),
2704                ..Default::default()
2705            },
2706            &ToolInvocation {
2707                id: "patch".to_string(),
2708                tool_name: "apply_patch".to_string(),
2709                input: serde_json::json!({
2710                    "patch": "*** Begin Patch\n*** Add File: ../outside.txt\n+bad\n*** End Patch\n"
2711                }),
2712            },
2713        );
2714
2715        // YOLO auto-approves writes; the important check is that path jail does
2716        // not Deny traversal outside Restricted mode.
2717        assert_eq!(decision, SecurityDecision::Allow);
2718    }
2719
2720    #[test]
2721    fn regression_apply_patch_structured_traversal_denied_in_restricted() {
2722        let tempdir = tempfile::tempdir().expect("tempdir");
2723        let project = tempdir.path().join("project");
2724        let data = tempdir.path().join("data");
2725        std::fs::create_dir_all(&project).expect("project");
2726        std::fs::create_dir_all(&data).expect("data");
2727        let policy = policy(project, data);
2728
2729        let decision = policy.validate_tool_invocation(
2730            &ToolDefinition {
2731                name: "apply_patch".to_string(),
2732                description: String::new(),
2733                kind: ToolKind::Write,
2734                input_schema: serde_json::json!({}),
2735                ..Default::default()
2736            },
2737            &ToolInvocation {
2738                id: "patch".to_string(),
2739                tool_name: "apply_patch".to_string(),
2740                input: serde_json::json!({
2741                    "patch": "*** Begin Patch\n*** Add File: ../outside.txt\n+bad\n*** End Patch\n"
2742                }),
2743            },
2744        );
2745
2746        assert!(
2747            matches!(decision, SecurityDecision::Deny(_)),
2748            "Restricted must deny apply_patch traversal, got {decision:?}"
2749        );
2750    }
2751
2752    #[test]
2753    fn regression_apply_patch_unified_git_path_denied() {
2754        let tempdir = tempfile::tempdir().expect("tempdir");
2755        let project = tempdir.path().join("project");
2756        let data = tempdir.path().join("data");
2757        std::fs::create_dir_all(project.join(".git")).expect("project");
2758        std::fs::create_dir_all(&data).expect("data");
2759        let policy = policy(project, data);
2760
2761        let decision = policy.validate_tool_invocation(
2762            &ToolDefinition {
2763                name: "apply_patch".to_string(),
2764                description: String::new(),
2765                kind: ToolKind::Write,
2766                input_schema: serde_json::json!({}),
2767                ..Default::default()
2768            },
2769            &ToolInvocation {
2770                id: "patch".to_string(),
2771                tool_name: "apply_patch".to_string(),
2772                input: serde_json::json!({
2773                    "patch": "--- a/.git/config\n+++ b/.git/config\n@@ -1 +1 @@\n-old\n+new\n"
2774                }),
2775            },
2776        );
2777
2778        assert!(matches!(decision, SecurityDecision::Deny(_)));
2779    }
2780
2781    // ── Deny list tests ────────────────────────────────────────────────────
2782
2783    #[test]
2784    fn deny_list_allows_node_modules_when_empty() {
2785        let tempdir = tempfile::tempdir().expect("tempdir");
2786        let project = tempdir.path().join("project");
2787        let data = tempdir.path().join("data");
2788        std::fs::create_dir_all(project.join("node_modules/pkg")).expect("nm");
2789        std::fs::create_dir_all(&data).expect("data");
2790        let policy = policy(project.clone(), data);
2791
2792        let decision =
2793            policy.validate_path(project.join("node_modules/pkg/index.js").as_path(), false);
2794
2795        assert_eq!(decision, SecurityDecision::Allow);
2796    }
2797
2798    #[test]
2799    fn deny_list_allows_target_when_empty() {
2800        let tempdir = tempfile::tempdir().expect("tempdir");
2801        let project = tempdir.path().join("project");
2802        let data = tempdir.path().join("data");
2803        std::fs::create_dir_all(project.join("target/debug")).expect("target");
2804        std::fs::create_dir_all(&data).expect("data");
2805        let policy = policy(project.clone(), data);
2806
2807        let decision = policy.validate_path(project.join("target/debug/app").as_path(), false);
2808
2809        assert_eq!(decision, SecurityDecision::Allow);
2810    }
2811
2812    #[test]
2813    fn deny_list_allows_log_files_when_empty() {
2814        let tempdir = tempfile::tempdir().expect("tempdir");
2815        let project = tempdir.path().join("project");
2816        let data = tempdir.path().join("data");
2817        std::fs::create_dir_all(&project).expect("project");
2818        std::fs::create_dir_all(&data).expect("data");
2819        std::fs::write(project.join("debug.log"), "logs").expect("log");
2820        let policy = policy(project.clone(), data);
2821
2822        let decision = policy.validate_path(project.join("debug.log").as_path(), false);
2823
2824        assert_eq!(decision, SecurityDecision::Allow);
2825    }
2826
2827    #[test]
2828    fn deny_list_allows_normal_files() {
2829        let tempdir = tempfile::tempdir().expect("tempdir");
2830        let project = tempdir.path().join("project");
2831        let data = tempdir.path().join("data");
2832        std::fs::create_dir_all(project.join("src")).expect("src");
2833        std::fs::create_dir_all(&data).expect("data");
2834        let policy = policy(project.clone(), data);
2835
2836        let decision = policy.validate_path(project.join("src/main.rs").as_path(), false);
2837
2838        assert_eq!(decision, SecurityDecision::Allow);
2839    }
2840
2841    #[test]
2842    fn deny_list_allows_package_lock_when_empty() {
2843        let tempdir = tempfile::tempdir().expect("tempdir");
2844        let project = tempdir.path().join("project");
2845        let data = tempdir.path().join("data");
2846        std::fs::create_dir_all(&project).expect("project");
2847        std::fs::create_dir_all(&data).expect("data");
2848        std::fs::write(project.join("package-lock.json"), "{}").expect("lock");
2849        let policy = policy(project.clone(), data);
2850
2851        let decision = policy.validate_path(project.join("package-lock.json").as_path(), false);
2852
2853        assert_eq!(decision, SecurityDecision::Allow);
2854    }
2855
2856    #[test]
2857    fn is_path_denied_glob_pattern_empty_deny_list() {
2858        let tempdir = tempfile::tempdir().expect("tempdir");
2859        let project = tempdir.path().join("project");
2860        let data = tempdir.path().join("data");
2861        std::fs::create_dir_all(&project).expect("project");
2862        std::fs::create_dir_all(&data).expect("data");
2863        let policy = policy(project, data);
2864
2865        assert!(!policy.is_path_denied(Path::new("app.log")));
2866        assert!(!policy.is_path_denied(Path::new("logs/debug.log")));
2867        assert!(!policy.is_path_denied(Path::new("app.rs")));
2868    }
2869
2870    #[test]
2871    fn is_path_denied_directory_prefix_empty_deny_list() {
2872        let tempdir = tempfile::tempdir().expect("tempdir");
2873        let project = tempdir.path().join("project");
2874        let data = tempdir.path().join("data");
2875        std::fs::create_dir_all(&project).expect("project");
2876        std::fs::create_dir_all(&data).expect("data");
2877        let policy = policy(project, data);
2878
2879        assert!(!policy.is_path_denied(Path::new("node_modules/foo/bar.js")));
2880        assert!(!policy.is_path_denied(Path::new("target/debug/app")));
2881        assert!(!policy.is_path_denied(Path::new("src/main.rs")));
2882    }
2883
2884    #[test]
2885    fn filter_denied_lines_keeps_all_when_empty_deny_list() {
2886        let tempdir = tempfile::tempdir().expect("tempdir");
2887        let project = tempdir.path().join("project");
2888        let data = tempdir.path().join("data");
2889        std::fs::create_dir_all(&project).expect("project");
2890        std::fs::create_dir_all(&data).expect("data");
2891        let policy = policy(project, data);
2892
2893        let text = "src/main.rs:42: fn main() {}\nnode_modules/foo/index.js:1: export {}\nsrc/lib.rs:10: pub fn test()\n";
2894        let filtered = policy.filter_denied_lines(text);
2895
2896        assert_eq!(filtered, text);
2897    }
2898
2899    #[test]
2900    fn filter_denied_lines_empty_deny_list() {
2901        let tempdir = tempfile::tempdir().expect("tempdir");
2902        let project = tempdir.path().join("project");
2903        let data = tempdir.path().join("data");
2904        std::fs::create_dir_all(&project).expect("project");
2905        std::fs::create_dir_all(&data).expect("data");
2906
2907        let config = SecurityConfig {
2908            deny_paths: vec![],
2909            ..Default::default()
2910        };
2911        let policy = SecurityPolicy::new(project, data, config).expect("policy");
2912
2913        let text = "node_modules/foo/index.js:1: export {}\n";
2914        let filtered = policy.filter_denied_lines(text);
2915
2916        assert_eq!(filtered, text);
2917    }
2918
2919    // ── MCP server allowlist tests ──────────────────────────────────────────
2920
2921    #[test]
2922    fn mcp_allowlist_empty_allows_all_servers() {
2923        let config = SecurityConfig::default();
2924        assert!(config.is_mcp_server_allowed("any-server"));
2925        assert!(config.is_mcp_server_allowed(""));
2926    }
2927
2928    #[test]
2929    fn mcp_allowlist_blocks_disallowed_servers() {
2930        let config = SecurityConfig {
2931            allowed_mcp_servers: vec!["safe-server".to_string()],
2932            ..Default::default()
2933        };
2934        assert!(!config.is_mcp_server_allowed("unsafe-server"));
2935        assert!(config.is_mcp_server_allowed("safe-server"));
2936    }
2937
2938    #[test]
2939    fn validate_mcp_server_respects_allowlist() {
2940        let tempdir = tempfile::tempdir().expect("tempdir");
2941        let project = tempdir.path().join("project");
2942        let data = tempdir.path().join("data");
2943        std::fs::create_dir_all(&project).expect("project");
2944        std::fs::create_dir_all(&data).expect("data");
2945
2946        let config = SecurityConfig {
2947            allowed_mcp_servers: vec!["trusted".to_string()],
2948            ..Default::default()
2949        };
2950        let policy = SecurityPolicy::new(project, data, config).expect("policy");
2951
2952        assert_eq!(
2953            policy.validate_mcp_server("trusted"),
2954            SecurityDecision::Allow
2955        );
2956        assert!(matches!(
2957            policy.validate_mcp_server("untrusted"),
2958            SecurityDecision::Deny(_)
2959        ));
2960    }
2961
2962    #[test]
2963    fn validate_mcp_server_empty_allowlist_allows_all() {
2964        let tempdir = tempfile::tempdir().expect("tempdir");
2965        let project = tempdir.path().join("project");
2966        let data = tempdir.path().join("data");
2967        std::fs::create_dir_all(&project).expect("project");
2968        std::fs::create_dir_all(&data).expect("data");
2969
2970        let config = SecurityConfig::default();
2971        let policy = SecurityPolicy::new(project, data, config).expect("policy");
2972
2973        assert_eq!(
2974            policy.validate_mcp_server("any-server"),
2975            SecurityDecision::Allow
2976        );
2977    }
2978}