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#[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#[derive(Debug, Clone, Default)]
26struct PlanModeGate {
27 active: bool,
28 plan_file: Option<PathBuf>,
30}
31
32#[derive(Debug, Clone)]
35pub struct SecurityPolicy {
36 project_root: PathBuf,
37 data_dir: PathBuf,
38 config: SecurityConfig,
39 write_scope: Option<WritePathScope>,
41 plan_mode: Arc<RwLock<PlanModeGate>>,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum SecurityDecision {
48 Allow,
50 NeedsApproval(SecurityRisk),
52 Deny(String),
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
58pub enum SecurityRisk {
59 Tool,
61 Write,
63 Command,
65 GuardedCommand,
68 ExternalPlugin,
70}
71
72impl SecurityPolicy {
73 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 pub fn with_write_scope(mut self, scope: WritePathScope) -> Self {
88 self.write_scope = Some(scope);
89 self
90 }
91
92 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 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 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 gate.active && is_under_plans_dir(&self.data_dir, path) && is_markdown_path(path)
127 }
128
129 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 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 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 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 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 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 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 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 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 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 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 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 pub fn post_execution_effect_check(
622 &self,
623 tool_name: &str,
624 paths: &[PathBuf],
625 _command: Option<&str>,
626 ) -> PostDecision {
627 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 pub fn project_root(&self) -> &Path {
667 &self.project_root
668 }
669
670 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 pub fn config(&self) -> &SecurityConfig {
683 &self.config
684 }
685
686 pub fn set_config(&mut self, config: SecurityConfig) {
688 self.config = config;
689 }
690
691 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 if is_under_plans_dir(&self.data_dir, path) && is_markdown_path(path) {
706 return false;
707 }
708 if path == plans_dir(&self.data_dir) {
710 return false;
711 }
712 true
713 }
714
715 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 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 pub fn write_scope(&self) -> Option<&WritePathScope> {
749 self.write_scope.as_ref()
750 }
751
752 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 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 if let Some(suffix) = pattern_lower.strip_prefix('*') {
785 if path_lower.ends_with(suffix) {
786 return true;
787 }
788 continue;
789 }
790
791 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 if path_lower.ends_with(&pattern_lower) {
805 return true;
806 }
807 }
808
809 false
810 }
811
812 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 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 if line_lower.contains(suffix) {
840 return true;
841 }
842 } else {
843 if line_lower.contains(&pattern_lower) {
845 return true;
846 }
847 }
848 }
849 false
850 }
851}
852
853pub fn redact_snapshot_events(events: &[AgentEvent]) -> Vec<AgentEvent> {
855 events.iter().map(redact_agent_event).collect()
856}
857
858pub 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
933pub 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
996pub 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
1084fn is_destructive_git_command(program: &str) -> bool {
1087 let Some(subcommand) = git_primary_subcommand(program) else {
1088 return true;
1090 };
1091
1092 match subcommand.as_str() {
1093 "push" | "rm" | "reset" | "clean" | "rebase" | "filter-branch" | "filter-repo"
1095 | "update-ref" | "replace" | "gc" | "prune" | "notes" | "am" => true,
1096 "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 _ => false,
1105 }
1106}
1107
1108fn git_primary_subcommand(program: &str) -> Option<String> {
1111 let tokens = shell_tokens(program);
1112 let mut index = 0;
1113
1114 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 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 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
1177fn 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 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 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 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 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 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 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 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 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 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 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 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 #[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 #[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}