Skip to main content

navi_core/
effect.rs

1//! Effect-based permissions: analyse what a tool execution actually affected
2//! on disk and make post-execution security decisions.
3//!
4//! This module provides the [`EffectAnalyzer`] which inspects file paths for
5//! sensitivity patterns (`.env`, `Cargo.toml`, `Dockerfile`, CI config, etc.)
6//! and produces an [`EffectReport`] with a classified [`BlastRadius`].
7//! The resulting [`PostDecision`] feeds into the security policy to escalate
8//! guarded effects (e.g. roll back `.env` modifications).
9
10use serde::{Deserialize, Serialize};
11use std::path::{Path, PathBuf};
12
13/// What a tool execution actually affected on disk.
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15pub struct EffectReport {
16    /// Files that were created by the tool.
17    pub files_created: Vec<PathBuf>,
18    /// Files that were modified by the tool.
19    pub files_modified: Vec<PathBuf>,
20    /// Files that were deleted by the tool.
21    pub files_deleted: Vec<PathBuf>,
22    /// Human-readable descriptions of sensitive files affected
23    /// (e.g. `"Cargo.toml (dependency)"`, `".env (secret)"`).
24    pub key_files_affected: Vec<String>,
25    /// Categorisation of how widespread the effect is.
26    pub blast_radius: BlastRadius,
27}
28
29/// How widespread a tool's effect is.
30///
31/// Each variant carries a `&'static str` representation matching the
32/// instruction spec for serialisation or display.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34pub enum BlastRadius {
35    /// A single non-sensitive file was touched.
36    SingleFile,
37    /// Multiple non-sensitive files were touched.
38    MultipleFiles,
39    /// A dependency manifest was modified (`Cargo.toml`, `package.json`, …).
40    DependencyChange,
41    /// CI/CD configuration was modified (`.github/`, `ci/`, …).
42    CiConfig,
43    /// A security-sensitive file was touched (`.env`, credentials, …).
44    SecuritySensitive,
45}
46
47impl std::fmt::Display for BlastRadius {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        match self {
50            Self::SingleFile => write!(f, "single_file"),
51            Self::MultipleFiles => write!(f, "multiple_files"),
52            Self::DependencyChange => write!(f, "dependency_change"),
53            Self::CiConfig => write!(f, "ci_config"),
54            Self::SecuritySensitive => write!(f, "security_sensitive"),
55        }
56    }
57}
58
59/// Decision after analysing the effects of a completed tool execution.
60///
61/// Returned by [`SecurityPolicy::post_execution_effect_check`] to tell the
62/// harness what to do about the just-completed tool result.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub enum PostDecision {
65    /// The effect is acceptable; no further action needed.
66    Allow,
67    /// The effect should be surfaced to the user for confirmation.
68    Ask(String),
69    /// The effect is unacceptable; the result should be treated as denied.
70    Deny(String),
71    /// The effect is problematic; the harness should attempt to roll back.
72    Rollback(String),
73}
74
75/// Inspects file paths for sensitivity and categorises blast radius.
76#[derive(Debug, Clone)]
77pub struct EffectAnalyzer;
78
79impl EffectAnalyzer {
80    /// Analyse created / modified / deleted paths and produce an effect report.
81    ///
82    /// `created`, `modified`, and `deleted` are separate sets so the report
83    /// can differentiate creation vs. modification when needed.
84    pub fn analyze(created: &[PathBuf], modified: &[PathBuf], deleted: &[PathBuf]) -> EffectReport {
85        let all_paths: Vec<&PathBuf> = created
86            .iter()
87            .chain(modified.iter())
88            .chain(deleted.iter())
89            .collect();
90
91        let key_files_affected = Self::find_sensitive_files(&all_paths);
92        let blast_radius = Self::classify_blast_radius(&all_paths, &key_files_affected);
93
94        EffectReport {
95            files_created: created.to_vec(),
96            files_modified: modified.to_vec(),
97            files_deleted: deleted.to_vec(),
98            key_files_affected,
99            blast_radius,
100        }
101    }
102
103    /// Check a single path against known sensitivity patterns.
104    ///
105    /// Returns a human-readable label when the path matches a sensitivity
106    /// pattern, or `None` if the path is unremarkable.
107    pub fn check_sensitivity(path: &Path) -> Option<String> {
108        let file_name = path.file_name()?.to_str()?;
109        let path_str = path.to_string_lossy();
110
111        // Security-sensitive files (secrets, credentials).
112        if file_name == ".env" {
113            return Some(".env (secret)".to_string());
114        }
115        if file_name == ".env.example" {
116            return Some(".env.example (secret template)".to_string());
117        }
118        if file_name == ".gitignore" {
119            return Some(".gitignore (ignore rules)".to_string());
120        }
121
122        // Dependency manifests.
123        if file_name == "Cargo.toml" {
124            return Some("Cargo.toml (dependency)".to_string());
125        }
126        if file_name == "Cargo.lock" {
127            return Some("Cargo.lock (lockfile)".to_string());
128        }
129        if file_name == "package.json" {
130            return Some("package.json (dependency)".to_string());
131        }
132        if file_name == "package-lock.json" {
133            return Some("package-lock.json (lockfile)".to_string());
134        }
135        if file_name == "yarn.lock" {
136            return Some("yarn.lock (lockfile)".to_string());
137        }
138        if file_name == "pnpm-lock.yaml" {
139            return Some("pnpm-lock.yaml (lockfile)".to_string());
140        }
141
142        // Container / infrastructure.
143        if file_name == "Dockerfile" {
144            return Some("Dockerfile (container)".to_string());
145        }
146        if file_name == "docker-compose.yml" || file_name == "docker-compose.yaml" {
147            return Some(format!("{file_name} (container)"));
148        }
149
150        // CI/CD configuration.
151        if path_str.contains("/.github/") || path_str.starts_with(".github/") {
152            return Some(".github/ (CI config)".to_string());
153        }
154        if path_str.contains("/ci/") || path_str.starts_with("ci/") {
155            return Some("ci/ (CI config)".to_string());
156        }
157
158        // Package manager / runtime config.
159        if matches!(
160            file_name.as_ref(),
161            ".npmrc" | ".yarnrc" | ".yarnrc.yml" | ".browserslistrc"
162        ) {
163            return Some(format!("{file_name} (config)"));
164        }
165
166        None
167    }
168
169    // ── Internal helpers ─────────────────────────────────────────────────
170
171    fn find_sensitive_files(paths: &[&PathBuf]) -> Vec<String> {
172        let mut sensitive = Vec::new();
173        for path in paths {
174            if let Some(label) = Self::check_sensitivity(path) {
175                if !sensitive.contains(&label) {
176                    sensitive.push(label);
177                }
178            }
179        }
180        sensitive
181    }
182
183    fn classify_blast_radius(all_paths: &[&PathBuf], key_files: &[String]) -> BlastRadius {
184        // Security-sensitive files take highest priority.
185        if key_files.iter().any(|l| l.contains("secret")) {
186            return BlastRadius::SecuritySensitive;
187        }
188
189        // CI configuration changes.
190        if key_files.iter().any(|l| l.contains("CI config")) {
191            return BlastRadius::CiConfig;
192        }
193
194        // Dependency / lockfile changes.
195        if key_files
196            .iter()
197            .any(|l| l.contains("dependency") || l.contains("lockfile"))
198        {
199            return BlastRadius::DependencyChange;
200        }
201
202        // Container config.
203        if key_files.iter().any(|l| l.contains("container")) {
204            return BlastRadius::DependencyChange;
205        }
206
207        match all_paths.len() {
208            0 | 1 => BlastRadius::SingleFile,
209            _ => BlastRadius::MultipleFiles,
210        }
211    }
212}
213
214/// Extract file paths referenced by a completed tool result and its
215/// originating invocation.
216///
217/// Looks at common fields in both the result output and the invocation input
218/// so that the [`EffectAnalyzer`] has paths to inspect.
219pub fn extract_paths(
220    result: &crate::tool::ToolResult,
221    invocation: &crate::tool::ToolInvocation,
222) -> Vec<PathBuf> {
223    let mut paths: Vec<PathBuf> = Vec::new();
224
225    // Result output: direct write provides "path".
226    if let Some(p) = result
227        .output
228        .get("path")
229        .and_then(serde_json::Value::as_str)
230    {
231        push_unique_path(&mut paths, PathBuf::from(p));
232    }
233
234    // Result output: patch mode provides "affected_paths".
235    if let Some(affected) = result
236        .output
237        .get("affected_paths")
238        .and_then(serde_json::Value::as_array)
239    {
240        for v in affected {
241            if let Some(p) = v.as_str() {
242                push_unique_path(&mut paths, PathBuf::from(p));
243            }
244        }
245    }
246
247    // Invocation input: "file", "path", or Crush-compatible "file_path".
248    for key in &["file", "path", "file_path"] {
249        if let Some(p) = invocation
250            .input
251            .get(*key)
252            .and_then(serde_json::Value::as_str)
253        {
254            push_unique_path(&mut paths, PathBuf::from(p));
255        }
256    }
257
258    // Result output: files_changed from edit/multiedit/search-replace.
259    if let Some(changed) = result
260        .output
261        .get("files_changed")
262        .and_then(serde_json::Value::as_array)
263    {
264        for v in changed {
265            if let Some(p) = v.as_str() {
266                push_unique_path(&mut paths, PathBuf::from(p));
267            }
268        }
269    }
270
271    paths
272}
273
274fn push_unique_path(paths: &mut Vec<PathBuf>, path: PathBuf) {
275    if !paths.contains(&path) {
276        paths.push(path);
277    }
278}
279
280// ═══════════════════════════════════════════════════════════════════════════
281// Tests
282// ═══════════════════════════════════════════════════════════════════════════
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    use crate::tool::{ToolInvocation, ToolResult};
288    use serde_json::json;
289    use std::path::PathBuf;
290
291    // ── Sensitivity detection ────────────────────────────────────────────
292
293    #[test]
294    fn analyzer_detects_env_file() {
295        let report = EffectAnalyzer::analyze(&[PathBuf::from(".env")], &[], &[]);
296        assert!(
297            report.key_files_affected.iter().any(|k| k.contains(".env")),
298            "expected .env in key_files_affected, got {:?}",
299            report.key_files_affected
300        );
301        assert_eq!(report.blast_radius, BlastRadius::SecuritySensitive);
302    }
303
304    #[test]
305    fn analyzer_detects_cargo_toml_change() {
306        let report = EffectAnalyzer::analyze(&[], &[PathBuf::from("Cargo.toml")], &[]);
307        assert!(
308            report
309                .key_files_affected
310                .iter()
311                .any(|k| k.contains("Cargo.toml")),
312            "expected Cargo.toml in key_files_affected, got {:?}",
313            report.key_files_affected
314        );
315        assert_eq!(report.blast_radius, BlastRadius::DependencyChange);
316    }
317
318    #[test]
319    fn analyzer_ignores_src_lib_rs_change() {
320        let report = EffectAnalyzer::analyze(&[], &[PathBuf::from("src/lib.rs")], &[]);
321        assert!(
322            report.key_files_affected.is_empty(),
323            "expected no key files for src/lib.rs, got {:?}",
324            report.key_files_affected
325        );
326        assert_eq!(report.blast_radius, BlastRadius::SingleFile);
327    }
328
329    #[test]
330    fn analyzer_detects_package_json() {
331        let report = EffectAnalyzer::analyze(&[], &[PathBuf::from("package.json")], &[]);
332        assert!(
333            report
334                .key_files_affected
335                .iter()
336                .any(|k| k.contains("package.json")),
337            "expected package.json in key_files_affected"
338        );
339        assert_eq!(report.blast_radius, BlastRadius::DependencyChange);
340    }
341
342    #[test]
343    fn analyzer_detects_dockerfile() {
344        let report = EffectAnalyzer::analyze(&[], &[PathBuf::from("Dockerfile")], &[]);
345        assert!(
346            report
347                .key_files_affected
348                .iter()
349                .any(|k| k.contains("Dockerfile")),
350            "expected Dockerfile in key_files_affected"
351        );
352    }
353
354    #[test]
355    fn analyzer_detects_gitignore() {
356        let report = EffectAnalyzer::analyze(&[], &[PathBuf::from(".gitignore")], &[]);
357        assert!(
358            report
359                .key_files_affected
360                .iter()
361                .any(|k| k.contains(".gitignore")),
362            "expected .gitignore in key_files_affected"
363        );
364    }
365
366    #[test]
367    fn analyzer_detects_github_ci_directory() {
368        let report =
369            EffectAnalyzer::analyze(&[], &[PathBuf::from(".github/workflows/ci.yml")], &[]);
370        assert!(
371            report
372                .key_files_affected
373                .iter()
374                .any(|k| k.contains("CI config")),
375            "expected CI config in key_files_affected, got {:?}",
376            report.key_files_affected
377        );
378        assert_eq!(report.blast_radius, BlastRadius::CiConfig);
379    }
380
381    #[test]
382    fn analyzer_detects_ci_folder() {
383        let report = EffectAnalyzer::analyze(&[], &[PathBuf::from("ci/build.sh")], &[]);
384        assert!(
385            report
386                .key_files_affected
387                .iter()
388                .any(|k| k.contains("CI config")),
389            "expected CI config for ci/ path"
390        );
391    }
392
393    #[test]
394    fn analyzer_detects_cargo_lock() {
395        let report = EffectAnalyzer::analyze(&[], &[PathBuf::from("Cargo.lock")], &[]);
396        assert!(
397            report
398                .key_files_affected
399                .iter()
400                .any(|k| k.contains("lockfile")),
401            "expected lockfile label for Cargo.lock"
402        );
403        assert_eq!(report.blast_radius, BlastRadius::DependencyChange);
404    }
405
406    #[test]
407    fn analyzer_detects_yarn_lock() {
408        let report = EffectAnalyzer::analyze(&[], &[PathBuf::from("yarn.lock")], &[]);
409        assert!(
410            report
411                .key_files_affected
412                .iter()
413                .any(|k| k.contains("lockfile")),
414            "expected lockfile label for yarn.lock"
415        );
416    }
417
418    // ── Sensitive file in deleted set ─────────────────────────────────────
419
420    #[test]
421    fn sensitive_file_detected_in_deleted_set() {
422        let report = EffectAnalyzer::analyze(&[], &[], &[PathBuf::from(".env")]);
423        assert!(
424            report.key_files_affected.iter().any(|k| k.contains(".env")),
425            "expected .env when it is in the deleted set"
426        );
427        assert_eq!(report.blast_radius, BlastRadius::SecuritySensitive);
428    }
429
430    // ── Multiple files blast radius ───────────────────────────────────────
431
432    #[test]
433    fn multiple_files_blast_radius() {
434        let report = EffectAnalyzer::analyze(
435            &[PathBuf::from("src/main.rs")],
436            &[PathBuf::from("src/lib.rs")],
437            &[],
438        );
439        assert_eq!(report.blast_radius, BlastRadius::MultipleFiles);
440    }
441
442    // ── PostDecision::Rollback for guarded effects ─────────────────────────
443
444    #[test]
445    fn post_decision_rollback_for_guarded_effects() {
446        let report = EffectAnalyzer::analyze(&[], &[PathBuf::from(".env")], &[]);
447
448        let decision = if report.blast_radius == BlastRadius::SecuritySensitive {
449            PostDecision::Rollback(
450                "Modifying .env file -- sensitive secrets may be exposed".to_string(),
451            )
452        } else {
453            PostDecision::Allow
454        };
455
456        assert_eq!(
457            decision,
458            PostDecision::Rollback(
459                "Modifying .env file -- sensitive secrets may be exposed".to_string()
460            )
461        );
462    }
463
464    // ── extract_paths from ToolResult ─────────────────────────────────────
465
466    #[test]
467    fn extract_paths_from_direct_write_result() {
468        let result = ToolResult {
469            invocation_id: "i1".into(),
470            ok: true,
471            output: json!({"path": "src/main.rs", "lines_added": 1}),
472        };
473        let inv = ToolInvocation {
474            id: "i1".into(),
475            tool_name: "write".into(),
476            input: json!({"path": "src/main.rs", "content": "fn main() {}"}),
477        };
478
479        let paths = extract_paths(&result, &inv);
480        assert_eq!(paths, vec![PathBuf::from("src/main.rs")]);
481    }
482
483    #[test]
484    fn extract_paths_from_patch_result() {
485        let result = ToolResult {
486            invocation_id: "i2".into(),
487            ok: true,
488            output: json!({
489                "method": "structured",
490                "affected_paths": ["a.txt", "b.txt"],
491            }),
492        };
493        let inv = ToolInvocation {
494            id: "i2".into(),
495            tool_name: "write".into(),
496            input: json!({"patch": "*** Begin Patch\n*** Add File: a.txt\n+hello\n*** End Patch"}),
497        };
498
499        let paths = extract_paths(&result, &inv);
500        assert_eq!(paths.len(), 2);
501        assert!(paths.contains(&PathBuf::from("a.txt")));
502        assert!(paths.contains(&PathBuf::from("b.txt")));
503    }
504
505    // ── BlastRadius Display ──────────────────────────────────────────────
506
507    #[test]
508    fn blast_radius_display() {
509        assert_eq!(BlastRadius::SingleFile.to_string(), "single_file");
510        assert_eq!(BlastRadius::MultipleFiles.to_string(), "multiple_files");
511        assert_eq!(
512            BlastRadius::DependencyChange.to_string(),
513            "dependency_change"
514        );
515        assert_eq!(BlastRadius::CiConfig.to_string(), "ci_config");
516        assert_eq!(
517            BlastRadius::SecuritySensitive.to_string(),
518            "security_sensitive"
519        );
520    }
521}