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