1use std::path::{Path, PathBuf};
2
3use rable::{Node, NodeKind};
4
5use crate::allowlists;
6use crate::ast;
7use crate::cc_permissions::{self, CcRules};
8use crate::condition::MatchContext;
9use crate::config::Config;
10use crate::environment::Environment;
11use crate::error::RippyError;
12use crate::handlers::{self, Classification, HandlerContext};
13use crate::parser::BashParser;
14use crate::resolve::{self, VarLookup};
15use crate::verdict::{Decision, Verdict};
16
17const MAX_DEPTH: usize = 256;
18
19const MAX_RESOLVED_LEN: usize = 16_384;
24
25const MAX_RESOLUTION_DEPTH: usize = 8;
30
31pub struct Analyzer {
33 pub config: Config,
34 pub parser: BashParser,
35 pub remote: bool,
36 pub working_directory: PathBuf,
37 pub verbose: bool,
38 cc_rules: CcRules,
39 git_branch: Option<String>,
41 piped: bool,
43 var_lookup: Box<dyn VarLookup>,
46 resolution_depth: usize,
49}
50
51impl Analyzer {
52 pub fn from_env(config: Config, env: Environment) -> Result<Self, RippyError> {
61 let cc_rules = cc_permissions::load_cc_rules_with_home(&env.working_directory, env.home);
62 let git_branch = crate::condition::detect_git_branch(&env.working_directory);
63 Ok(Self {
64 parser: BashParser::new()?,
65 config,
66 remote: env.remote,
67 working_directory: env.working_directory,
68 verbose: env.verbose,
69 cc_rules,
70 git_branch,
71 piped: false,
72 var_lookup: env.var_lookup,
73 resolution_depth: 0,
74 })
75 }
76
77 pub fn new(
86 config: Config,
87 remote: bool,
88 working_directory: PathBuf,
89 verbose: bool,
90 ) -> Result<Self, RippyError> {
91 let env = Environment::from_system(working_directory, remote, verbose);
92 Self::from_env(config, env)
93 }
94
95 pub fn new_with_var_lookup(
102 config: Config,
103 remote: bool,
104 working_directory: PathBuf,
105 verbose: bool,
106 var_lookup: Box<dyn VarLookup>,
107 ) -> Result<Self, RippyError> {
108 let env = Environment::from_system(working_directory, remote, verbose)
109 .with_var_lookup(var_lookup);
110 Self::from_env(config, env)
111 }
112
113 fn match_ctx(&self) -> MatchContext<'_> {
115 MatchContext {
116 branch: self.git_branch.as_deref(),
117 cwd: &self.working_directory,
118 }
119 }
120
121 pub fn analyze(&mut self, command: &str) -> Result<Verdict, RippyError> {
127 if let Some(decision) = self.cc_rules.check(command) {
128 if self.verbose {
129 eprintln!(
130 "[rippy] CC permission rule matched: {command} -> {}",
131 decision.as_str()
132 );
133 }
134 return Ok(cc_decision_to_verdict(decision, command));
135 }
136
137 if let Some(verdict) = self.config.match_command(command, Some(&self.match_ctx())) {
138 if self.verbose {
139 eprintln!(
140 "[rippy] config rule matched: {command} -> {}",
141 verdict.decision.as_str()
142 );
143 }
144 return Ok(verdict);
145 }
146
147 let nodes = self.parser.parse(command)?;
148 let cwd = self.working_directory.clone();
149 Ok(self.analyze_nodes(&nodes, &cwd, 0))
150 }
151
152 fn analyze_nodes(&mut self, nodes: &[Node], cwd: &Path, depth: usize) -> Verdict {
153 if nodes.is_empty() {
154 return Verdict::allow("");
155 }
156 let verdicts: Vec<Verdict> = nodes
157 .iter()
158 .map(|n| self.analyze_node(n, cwd, depth))
159 .collect();
160 Verdict::combine(&verdicts)
161 }
162
163 fn analyze_node(&mut self, node: &Node, cwd: &Path, depth: usize) -> Verdict {
164 if depth > MAX_DEPTH {
165 return Verdict::ask("nesting depth exceeded");
166 }
167 match &node.kind {
168 NodeKind::Command {
169 words, redirects, ..
170 } => self.analyze_command_node(words, redirects, cwd, depth),
171 NodeKind::Pipeline { commands, .. } => self.analyze_pipeline(commands, cwd, depth),
172 NodeKind::List { items } => self.analyze_list(items, cwd, depth),
173 NodeKind::If { .. }
174 | NodeKind::While { .. }
175 | NodeKind::Until { .. }
176 | NodeKind::For { .. }
177 | NodeKind::ForArith { .. }
178 | NodeKind::Select { .. }
179 | NodeKind::Case { .. }
180 | NodeKind::BraceGroup { .. } => self.analyze_control_flow(node, cwd, depth),
181 NodeKind::Subshell { body, redirects } => {
182 let mut verdicts = vec![self.analyze_node(body, cwd, depth + 1)];
183 verdicts.extend(self.analyze_redirects(redirects, cwd, depth));
184 Verdict::combine(&verdicts)
185 }
186 NodeKind::CommandSubstitution { command, .. } => {
187 let inner = self.analyze_node(command, cwd, depth + 1);
188 if ast::is_safe_heredoc_substitution(command) {
189 inner
190 } else {
191 most_restrictive(inner, Verdict::ask("command substitution"))
192 }
193 }
194 NodeKind::ProcessSubstitution { command, .. } => {
195 let inner = self.analyze_node(command, cwd, depth + 1);
196 most_restrictive(inner, Verdict::ask("command substitution"))
197 }
198 NodeKind::Function { .. } => Verdict::ask("function definition"),
199 NodeKind::Negation { pipeline } | NodeKind::Time { pipeline, .. } => {
200 self.analyze_node(pipeline, cwd, depth + 1)
201 }
202 NodeKind::HereDoc {
203 quoted, content, ..
204 } => Self::analyze_heredoc_node(*quoted, Some(content.as_str())),
205 NodeKind::Coproc { command, .. } => self.analyze_node(command, cwd, depth + 1),
206 NodeKind::ConditionalExpr { body, .. } => self.analyze_node(body, cwd, depth + 1),
207 NodeKind::ArithmeticCommand { redirects, .. } => {
208 let redirect_verdicts = self.analyze_redirects(redirects, cwd, depth);
209 Verdict::combine(&redirect_verdicts)
210 }
211 _ if ast::is_expansion_node(&node.kind) => Verdict::ask("shell expansion"),
212 _ => Verdict::allow(""),
213 }
214 }
215
216 fn analyze_control_flow(&mut self, node: &Node, cwd: &Path, depth: usize) -> Verdict {
217 match &node.kind {
218 NodeKind::If {
219 condition,
220 then_body,
221 else_body,
222 redirects,
223 } => {
224 let mut parts: Vec<&Node> = vec![condition.as_ref(), then_body.as_ref()];
225 if let Some(eb) = else_body.as_deref() {
226 parts.push(eb);
227 }
228 self.analyze_compound(&parts, redirects, cwd, depth)
229 }
230 NodeKind::While {
231 condition,
232 body,
233 redirects,
234 }
235 | NodeKind::Until {
236 condition,
237 body,
238 redirects,
239 } => self.analyze_compound(&[condition.as_ref(), body.as_ref()], redirects, cwd, depth),
240 NodeKind::For {
241 body, redirects, ..
242 }
243 | NodeKind::ForArith {
244 body, redirects, ..
245 }
246 | NodeKind::Select {
247 body, redirects, ..
248 }
249 | NodeKind::BraceGroup { body, redirects } => {
250 self.analyze_compound(&[body.as_ref()], redirects, cwd, depth)
251 }
252 NodeKind::Case {
253 patterns,
254 redirects,
255 ..
256 } => {
257 let mut verdicts: Vec<Verdict> = patterns
258 .iter()
259 .filter_map(|p| p.body.as_ref())
260 .map(|b| self.analyze_node(b, cwd, depth + 1))
261 .collect();
262 verdicts.extend(self.analyze_redirects(redirects, cwd, depth));
263 Verdict::combine(&verdicts)
264 }
265 _ => Verdict::allow(""),
266 }
267 }
268
269 fn analyze_pipeline(&mut self, commands: &[Node], cwd: &Path, depth: usize) -> Verdict {
270 let has_unsafe_redirect = commands.iter().any(ast::has_unsafe_file_redirect);
271
272 let mut verdicts: Vec<Verdict> = commands
273 .iter()
274 .enumerate()
275 .map(|(i, cmd)| self.analyze_pipeline_command(cmd, i > 0, cwd, depth + 1))
276 .collect();
277
278 if has_unsafe_redirect {
279 verdicts.push(Verdict::ask("pipeline writes to file"));
280 }
281
282 Verdict::combine(&verdicts)
283 }
284
285 fn analyze_pipeline_command(
286 &mut self,
287 node: &Node,
288 piped: bool,
289 cwd: &Path,
290 depth: usize,
291 ) -> Verdict {
292 let prev_piped = self.piped;
293 self.piped = piped;
294 let v = self.analyze_node(node, cwd, depth);
295 self.piped = prev_piped;
296 v
297 }
298
299 fn analyze_list(&mut self, items: &[rable::ListItem], cwd: &Path, depth: usize) -> Verdict {
300 let mut verdicts = Vec::new();
301 let mut current_cwd = cwd.to_owned();
302 let mut is_harmless_fallback = false;
303
304 for (i, item) in items.iter().enumerate() {
305 let v = self.analyze_node(&item.command, ¤t_cwd, depth + 1);
306
307 if let Some(dir) = extract_cd_target(&item.command) {
308 current_cwd = if Path::new(&dir).is_absolute() {
309 PathBuf::from(&dir)
310 } else {
311 current_cwd.join(&dir)
312 };
313 }
314
315 if is_harmless_fallback && v.decision == Decision::Allow {
317 is_harmless_fallback = false;
318 continue;
319 }
320 is_harmless_fallback = false;
321
322 if item.operator == Some(rable::ListOperator::Or)
323 && items
324 .get(i + 1)
325 .is_some_and(|next| ast::is_harmless_fallback(&next.command))
326 {
327 is_harmless_fallback = true;
328 }
329
330 verdicts.push(v);
331 }
332
333 Verdict::combine(&verdicts)
334 }
335
336 fn analyze_compound(
337 &mut self,
338 parts: &[&Node],
339 redirects: &[Node],
340 cwd: &Path,
341 depth: usize,
342 ) -> Verdict {
343 let mut verdicts: Vec<Verdict> = parts
344 .iter()
345 .map(|b| self.analyze_node(b, cwd, depth + 1))
346 .collect();
347 verdicts.extend(self.analyze_redirects(redirects, cwd, depth));
348 Verdict::combine(&verdicts)
349 }
350
351 fn analyze_command_node(
352 &mut self,
353 words: &[Node],
354 redirects: &[Node],
355 cwd: &Path,
356 depth: usize,
357 ) -> Verdict {
358 if let Some(resolved_verdict) = self.try_resolve(words, cwd, depth) {
363 let mut verdicts = vec![resolved_verdict];
367 verdicts.extend(self.analyze_redirects(redirects, cwd, depth));
368 return Verdict::combine(&verdicts);
369 }
370
371 let Some(raw_name) = ast::command_name_from_words(words) else {
372 return Verdict::allow("empty command");
373 };
374 let name = raw_name.to_owned();
375 let args = ast::command_args_from_words(words);
376
377 let resolved = self.config.resolve_alias(&name);
378 let cmd_name = if resolved == name {
379 name.clone()
380 } else {
381 resolved.to_owned()
382 };
383
384 if self.verbose {
385 eprintln!("[rippy] command: {cmd_name}");
386 }
387
388 if allowlists::is_wrapper(&cmd_name) {
389 if args.is_empty() {
390 return Verdict::allow(format!("{cmd_name} (no inner command)"));
391 }
392 let inner = args.join(" ");
393 return self.analyze_inner_command(&inner, cwd, depth);
394 }
395
396 if allowlists::is_simple_safe(&cmd_name) {
397 if self.verbose {
398 eprintln!("[rippy] allowlist: {cmd_name} is safe");
399 }
400 let mut v = Verdict::allow(format!("{cmd_name} is safe"));
401 for rv in self.analyze_redirects(redirects, cwd, depth) {
402 v = most_restrictive(v, rv);
403 }
404 return v;
405 }
406
407 if args
408 .iter()
409 .any(|a| a == "--help" || a == "-h" || a == "--version")
410 {
411 return Verdict::allow(format!("{cmd_name} help/version"));
412 }
413
414 let handler_verdict = self.classify_with_handler(&cmd_name, &args, cwd, depth);
415
416 let redirect_verdicts = self.analyze_redirects(redirects, cwd, depth);
417 if redirect_verdicts.is_empty() {
418 handler_verdict
419 } else {
420 let mut all = vec![handler_verdict];
421 all.extend(redirect_verdicts);
422 Verdict::combine(&all)
423 }
424 }
425
426 fn analyze_redirects(&self, redirects: &[Node], _cwd: &Path, _depth: usize) -> Vec<Verdict> {
427 let mut verdicts = Vec::new();
428 for redir in redirects {
429 match &redir.kind {
430 NodeKind::Redirect { .. } => {
431 if let Some((op, target)) = ast::redirect_info(redir) {
432 verdicts.push(self.analyze_redirect(op, &target));
433 }
434 }
435 NodeKind::HereDoc {
436 quoted, content, ..
437 } => {
438 verdicts.push(Self::analyze_heredoc_node(*quoted, Some(content.as_str())));
439 }
440 _ => {}
441 }
442 }
443 verdicts
444 }
445
446 fn classify_with_handler(
447 &mut self,
448 cmd_name: &str,
449 args: &[String],
450 cwd: &Path,
451 depth: usize,
452 ) -> Verdict {
453 if let Some(handler) = handlers::get_handler(cmd_name) {
454 let ctx = HandlerContext {
455 command_name: cmd_name,
456 args,
457 working_directory: cwd,
458 remote: self.remote,
459 receives_piped_input: self.piped,
460 cd_allowed_dirs: &self.config.cd_allowed_dirs,
461 };
462 let classification = handler.classify(&ctx);
463 if self.verbose {
464 eprintln!("[rippy] handler: {cmd_name} -> {classification:?}");
465 }
466 return self.apply_classification(classification, cwd, depth);
467 }
468
469 if self.verbose {
470 eprintln!("[rippy] no handler for: {cmd_name}");
471 }
472 self.default_verdict(cmd_name)
473 }
474
475 fn analyze_redirect(&self, op: ast::RedirectOp, target: &str) -> Verdict {
476 if op == ast::RedirectOp::Read {
477 return Verdict::allow("input redirect");
478 }
479 if ast::is_safe_redirect_target(target) {
480 return Verdict::allow(format!("redirect to {target}"));
481 }
482 if op == ast::RedirectOp::FdDup {
483 return Verdict::allow("fd redirect");
484 }
485 if self.config.self_protect && crate::self_protect::is_protected_path(target) {
486 return Verdict::deny(crate::self_protect::PROTECTION_MESSAGE);
487 }
488 if let Some(verdict) = self.config.match_redirect(target, Some(&self.match_ctx())) {
489 return verdict;
490 }
491 Verdict::ask(format!("redirect to {target}"))
492 }
493
494 fn analyze_heredoc_node(quoted: bool, content: Option<&str>) -> Verdict {
495 if quoted {
496 return Verdict::allow("heredoc");
497 }
498 if let Some(body) = content
499 && ast::has_shell_expansion_pattern(body)
500 {
501 return Verdict::ask("heredoc with expansion");
502 }
503 Verdict::allow("heredoc")
504 }
505
506 fn analyze_inner_command(&mut self, inner: &str, cwd: &Path, depth: usize) -> Verdict {
507 let Ok(nodes) = self.parser.parse(inner) else {
508 return Verdict::ask("unparseable inner command");
509 };
510 self.analyze_nodes(&nodes, cwd, depth)
511 }
512
513 fn try_resolve(&mut self, words: &[Node], cwd: &Path, depth: usize) -> Option<Verdict> {
525 if !ast::has_expansions_in_slices(words, &[]) {
526 return None;
527 }
528 if self.resolution_depth >= MAX_RESOLUTION_DEPTH {
532 return Some(Verdict::ask("shell expansion (resolution depth exceeded)"));
533 }
534 let resolved = resolve::resolve_command_args(words, self.var_lookup.as_ref());
535 let Some(args) = resolved.args else {
536 let reason = resolved.failure_reason.map_or_else(
537 || "shell expansion".to_string(),
538 |r| format!("shell expansion ({r})"),
539 );
540 return Some(Verdict::ask(reason));
541 };
542 let resolved_command = resolve::shell_join(&args);
543 if resolved_command.len() > MAX_RESOLVED_LEN {
545 return Some(Verdict::ask(format!(
546 "shell expansion (resolved command exceeds {MAX_RESOLVED_LEN}-byte limit)"
547 )));
548 }
549 if self.verbose {
550 eprintln!("[rippy] resolved: {resolved_command}");
551 }
552 if resolved.command_position_dynamic {
553 return Some(
554 Verdict::ask(format!("dynamic command (resolved: {resolved_command})"))
555 .with_resolution(resolved_command),
556 );
557 }
558 self.resolution_depth += 1;
560 let inner = self.analyze_inner_command(&resolved_command, cwd, depth + 1);
561 self.resolution_depth -= 1;
562 Some(annotate_with_resolution(inner, &resolved_command))
563 }
564
565 fn apply_classification(&mut self, class: Classification, cwd: &Path, depth: usize) -> Verdict {
566 match class {
567 Classification::Allow(desc) => Verdict::allow(desc),
568 Classification::Ask(desc) => Verdict::ask(desc),
569 Classification::Deny(desc) => Verdict::deny(desc),
570 Classification::Recurse(inner) => {
571 if self.verbose {
572 eprintln!("[rippy] recurse: {inner}");
573 }
574 self.analyze_inner_command(&inner, cwd, depth)
575 }
576 Classification::RecurseRemote(inner) => {
577 if self.verbose {
578 eprintln!("[rippy] recurse (remote): {inner}");
579 }
580 let prev_remote = self.remote;
581 self.remote = true;
582 let v = self.analyze_inner_command(&inner, cwd, depth);
583 self.remote = prev_remote;
584 v
585 }
586 Classification::WithRedirects(decision, desc, targets) => {
587 let mut verdicts = vec![Verdict {
588 decision,
589 reason: desc,
590 resolved_command: None,
591 }];
592 for target in &targets {
593 verdicts.push(self.analyze_redirect(ast::RedirectOp::Write, target));
594 }
595 Verdict::combine(&verdicts)
596 }
597 }
598 }
599
600 fn default_verdict(&self, cmd_name: &str) -> Verdict {
601 self.config.default_action.map_or_else(
602 || Verdict::ask(format!("{cmd_name} (unknown command)")),
603 |action| {
604 let mut reason = format!("{cmd_name} (default action)");
605 if action == Decision::Allow {
606 reason.push_str(self.config.weakening_suffix());
607 }
608 Verdict {
609 decision: action,
610 reason,
611 resolved_command: None,
612 }
613 },
614 )
615 }
616}
617
618fn cc_decision_to_verdict(decision: Decision, command: &str) -> Verdict {
619 let reason = match decision {
620 Decision::Allow => format!("{command} (CC permission: allow)"),
621 Decision::Ask => format!("{command} (CC permission: ask)"),
622 Decision::Deny => format!("{command} (CC permission: deny)"),
623 };
624 Verdict {
625 decision,
626 reason,
627 resolved_command: None,
628 }
629}
630
631fn annotate_with_resolution(mut v: Verdict, resolved: &str) -> Verdict {
634 if !v.reason.contains("(resolved:") {
635 v.reason = if v.reason.is_empty() {
636 format!("(resolved: {resolved})")
637 } else {
638 format!("{} (resolved: {resolved})", v.reason)
639 };
640 }
641 v.resolved_command = Some(resolved.to_string());
642 v
643}
644
645fn extract_cd_target(node: &Node) -> Option<String> {
646 let name = ast::command_name(node)?;
647 if name != "cd" {
648 return None;
649 }
650 let args = ast::command_args(node);
651 args.first().cloned()
652}
653
654fn most_restrictive(a: Verdict, b: Verdict) -> Verdict {
655 if a.decision >= b.decision { a } else { b }
656}
657
658#[cfg(test)]
659#[allow(clippy::unwrap_used, clippy::literal_string_with_formatting_args)]
660mod tests {
661 use super::*;
662 use crate::resolve::tests::MockLookup;
663 use crate::verdict::Decision;
664
665 fn make_analyzer() -> Analyzer {
666 make_analyzer_with(MockLookup::new())
669 }
670
671 fn make_analyzer_with(lookup: MockLookup) -> Analyzer {
672 Analyzer::new_with_var_lookup(
673 Config::empty(),
674 false,
675 PathBuf::from("/tmp"),
676 false,
677 Box::new(lookup),
678 )
679 .unwrap()
680 }
681
682 #[test]
683 fn simple_safe_command() {
684 let mut a = make_analyzer();
685 let v = a.analyze("ls -la").unwrap();
686 assert_eq!(v.decision, Decision::Allow);
687 }
688
689 #[test]
690 fn git_status_safe() {
691 let mut a = make_analyzer();
692 let v = a.analyze("git status").unwrap();
693 assert_eq!(v.decision, Decision::Allow);
694 }
695
696 #[test]
697 fn git_push_asks() {
698 let mut a = make_analyzer();
699 let v = a.analyze("git push").unwrap();
700 assert_eq!(v.decision, Decision::Ask);
701 }
702
703 #[test]
704 fn rm_rf_asks() {
705 let mut a = make_analyzer();
706 let v = a.analyze("rm -rf /").unwrap();
707 assert_eq!(v.decision, Decision::Ask);
708 }
709
710 #[test]
711 fn pipeline_safe() {
712 let mut a = make_analyzer();
713 let v = a.analyze("cat file.txt | grep pattern").unwrap();
714 assert_eq!(v.decision, Decision::Allow);
715 }
716
717 #[test]
718 fn pipeline_mixed() {
719 let mut a = make_analyzer();
720 let v = a.analyze("cat file.txt | rm -rf /tmp").unwrap();
721 assert_eq!(v.decision, Decision::Ask);
722 }
723
724 #[test]
725 fn redirect_to_dev_null() {
726 let mut a = make_analyzer();
727 let v = a.analyze("echo foo > /dev/null").unwrap();
728 assert_eq!(v.decision, Decision::Allow);
729 }
730
731 #[test]
732 fn redirect_to_file_asks() {
733 let mut a = make_analyzer();
734 let v = a.analyze("echo foo > output.txt").unwrap();
735 assert_eq!(v.decision, Decision::Ask);
736 }
737
738 #[test]
739 fn wrapper_command_analyzes_inner() {
740 let mut a = make_analyzer();
741 let v = a.analyze("time git status").unwrap();
742 assert_eq!(v.decision, Decision::Allow);
743 }
744
745 #[test]
746 fn wrapper_command_unsafe_inner() {
747 let mut a = make_analyzer();
748 let v = a.analyze("time git push").unwrap();
749 assert_eq!(v.decision, Decision::Ask);
750 }
751
752 #[test]
753 fn command_substitution_asks() {
754 let mut a = make_analyzer();
755 let v = a.analyze("echo $(rm -rf /)").unwrap();
756 assert_eq!(v.decision, Decision::Ask);
757 }
758
759 #[test]
760 fn shell_c_recurses() {
761 let mut a = make_analyzer();
762 let v = a.analyze("bash -c 'git status'").unwrap();
763 assert_eq!(v.decision, Decision::Allow);
764 }
765
766 #[test]
767 fn shell_c_unsafe() {
768 let mut a = make_analyzer();
769 let v = a.analyze("bash -c 'rm -rf /'").unwrap();
770 assert_eq!(v.decision, Decision::Ask);
771 }
772
773 #[test]
774 fn config_override_allows() {
775 use crate::config::{ConfigDirective, Rule, RuleTarget};
776
777 let config = Config::from_directives(vec![ConfigDirective::Rule(
778 Rule::new(RuleTarget::Command, Decision::Allow, "rm -rf /tmp")
779 .with_message("cleanup allowed"),
780 )]);
781 let mut a = Analyzer::new(config, false, PathBuf::from("/tmp"), false).unwrap();
782 let v = a.analyze("rm -rf /tmp").unwrap();
783 assert_eq!(v.decision, Decision::Allow);
784 }
785
786 #[test]
787 fn help_flag_always_safe() {
788 let mut a = make_analyzer();
789 let v = a.analyze("npm --help").unwrap();
790 assert_eq!(v.decision, Decision::Allow);
791 }
792
793 #[test]
794 fn list_and() {
795 let mut a = make_analyzer();
796 let v = a.analyze("ls && echo done").unwrap();
797 assert_eq!(v.decision, Decision::Allow);
798 }
799
800 #[test]
801 fn unknown_command_asks() {
802 let mut a = make_analyzer();
803 let v = a.analyze("some_unknown_tool --flag").unwrap();
804 assert_eq!(v.decision, Decision::Ask);
805 }
806
807 #[test]
808 fn depth_limit_exceeded() {
809 let mut a = make_analyzer();
810 let nodes = a.parser.parse("echo ok").unwrap();
811 let v = a.analyze_node(&nodes[0], Path::new("/tmp"), MAX_DEPTH + 1);
812 assert_eq!(v.decision, Decision::Ask);
813 assert!(v.reason.contains("nesting depth exceeded"));
814 }
815
816 #[test]
817 fn depth_at_max_still_works() {
818 let mut a = make_analyzer();
819 let nodes = a.parser.parse("echo ok").unwrap();
820 let v = a.analyze_node(&nodes[0], Path::new("/tmp"), MAX_DEPTH - 2);
821 assert_eq!(v.decision, Decision::Allow);
822 }
823
824 #[test]
825 fn subshell_safe_allows() {
826 let mut a = make_analyzer();
827 let v = a.analyze("(echo ok)").unwrap();
828 assert_eq!(v.decision, Decision::Allow); }
830
831 #[test]
832 fn heredoc_safe_allows() {
833 let mut a = make_analyzer();
834 let v = a.analyze("cat <<EOF\nhello world\nEOF").unwrap();
835 assert_eq!(v.decision, Decision::Allow);
836 }
837
838 #[test]
839 fn heredoc_quoted_delimiter_allows_even_with_expansion_syntax() {
840 let mut a = make_analyzer();
841 let v = a.analyze("cat <<'EOF'\n$(rm -rf /)\nEOF").unwrap();
842 assert_eq!(v.decision, Decision::Allow);
843 }
844
845 #[test]
846 fn nested_substitution_asks() {
847 let mut a = make_analyzer();
848 let v = a.analyze("echo $(echo $(whoami))").unwrap();
849 assert_eq!(v.decision, Decision::Ask);
850 }
851
852 #[test]
853 fn complex_pipeline_all_safe() {
854 let mut a = make_analyzer();
855 let v = a.analyze("cat file | grep pattern | head -5").unwrap();
856 assert_eq!(v.decision, Decision::Allow);
857 }
858
859 #[test]
860 fn if_statement_safe() {
861 let mut a = make_analyzer();
862 let v = a.analyze("if true; then echo yes; fi").unwrap();
863 assert_eq!(v.decision, Decision::Allow);
864 }
865
866 #[test]
867 fn if_statement_unsafe_body() {
868 let mut a = make_analyzer();
869 let v = a.analyze("if true; then rm -rf /; fi").unwrap();
870 assert_eq!(v.decision, Decision::Ask);
871 }
872
873 #[test]
874 fn for_loop_unsafe() {
875 let mut a = make_analyzer();
876 let v = a.analyze("for i in 1 2 3; do rm -rf /; done").unwrap();
877 assert_eq!(v.decision, Decision::Ask);
878 }
879
880 #[test]
881 fn empty_command_allows() {
882 let mut a = make_analyzer();
883 let v = a.analyze("").unwrap();
884 assert_eq!(v.decision, Decision::Allow);
885 }
886
887 #[test]
888 fn case_statement() {
889 let mut a = make_analyzer();
890 let v = a.analyze("case x in a) echo yes;; esac").unwrap();
891 assert_eq!(v.decision, Decision::Allow);
892 }
893
894 #[test]
895 fn cc_allow_rule_overrides_handler() {
896 let dir = tempfile::tempdir().unwrap();
897 let claude_dir = dir.path().join(".claude");
898 std::fs::create_dir(&claude_dir).unwrap();
899 std::fs::write(
900 claude_dir.join("settings.local.json"),
901 r#"{"permissions": {"allow": ["Bash(git push)"]}}"#,
902 )
903 .unwrap();
904 let mut a = Analyzer::new(Config::empty(), false, dir.path().to_path_buf(), false).unwrap();
905 let v = a.analyze("git push origin main").unwrap();
906 assert_eq!(v.decision, Decision::Allow);
907 }
908
909 #[test]
910 fn cc_deny_rule_overrides_handler() {
911 let dir = tempfile::tempdir().unwrap();
912 let claude_dir = dir.path().join(".claude");
913 std::fs::create_dir(&claude_dir).unwrap();
914 std::fs::write(
915 claude_dir.join("settings.json"),
916 r#"{"permissions": {"deny": ["Bash(ls)"]}}"#,
917 )
918 .unwrap();
919 let mut a = Analyzer::new(Config::empty(), false, dir.path().to_path_buf(), false).unwrap();
920 let v = a.analyze("ls").unwrap();
921 assert_eq!(v.decision, Decision::Deny);
922 }
923
924 #[test]
925 fn cc_rules_checked_before_rippy_config() {
926 use crate::config::{ConfigDirective, Rule, RuleTarget};
927
928 let dir = tempfile::tempdir().unwrap();
929 let claude_dir = dir.path().join(".claude");
930 std::fs::create_dir(&claude_dir).unwrap();
931 std::fs::write(
932 claude_dir.join("settings.local.json"),
933 r#"{"permissions": {"allow": ["Bash(rm -rf /tmp)"]}}"#,
934 )
935 .unwrap();
936
937 let config = Config::from_directives(vec![ConfigDirective::Rule(
938 Rule::new(RuleTarget::Command, Decision::Ask, "rm -rf /tmp").with_message("dangerous"),
939 )]);
940 let mut a = Analyzer::new(config, false, dir.path().to_path_buf(), false).unwrap();
941 let v = a.analyze("rm -rf /tmp").unwrap();
942 assert_eq!(v.decision, Decision::Allow);
943 }
944
945 #[test]
946 fn pipeline_with_file_redirect_asks() {
947 let mut a = make_analyzer();
948 let v = a.analyze("cat file | grep pattern > out.txt").unwrap();
949 assert_eq!(v.decision, Decision::Ask);
950 }
951
952 #[test]
953 fn pipeline_with_dev_null_allows() {
954 let mut a = make_analyzer();
955 let v = a.analyze("ls | grep foo > /dev/null").unwrap();
956 assert_eq!(v.decision, Decision::Allow);
957 }
958
959 #[test]
960 fn pipeline_mid_redirect_asks() {
961 let mut a = make_analyzer();
962 let v = a.analyze("echo hello > file.txt | cat").unwrap();
963 assert_eq!(v.decision, Decision::Ask);
964 }
965
966 #[test]
967 fn subshell_unsafe_propagates() {
968 let mut a = make_analyzer();
969 let v = a.analyze("(rm -rf /)").unwrap();
970 assert_eq!(v.decision, Decision::Ask);
971 }
972
973 #[test]
974 fn subshell_with_redirect_asks() {
975 let mut a = make_analyzer();
976 let v = a.analyze("(echo ok) > file.txt").unwrap();
977 assert_eq!(v.decision, Decision::Ask);
978 }
979
980 #[test]
981 fn or_true_uses_cmd_verdict() {
982 let mut a = make_analyzer();
983 let v = a.analyze("git push || true").unwrap();
984 assert_eq!(v.decision, Decision::Ask);
985 }
986
987 #[test]
988 fn safe_cmd_or_true_allows() {
989 let mut a = make_analyzer();
990 let v = a.analyze("ls || true").unwrap();
991 assert_eq!(v.decision, Decision::Allow);
992 }
993
994 #[test]
995 fn or_colon_uses_cmd_verdict() {
996 let mut a = make_analyzer();
997 let v = a.analyze("ls || :").unwrap();
998 assert_eq!(v.decision, Decision::Allow);
999 }
1000
1001 #[test]
1002 fn or_with_unsafe_fallback_combines() {
1003 let mut a = make_analyzer();
1004 let v = a.analyze("ls || rm -rf /").unwrap();
1005 assert_eq!(v.decision, Decision::Ask);
1006 }
1007
1008 #[test]
1009 fn and_combines_normally() {
1010 let mut a = make_analyzer();
1011 let v = a.analyze("ls && git push").unwrap();
1012 assert_eq!(v.decision, Decision::Ask);
1013 }
1014
1015 #[test]
1016 fn command_substitution_floor_is_ask() {
1017 let mut a = make_analyzer();
1018 let v = a.analyze("echo $(ls)").unwrap();
1019 assert_eq!(v.decision, Decision::Ask);
1021 }
1022
1023 #[test]
1024 fn or_harmless_fallback_with_redirect_asks() {
1025 let mut a = make_analyzer();
1026 let v = a.analyze("ls || echo fail > log.txt").unwrap();
1027 assert_eq!(v.decision, Decision::Ask);
1028 }
1029
1030 #[test]
1033 fn param_expansion_in_safe_command_resolves_to_value() {
1034 let mut a = make_analyzer_with(MockLookup::new().with("HOME", "/Users/test"));
1035 let v = a.analyze("echo ${HOME}").unwrap();
1036 assert_eq!(v.decision, Decision::Allow);
1037 assert_eq!(v.resolved_command.as_deref(), Some("echo /Users/test"));
1038 assert!(v.reason.contains("(resolved: echo /Users/test)"));
1039 }
1040
1041 #[test]
1042 fn simple_var_in_safe_command_resolves_to_value() {
1043 let mut a = make_analyzer_with(MockLookup::new().with("HOME", "/Users/test"));
1044 let v = a.analyze("echo $HOME").unwrap();
1045 assert_eq!(v.decision, Decision::Allow);
1046 assert_eq!(v.resolved_command.as_deref(), Some("echo /Users/test"));
1047 }
1048
1049 #[test]
1050 fn ansi_c_in_safe_command_resolves_to_literal() {
1051 let mut a = make_analyzer();
1052 let v = a.analyze("echo $'\\x41'").unwrap();
1053 assert_eq!(v.decision, Decision::Allow);
1054 assert_eq!(v.resolved_command.as_deref(), Some("echo A"));
1055 }
1056
1057 #[test]
1058 fn locale_string_in_safe_command_resolves_to_literal() {
1059 let mut a = make_analyzer();
1060 let v = a.analyze("echo $\"hello\"").unwrap();
1061 assert_eq!(v.decision, Decision::Allow);
1062 assert_eq!(v.resolved_command.as_deref(), Some("echo hello"));
1063 }
1064
1065 #[test]
1066 fn arithmetic_expansion_in_safe_command_resolves_to_literal() {
1067 let mut a = make_analyzer();
1068 let v = a.analyze("echo $((1+1))").unwrap();
1069 assert_eq!(v.decision, Decision::Allow);
1070 assert_eq!(v.resolved_command.as_deref(), Some("echo 2"));
1071 }
1072
1073 #[test]
1074 fn brace_expansion_in_safe_command_resolves_to_literal() {
1075 let mut a = make_analyzer();
1076 let v = a.analyze("echo {a,b,c}").unwrap();
1077 assert_eq!(v.decision, Decision::Allow);
1078 assert_eq!(v.resolved_command.as_deref(), Some("echo a b c"));
1079 }
1080
1081 #[test]
1084 fn heredoc_with_param_expansion_asks() {
1085 let mut a = make_analyzer();
1086 let v = a.analyze("cat <<EOF\n${HOME}\nEOF").unwrap();
1087 assert_eq!(v.decision, Decision::Ask);
1088 }
1089
1090 #[test]
1091 fn heredoc_quoted_with_param_expansion_allows() {
1092 let mut a = make_analyzer();
1093 let v = a.analyze("cat <<'EOF'\n${HOME}\nEOF").unwrap();
1094 assert_eq!(v.decision, Decision::Allow);
1095 }
1096
1097 #[test]
1098 fn heredoc_bare_var_asks() {
1099 let mut a = make_analyzer();
1100 let v = a.analyze("cat <<EOF\n$HOME\nEOF").unwrap();
1101 assert_eq!(v.decision, Decision::Ask);
1102 }
1103
1104 #[test]
1105 fn safe_command_without_expansion_allows() {
1106 let mut a = make_analyzer();
1107 let v = a.analyze("echo hello").unwrap();
1108 assert_eq!(v.decision, Decision::Allow);
1109 assert!(v.resolved_command.is_none());
1110 }
1111
1112 #[test]
1115 fn param_length_in_safe_command_asks() {
1116 let mut a = make_analyzer();
1117 let v = a.analyze("echo ${#var}").unwrap();
1118 assert_eq!(v.decision, Decision::Ask);
1119 }
1120
1121 #[test]
1122 fn param_indirect_in_safe_command_asks() {
1123 let mut a = make_analyzer();
1124 let v = a.analyze("echo ${!ref}").unwrap();
1125 assert_eq!(v.decision, Decision::Ask);
1126 }
1127
1128 #[test]
1129 fn unset_var_asks_with_diagnostic_reason() {
1130 let mut a = make_analyzer();
1131 let v = a.analyze("echo $UNSET").unwrap();
1132 assert_eq!(v.decision, Decision::Ask);
1133 assert!(
1134 v.reason.contains("$UNSET is not set"),
1135 "expected diagnostic about unset var, got: {}",
1136 v.reason
1137 );
1138 }
1139
1140 #[test]
1141 fn command_substitution_still_asks() {
1142 let mut a = make_analyzer();
1144 let v = a.analyze("echo $(whoami)").unwrap();
1145 assert_eq!(v.decision, Decision::Ask);
1146 }
1147
1148 #[test]
1149 fn arithmetic_division_by_zero_asks() {
1150 let mut a = make_analyzer();
1151 let v = a.analyze("echo $((1/0))").unwrap();
1152 assert_eq!(v.decision, Decision::Ask);
1153 }
1154
1155 #[test]
1158 fn rm_with_resolved_arg_still_asks_via_handler() {
1159 let mut a = make_analyzer_with(MockLookup::new().with("TARGET", "/tmp/file"));
1160 let v = a.analyze("rm $TARGET").unwrap();
1161 assert_eq!(v.decision, Decision::Ask);
1163 assert_eq!(v.resolved_command.as_deref(), Some("rm /tmp/file"));
1165 }
1166
1167 #[test]
1170 fn dynamic_command_position_asks_even_when_resolved() {
1171 let mut a = make_analyzer_with(MockLookup::new().with("cmd", "ls"));
1174 let v = a.analyze("$cmd args").unwrap();
1175 assert_eq!(v.decision, Decision::Ask);
1176 assert!(
1177 v.reason.contains("dynamic command"),
1178 "expected dynamic-command reason, got: {}",
1179 v.reason
1180 );
1181 assert_eq!(v.resolved_command.as_deref(), Some("ls args"));
1182 }
1183
1184 #[test]
1187 fn handler_path_resolves_quoted_subcommand() {
1188 let mut a = make_analyzer();
1191 let v = a.analyze("git $'status'").unwrap();
1192 assert_eq!(v.decision, Decision::Allow);
1193 assert_eq!(v.resolved_command.as_deref(), Some("git status"));
1194 }
1195
1196 #[test]
1199 fn param_default_resolves_when_unset() {
1200 let mut a = make_analyzer();
1201 let v = a.analyze("echo ${UNSET:-default}").unwrap();
1202 assert_eq!(v.decision, Decision::Allow);
1203 assert_eq!(v.resolved_command.as_deref(), Some("echo default"));
1204 }
1205
1206 #[test]
1209 fn var_value_with_command_substitution_stays_literal() {
1210 let mut a = make_analyzer_with(MockLookup::new().with("CMD_STR", "$(whoami)"));
1215 let v = a.analyze("echo $CMD_STR").unwrap();
1216 assert_eq!(
1217 v.decision,
1218 Decision::Allow,
1219 "echo with literal-looking command sub should allow, got: {v:?}"
1220 );
1221 assert_eq!(v.resolved_command.as_deref(), Some("echo '$(whoami)'"));
1223 }
1224
1225 #[test]
1226 fn var_value_with_dangerous_command_string_still_safe_for_echo() {
1227 let mut a = make_analyzer_with(MockLookup::new().with("CMD_STR", "rm -rf /"));
1231 let v = a.analyze("echo $CMD_STR").unwrap();
1232 assert_eq!(v.decision, Decision::Allow);
1233 assert_eq!(v.resolved_command.as_deref(), Some("echo 'rm -rf /'"));
1236 }
1237
1238 #[test]
1239 fn var_value_with_backticks_stays_literal() {
1240 let mut a = make_analyzer_with(MockLookup::new().with("X", "`whoami`"));
1243 let v = a.analyze("echo $X").unwrap();
1244 assert_eq!(v.decision, Decision::Allow);
1245 assert_eq!(v.resolved_command.as_deref(), Some("echo '`whoami`'"));
1246 }
1247
1248 #[test]
1251 fn huge_brace_expansion_falls_back_to_ask() {
1252 let mut a = make_analyzer();
1255 let v = a.analyze("echo {1..100000}").unwrap();
1256 assert_eq!(v.decision, Decision::Ask);
1257 }
1258
1259 #[test]
1260 fn cartesian_brace_explosion_falls_back_to_ask() {
1261 let mut a = make_analyzer();
1263 let v = a.analyze("echo {1..32}{1..32}{1..32}").unwrap();
1264 assert_eq!(v.decision, Decision::Ask);
1265 }
1266
1267 #[test]
1268 fn safe_heredoc_in_command_substitution_allows() {
1269 let mut a = make_analyzer();
1270 let v = a
1273 .analyze("echo \"$(cat <<'EOF'\nhello world\nEOF\n)\"")
1274 .unwrap();
1275 assert_eq!(v.decision, Decision::Allow);
1276 }
1277
1278 #[test]
1279 fn unquoted_heredoc_in_command_substitution_asks() {
1280 let mut a = make_analyzer();
1281 let v = a
1282 .analyze("echo \"$(cat <<EOF\n$(rm -rf /)\nEOF\n)\"")
1283 .unwrap();
1284 assert_eq!(v.decision, Decision::Ask);
1285 }
1286
1287 #[test]
1288 fn unsafe_command_heredoc_in_substitution_asks() {
1289 let mut a = make_analyzer();
1290 let v = a
1291 .analyze("echo \"$(bash <<'EOF'\nrm -rf /\nEOF\n)\"")
1292 .unwrap();
1293 assert_eq!(v.decision, Decision::Ask);
1294 }
1295
1296 #[test]
1297 fn pipeline_in_heredoc_substitution_asks() {
1298 let mut a = make_analyzer();
1299 let v = a
1300 .analyze("echo \"$(cat <<'EOF' | bash\nhello\nEOF\n)\"")
1301 .unwrap();
1302 assert_eq!(v.decision, Decision::Ask);
1303 }
1304
1305 #[test]
1306 fn heredoc_substitution_in_git_commit_resolves() {
1307 let mut a = make_analyzer();
1310 let v = a
1311 .analyze("git commit -m \"$(cat <<'EOF'\nmy commit message\nEOF\n)\"")
1312 .unwrap();
1313 assert_eq!(v.decision, Decision::Ask);
1314 assert!(
1315 v.resolved_command.is_some(),
1316 "heredoc substitution should resolve to a concrete command"
1317 );
1318 }
1319
1320 #[test]
1321 fn command_sub_without_heredoc_still_asks() {
1322 let mut a = make_analyzer();
1323 let v = a.analyze("echo $(ls)").unwrap();
1324 assert_eq!(v.decision, Decision::Ask);
1325 }
1326
1327 #[test]
1328 fn variable_value_containing_dollar_is_not_re_expanded() {
1329 let mut a = make_analyzer_with(MockLookup::new().with("A", "$B").with("B", "actual"));
1335 let v = a.analyze("echo $A").unwrap();
1336 assert_eq!(v.decision, Decision::Allow);
1337 assert_eq!(v.resolved_command.as_deref(), Some("echo '$B'"));
1339 }
1340}