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