Skip to main content

rippy_cli/
inspect.rs

1//! The `rippy inspect` command — display rules and trace command decisions.
2
3use std::path::{Path, PathBuf};
4use std::process::ExitCode;
5
6use serde::Serialize;
7
8use crate::allowlists;
9use crate::cc_permissions;
10use crate::cli::InspectArgs;
11use crate::config::{self, Config, ConfigDirective, Rule};
12use crate::error::RippyError;
13use crate::handlers;
14use crate::parser::BashParser;
15use crate::verdict::Decision;
16
17/// Run the `rippy inspect` command.
18///
19/// # Errors
20///
21/// Returns `RippyError` if config files cannot be loaded.
22pub fn run(args: &InspectArgs) -> Result<ExitCode, RippyError> {
23    if let Some(command) = &args.command {
24        trace_command(command, args)?;
25    } else {
26        list_rules(args)?;
27    }
28    Ok(ExitCode::SUCCESS)
29}
30
31// ---------------------------------------------------------------------------
32// Mode 1: List all rules
33// ---------------------------------------------------------------------------
34
35/// Collected rules from a single source file.
36#[derive(Debug, Serialize)]
37pub(crate) struct SourceRules {
38    pub(crate) path: String,
39    pub(crate) rules: Vec<RuleDisplay>,
40}
41
42/// A single rule formatted for display.
43#[derive(Debug, Serialize)]
44pub(crate) struct RuleDisplay {
45    pub(crate) action: String,
46    pub(crate) pattern: String,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub(crate) message: Option<String>,
49}
50
51/// Summary of active configuration for JSON output.
52#[derive(Debug, Serialize)]
53pub(crate) struct ListOutput {
54    pub(crate) config_sources: Vec<SourceRules>,
55    pub(crate) cc_sources: Vec<SourceRules>,
56    active_package: Option<String>,
57    default_action: Option<String>,
58    handler_count: usize,
59    simple_safe_count: usize,
60    wrapper_count: usize,
61}
62
63fn list_rules(args: &InspectArgs) -> Result<(), RippyError> {
64    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
65    let output = collect_list_data(&cwd, args.config.as_deref())?;
66
67    if args.json {
68        let json = serde_json::to_string_pretty(&output)
69            .map_err(|e| RippyError::Setup(format!("JSON serialization failed: {e}")))?;
70        println!("{json}");
71    } else {
72        print_list_text(&output);
73    }
74    Ok(())
75}
76
77pub(crate) fn collect_list_data(
78    cwd: &Path,
79    config_override: Option<&Path>,
80) -> Result<ListOutput, RippyError> {
81    let mut config_sources = Vec::new();
82
83    for source in config::enumerate_config_sources(cwd, config_override) {
84        match source.path {
85            None => {
86                // Stdlib — load from embedded directives.
87                let directives = crate::stdlib::stdlib_directives()?;
88                let displays: Vec<RuleDisplay> =
89                    directives.iter().filter_map(directive_to_display).collect();
90                if !displays.is_empty() {
91                    config_sources.push(SourceRules {
92                        path: "(stdlib)".to_string(),
93                        rules: displays,
94                    });
95                }
96            }
97            Some(path) => {
98                config_sources.push(load_source_rules(&path)?);
99            }
100        }
101    }
102
103    // CC permissions.
104    let cc_sources = collect_cc_rules(cwd);
105
106    // Load merged config to get default action.
107    let merged = Config::load(cwd, config_override)?;
108
109    Ok(ListOutput {
110        config_sources,
111        cc_sources,
112        active_package: merged.active_package.map(|p| p.name().to_string()),
113        default_action: merged.default_action.map(|d| d.as_str().to_string()),
114        handler_count: handlers::handler_count(),
115        simple_safe_count: allowlists::simple_safe_count(),
116        wrapper_count: allowlists::wrapper_count(),
117    })
118}
119
120fn load_source_rules(path: &Path) -> Result<SourceRules, RippyError> {
121    let mut directives = Vec::new();
122    config::load_file(path, &mut directives)?;
123
124    let displays: Vec<RuleDisplay> = directives.iter().filter_map(directive_to_display).collect();
125    Ok(SourceRules {
126        path: path.display().to_string(),
127        rules: displays,
128    })
129}
130
131fn directive_to_display(directive: &ConfigDirective) -> Option<RuleDisplay> {
132    match directive {
133        ConfigDirective::Rule(rule) => Some(rule_to_display(rule)),
134        ConfigDirective::Set { .. }
135        | ConfigDirective::Alias { .. }
136        | ConfigDirective::CdAllow(_)
137        | ConfigDirective::ProjectBoundary => None,
138    }
139}
140
141fn rule_to_display(rule: &Rule) -> RuleDisplay {
142    let pattern = if rule.has_structured_fields() && rule.pattern.is_any() {
143        rule.structured_description()
144    } else if rule.has_structured_fields() {
145        format!("{} + {}", rule.pattern.raw(), rule.structured_description())
146    } else {
147        rule.pattern.raw().to_string()
148    };
149    RuleDisplay {
150        action: rule.action_str(),
151        pattern,
152        message: rule.message.clone(),
153    }
154}
155
156fn collect_cc_rules(cwd: &Path) -> Vec<SourceRules> {
157    let paths = cc_permissions::get_settings_paths(cwd);
158    let cc_rules = cc_permissions::load_cc_rules(cwd);
159    let all = cc_rules.all_rules();
160
161    if all.is_empty() {
162        return Vec::new();
163    }
164
165    // Group all CC rules under the first settings path that exists.
166    let source_path = paths.iter().find(|p| p.is_file()).map_or_else(
167        || "Claude Code settings".to_string(),
168        |p| p.display().to_string(),
169    );
170
171    let displays: Vec<RuleDisplay> = all
172        .iter()
173        .map(|(decision, pattern)| RuleDisplay {
174            action: decision.as_str().to_string(),
175            pattern: format!("Bash({pattern})"),
176            message: None,
177        })
178        .collect();
179
180    vec![SourceRules {
181        path: source_path,
182        rules: displays,
183    }]
184}
185
186fn print_list_text(output: &ListOutput) {
187    println!("Rules:\n");
188
189    for source in &output.config_sources {
190        println!("  {}:", source.path);
191        for rule in &source.rules {
192            let msg = rule
193                .message
194                .as_ref()
195                .map_or(String::new(), |m| format!("  \"{m}\""));
196            println!("    {:<6} {}{msg}", rule.action, rule.pattern);
197        }
198        println!();
199    }
200
201    for source in &output.cc_sources {
202        println!("  {}:", source.path);
203        for rule in &source.rules {
204            println!("    {:<6} {}", rule.action, rule.pattern);
205        }
206        println!();
207    }
208
209    if let Some(package) = &output.active_package {
210        println!("  Package: {package}");
211    }
212
213    if let Some(default) = &output.default_action {
214        println!("  Default: {default}");
215    }
216
217    println!("  Handlers: {} registered", output.handler_count);
218    println!("  Simple safe: {} commands", output.simple_safe_count);
219    println!("  Wrappers: {} commands", output.wrapper_count);
220}
221
222// ---------------------------------------------------------------------------
223// Mode 2: Trace a command
224// ---------------------------------------------------------------------------
225
226/// Structured trace of a command's decision path.
227#[derive(Debug, Serialize)]
228pub(crate) struct TraceOutput {
229    pub command: String,
230    pub decision: String,
231    pub reason: String,
232    /// The fully-resolved command form (after `$VAR`, `$'...'`, `$((...))`, `{a,b}`
233    /// expansion) when the analyzer resolved expansions statically. `None` when
234    /// no resolution occurred.
235    #[serde(skip_serializing_if = "Option::is_none")]
236    pub resolved: Option<String>,
237    pub steps: Vec<TraceStep>,
238}
239
240#[derive(Debug, Clone, Serialize)]
241pub(crate) struct TraceStep {
242    pub stage: String,
243    pub matched: bool,
244    pub detail: String,
245}
246
247fn trace_command(command: &str, args: &InspectArgs) -> Result<(), RippyError> {
248    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
249    let output = collect_trace_data(command, &cwd, args.config.as_deref())?;
250
251    if args.json {
252        let json = serde_json::to_string_pretty(&output)
253            .map_err(|e| RippyError::Setup(format!("JSON serialization failed: {e}")))?;
254        println!("{json}");
255    } else {
256        print_trace_text(&output);
257    }
258    Ok(())
259}
260
261pub(crate) fn collect_trace_data(
262    command: &str,
263    cwd: &Path,
264    config_override: Option<&Path>,
265) -> Result<TraceOutput, RippyError> {
266    let config = Config::load(cwd, config_override)?;
267    let cc_rules = cc_permissions::load_cc_rules(cwd);
268    let mut steps = Vec::new();
269
270    if let Some(out) = trace_cc_step(command, &cc_rules, &mut steps) {
271        return Ok(out);
272    }
273    if let Some(out) = trace_config_step(command, &config, &mut steps) {
274        return Ok(out);
275    }
276    trace_parse_and_classify(command, config, cwd, &mut steps)
277}
278
279fn trace_cc_step(
280    command: &str,
281    cc_rules: &cc_permissions::CcRules,
282    steps: &mut Vec<TraceStep>,
283) -> Option<TraceOutput> {
284    let result = cc_rules.check(command);
285    steps.push(TraceStep {
286        stage: "CC permissions".to_string(),
287        matched: result.is_some(),
288        detail: result.map_or_else(
289            || "no match".to_string(),
290            |d| format!("{} matched", d.as_str()),
291        ),
292    });
293    result.map(|decision| TraceOutput {
294        command: command.to_string(),
295        decision: decision.as_str().to_string(),
296        reason: format!("CC permission: {command}"),
297        resolved: None,
298        steps: steps.clone(),
299    })
300}
301
302fn trace_config_step(
303    command: &str,
304    config: &Config,
305    steps: &mut Vec<TraceStep>,
306) -> Option<TraceOutput> {
307    let result = config.match_command(command, None);
308    steps.push(TraceStep {
309        stage: "Config rules".to_string(),
310        matched: result.is_some(),
311        detail: result.as_ref().map_or_else(
312            || "no match".to_string(),
313            |v| format!("{}: {}", v.decision.as_str(), v.reason),
314        ),
315    });
316    result.map(|verdict| TraceOutput {
317        command: command.to_string(),
318        decision: verdict.decision.as_str().to_string(),
319        reason: verdict.reason,
320        resolved: verdict.resolved_command,
321        steps: steps.clone(),
322    })
323}
324
325fn trace_parse_and_classify(
326    command: &str,
327    config: Config,
328    cwd: &Path,
329    steps: &mut Vec<TraceStep>,
330) -> Result<TraceOutput, RippyError> {
331    let cmd_name = parse_command_name(command);
332    steps.push(TraceStep {
333        stage: "Parse".to_string(),
334        matched: cmd_name.is_some(),
335        detail: cmd_name
336            .as_ref()
337            .map_or_else(|| "parse failed".to_string(), Clone::clone),
338    });
339
340    let Some(cmd_name) = cmd_name else {
341        return Ok(make_output(
342            command,
343            "ask",
344            "could not parse command",
345            steps,
346        ));
347    };
348
349    let is_safe = allowlists::is_simple_safe(&cmd_name);
350    steps.push(TraceStep {
351        stage: "Allowlist".to_string(),
352        matched: is_safe,
353        detail: if is_safe {
354            format!("{cmd_name} is in simple_safe list")
355        } else {
356            "not in allowlist".to_string()
357        },
358    });
359
360    // When the command may contain expansions, always run the full analyzer so
361    // the resolved form is captured in the verdict's `resolved_command` field
362    // and bubbled up to `TraceOutput.resolved`. Plain safe commands without
363    // expansions short-circuit to avoid the analyzer cost.
364    let has_expansions = crate::ast::has_shell_expansion_pattern(command);
365    if is_safe && !has_expansions {
366        return Ok(make_output(command, "allow", &cmd_name, steps));
367    }
368    if is_safe || crate::handlers::get_handler(&cmd_name).is_none() {
369        // Safe command WITH expansions, or unknown command — go through the
370        // analyzer to resolve and re-classify.
371        return run_analyzer_for_trace(command, config, cwd, steps);
372    }
373
374    trace_handler_step(command, &cmd_name, config, cwd, steps)
375}
376
377/// Run the full analyzer and convert its verdict to a `TraceOutput`. Shared
378/// between the safe-with-expansions path and the handler path so resolution
379/// info propagates uniformly.
380fn run_analyzer_for_trace(
381    command: &str,
382    config: Config,
383    cwd: &Path,
384    steps: &[TraceStep],
385) -> Result<TraceOutput, RippyError> {
386    let mut analyzer = crate::analyzer::Analyzer::new(config, false, cwd.to_path_buf(), false)?;
387    let verdict = analyzer.analyze(command)?;
388    Ok(make_output_with_resolution(
389        command,
390        verdict.decision.as_str(),
391        &verdict.reason,
392        verdict.resolved_command,
393        steps,
394    ))
395}
396
397fn trace_handler_step(
398    command: &str,
399    cmd_name: &str,
400    config: Config,
401    cwd: &Path,
402    steps: &mut Vec<TraceStep>,
403) -> Result<TraceOutput, RippyError> {
404    let has_handler = handlers::get_handler(cmd_name).is_some();
405    steps.push(TraceStep {
406        stage: "Handler".to_string(),
407        matched: has_handler,
408        detail: if has_handler {
409            format!("handler registered for {cmd_name}")
410        } else {
411            "no handler registered".to_string()
412        },
413    });
414
415    if has_handler {
416        return run_analyzer_for_trace(command, config, cwd, steps);
417    }
418
419    let default = config.default_action.unwrap_or(Decision::Ask);
420    let reason = format!("default action: {}", default.as_str());
421    steps.push(TraceStep {
422        stage: "Default".to_string(),
423        matched: true,
424        detail: reason.clone(),
425    });
426    Ok(make_output(command, default.as_str(), &reason, steps))
427}
428
429fn make_output(command: &str, decision: &str, reason: &str, steps: &[TraceStep]) -> TraceOutput {
430    make_output_with_resolution(command, decision, reason, None, steps)
431}
432
433fn make_output_with_resolution(
434    command: &str,
435    decision: &str,
436    reason: &str,
437    resolved: Option<String>,
438    steps: &[TraceStep],
439) -> TraceOutput {
440    TraceOutput {
441        command: command.to_string(),
442        decision: decision.to_string(),
443        reason: reason.to_string(),
444        resolved,
445        steps: steps.to_vec(),
446    }
447}
448
449/// Extract command name from a command string, if parseable.
450fn parse_command_name(command: &str) -> Option<String> {
451    let mut parser = BashParser;
452    let nodes = parser.parse(command).ok()?;
453    let first = nodes.first()?;
454    crate::ast::command_name(first).map(String::from)
455}
456
457fn print_trace_text(output: &TraceOutput) {
458    println!("Decision: {}", output.decision.to_uppercase());
459    println!("Reason: {}", output.reason);
460    if let Some(resolved) = &output.resolved {
461        println!("Resolved: {resolved}");
462    }
463    println!("\nTrace:");
464    for (i, step) in output.steps.iter().enumerate() {
465        let status = if step.matched { "✓" } else { "·" };
466        println!("  {}. {:<16} {status} {}", i + 1, step.stage, step.detail);
467    }
468}
469
470// ---------------------------------------------------------------------------
471// Tests
472// ---------------------------------------------------------------------------
473
474#[cfg(test)]
475#[allow(clippy::unwrap_used)]
476mod tests {
477    use crate::config::RuleTarget;
478
479    use super::*;
480
481    #[test]
482    fn rule_to_display_command() {
483        let rule = Rule::new(RuleTarget::Command, Decision::Allow, "git status");
484        let d = rule_to_display(&rule);
485        assert_eq!(d.action, "allow");
486        assert_eq!(d.pattern, "git status");
487        assert!(d.message.is_none());
488    }
489
490    #[test]
491    fn rule_to_display_with_message() {
492        let rule =
493            Rule::new(RuleTarget::Command, Decision::Deny, "rm -rf *").with_message("use trash");
494        let d = rule_to_display(&rule);
495        assert_eq!(d.action, "deny");
496        assert_eq!(d.message.as_deref(), Some("use trash"));
497    }
498
499    #[test]
500    fn rule_to_display_redirect() {
501        let rule =
502            Rule::new(RuleTarget::Redirect, Decision::Deny, "**/.env*").with_message("protected");
503        let d = rule_to_display(&rule);
504        assert_eq!(d.action, "deny-redirect");
505    }
506
507    #[test]
508    fn rule_to_display_mcp() {
509        let rule = Rule::new(RuleTarget::Mcp, Decision::Allow, "mcp__github__*");
510        let d = rule_to_display(&rule);
511        assert_eq!(d.action, "allow-mcp");
512        assert_eq!(d.pattern, "mcp__github__*");
513    }
514
515    #[test]
516    fn rule_to_display_after() {
517        let rule = Rule::new(RuleTarget::After, Decision::Allow, "git commit")
518            .with_message("don't forget to push");
519        let d = rule_to_display(&rule);
520        assert_eq!(d.action, "after");
521        assert_eq!(d.message.as_deref(), Some("don't forget to push"));
522    }
523
524    #[test]
525    fn directive_to_display_skips_set() {
526        let d = ConfigDirective::Set {
527            key: "default".to_string(),
528            value: "ask".to_string(),
529        };
530        assert!(directive_to_display(&d).is_none());
531    }
532
533    #[test]
534    fn trace_handler_command() {
535        let cwd = std::env::current_dir().unwrap();
536        let output = collect_trace_data("git push origin main", &cwd, None).unwrap();
537        assert_eq!(output.decision, "ask");
538        assert!(
539            output
540                .steps
541                .iter()
542                .any(|s| s.stage == "Handler" && s.matched)
543        );
544    }
545
546    #[test]
547    fn trace_safe_command() {
548        let cwd = std::env::current_dir().unwrap();
549        let output = collect_trace_data("cat /tmp/file", &cwd, None).unwrap();
550        assert_eq!(output.decision, "allow");
551        assert!(
552            output
553                .steps
554                .iter()
555                .any(|s| s.stage == "Allowlist" && s.matched)
556        );
557    }
558
559    #[test]
560    fn trace_with_config_rule() {
561        let dir = tempfile::TempDir::new().unwrap();
562        let config_path = dir.path().join("test.toml");
563        std::fs::write(
564            &config_path,
565            "[[rules]]\naction = \"deny\"\npattern = \"echo evil\"\nmessage = \"no evil\"\n",
566        )
567        .unwrap();
568
569        let output = collect_trace_data("echo evil", dir.path(), Some(&config_path)).unwrap();
570        assert_eq!(output.decision, "deny");
571        assert_eq!(output.reason, "no evil");
572        assert!(
573            output
574                .steps
575                .iter()
576                .any(|s| s.stage == "Config rules" && s.matched)
577        );
578    }
579
580    #[test]
581    fn trace_unknown_command_asks() {
582        let dir = tempfile::TempDir::new().unwrap();
583        let output = collect_trace_data("some_unknown_tool --flag", dir.path(), None).unwrap();
584        // Unknown commands should result in ask (default).
585        assert_eq!(output.decision, "ask");
586    }
587
588    #[test]
589    fn list_rules_from_config_file() {
590        let dir = tempfile::TempDir::new().unwrap();
591        let config = dir.path().join("test.toml");
592        std::fs::write(&config, "[[rules]]\naction = \"allow\"\npattern = \"ls\"\n").unwrap();
593
594        let source = load_source_rules(&config).unwrap();
595        assert_eq!(source.rules.len(), 1);
596        assert_eq!(source.rules[0].action, "allow");
597        assert_eq!(source.rules[0].pattern, "ls");
598    }
599
600    #[test]
601    fn collect_list_with_config_override() {
602        let dir = tempfile::TempDir::new().unwrap();
603        let config = dir.path().join("test.toml");
604        std::fs::write(
605            &config,
606            "[settings]\ndefault = \"deny\"\n\n[[rules]]\naction = \"allow\"\npattern = \"git *\"\n",
607        )
608        .unwrap();
609
610        let output = collect_list_data(dir.path(), Some(&config)).unwrap();
611        assert!(!output.config_sources.is_empty());
612        assert_eq!(output.default_action.as_deref(), Some("deny"));
613        assert!(output.handler_count > 0);
614        assert!(output.simple_safe_count > 0);
615    }
616
617    #[test]
618    fn json_output_parses() {
619        let output = ListOutput {
620            config_sources: vec![SourceRules {
621                path: "test.toml".to_string(),
622                rules: vec![RuleDisplay {
623                    action: "allow".to_string(),
624                    pattern: "git status".to_string(),
625                    message: None,
626                }],
627            }],
628            cc_sources: vec![],
629            active_package: Some("develop".to_string()),
630            default_action: Some("ask".to_string()),
631            handler_count: 43,
632            simple_safe_count: 165,
633            wrapper_count: 8,
634        };
635        let json = serde_json::to_string(&output).unwrap();
636        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
637        assert_eq!(parsed["handler_count"], 43);
638    }
639
640    #[test]
641    fn trace_json_output_parses() {
642        let output = TraceOutput {
643            command: "git status".to_string(),
644            decision: "allow".to_string(),
645            reason: "git is safe".to_string(),
646            resolved: None,
647            steps: vec![TraceStep {
648                stage: "Allowlist".to_string(),
649                matched: true,
650                detail: "git is safe".to_string(),
651            }],
652        };
653        let json = serde_json::to_string(&output).unwrap();
654        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
655        assert_eq!(parsed["decision"], "allow");
656    }
657}