1use 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
17pub 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#[derive(Debug, Serialize)]
37pub(crate) struct SourceRules {
38 pub(crate) path: String,
39 pub(crate) rules: Vec<RuleDisplay>,
40}
41
42#[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#[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 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 let cc_sources = collect_cc_rules(cwd);
105
106 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 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#[derive(Debug, Serialize)]
228pub(crate) struct TraceOutput {
229 pub command: String,
230 pub decision: String,
231 pub reason: String,
232 #[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 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 return run_analyzer_for_trace(command, config, cwd, steps);
372 }
373
374 trace_handler_step(command, &cmd_name, config, cwd, steps)
375}
376
377fn 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
449fn 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#[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 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}