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