Skip to main content

rippy_cli/
analyzer.rs

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
19/// Maximum number of AST nodes walked per command. Bounds tree *breadth*
20/// where `MAX_DEPTH` only bounds tree height: a pathological input that
21/// produces thousands of sibling nodes at shallow depth would otherwise
22/// slip through. Analysis that hits the cap returns Ask.
23const MAX_NODES: usize = 10_000;
24
25/// Maximum length (bytes) of a resolved command string. Resolution that
26/// would produce a longer string falls back to Ask, preventing pathological
27/// expansions (e.g., variables that contain other expansions, deeply
28/// recursive aliases) from blowing up memory.
29const MAX_RESOLVED_LEN: usize = 16_384;
30
31/// Maximum number of nested resolution passes. Each call to `try_resolve`
32/// re-parses the resolved command and may resolve again; this cap is
33/// independent of `MAX_DEPTH` (which bounds AST node nesting) and prevents
34/// `A=$B; B=$C; C=$A` cycles from blowing the stack.
35const MAX_RESOLUTION_DEPTH: usize = 8;
36
37/// The core analysis engine: parses a command and produces a safety verdict.
38pub 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    /// Cached current git branch name.
46    git_branch: Option<String>,
47    /// Set to true when analyzing a command that receives piped input.
48    piped: bool,
49    /// Variable lookup used for static expansion resolution.
50    /// Defaults to `EnvLookup` (real process environment); tests inject mocks.
51    var_lookup: Box<dyn VarLookup>,
52    /// Tracks how many nested expansion-resolution passes have run for the
53    /// current command. Bounded by `MAX_RESOLUTION_DEPTH` to prevent cycles.
54    resolution_depth: usize,
55    /// Remaining AST-node budget for the current `analyze` call. Reset to
56    /// `MAX_NODES` at the top of every public `analyze` call and decremented
57    /// once per `analyze_node` entry. Returns Ask when exhausted.
58    node_budget: usize,
59}
60
61impl Analyzer {
62    /// Create a new analyzer from an [`Environment`] struct.
63    ///
64    /// This is the preferred constructor — it takes all external dependencies
65    /// as an explicit struct, making tests deterministic without env-var hacks.
66    ///
67    /// # Errors
68    ///
69    /// Returns `RippyError::Parse` if the bash parser cannot be initialized.
70    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    /// Create a new analyzer using the real process environment for variable lookups.
89    ///
90    /// Convenience wrapper around [`Analyzer::from_env`] that reads `$HOME`
91    /// and process env vars automatically.
92    ///
93    /// # Errors
94    ///
95    /// Returns `RippyError::Parse` if the bash parser cannot be initialized.
96    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    /// Create a new analyzer with a custom variable lookup (used by tests
107    /// to inject deterministic env values via `MockLookup`).
108    ///
109    /// # Errors
110    ///
111    /// Returns `RippyError::Parse` if the bash parser cannot be initialized.
112    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    /// Build a `MatchContext` for condition evaluation.
125    fn match_ctx(&self) -> MatchContext<'_> {
126        MatchContext {
127            branch: self.git_branch.as_deref(),
128            cwd: &self.working_directory,
129        }
130    }
131
132    /// Analyze a shell command string and return a safety verdict.
133    ///
134    /// # Errors
135    ///
136    /// Returns `RippyError::Parse` if the command cannot be parsed.
137    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, &current_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            // In `|| true` patterns, only include the fallback if it's non-trivial
332            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        // Static expansion resolution: if any words contain expansions, attempt
375        // to resolve them and re-classify the resolved command through the full
376        // pipeline. This applies uniformly to safe-list, wrapper, and handler
377        // paths — the resolved command goes back through analyze_inner_command.
378        if let Some(resolved_verdict) = self.try_resolve(words, cwd, depth) {
379            // Use Verdict::combine (not most_restrictive) so the resolved_command
380            // field is preserved even when a redirect verdict dominates the
381            // decision — combine borrows resolved_command from any input verdict.
382            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    /// Attempt to statically resolve any shell expansions in `words` and
530    /// re-classify the resolved command through the full pipeline.
531    ///
532    /// Returns:
533    /// - `None` when there are no expansions to resolve (caller proceeds normally)
534    /// - `Some(verdict)` when expansions were present:
535    ///   - On unresolvable expansions, an `Ask` verdict with a diagnostic reason
536    ///   - On command-position dynamic execution (`$cmd args`), an `Ask` verdict
537    ///     regardless of whether resolution succeeded
538    ///   - Otherwise, the verdict of re-analyzing the resolved command
539    ///     (annotated with the resolved form for transparency)
540    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        // Bail out on runaway resolution before doing any work. Each nested
545        // call increments `resolution_depth`; cycles like `A=$B; B=$A` are
546        // caught here even if individual depths are small.
547        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        // Refuse to materialize pathologically large resolved commands.
560        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        // Track nesting around the recursive analyze_inner_command call.
575        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
647/// Annotate a verdict with the resolved command form: appends `(resolved: <cmd>)`
648/// to the reason (idempotent) and stores the resolved command in `resolved_command`.
649fn 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        // Use an empty MockLookup so default tests are deterministic regardless
683        // of the host environment.
684        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); // subshell is transparent
845    }
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        // Even though ls is safe, command substitution has an Ask floor
1036        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    // ---- Expansion resolution tests ----
1047
1048    #[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    // ---- Heredoc tests (resolution NOT in scope for heredocs in this PR) ----
1098
1099    #[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    // ---- Tests for unresolvable expansions (still Ask) ----
1129
1130    #[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        // Command substitution can never be resolved statically.
1159        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    // ---- Resolution that triggers handler-side Ask ----
1172
1173    #[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        // rm always asks via the handler, regardless of arg
1178        assert_eq!(v.decision, Decision::Ask);
1179        // But the verdict carries the resolved form
1180        assert_eq!(v.resolved_command.as_deref(), Some("rm /tmp/file"));
1181    }
1182
1183    // ---- Command-position protection ----
1184
1185    #[test]
1186    fn dynamic_command_position_asks_even_when_resolved() {
1187        // `$cmd args` with cmd=ls would normally allow ls, but command-position
1188        // dynamic execution is always Ask regardless of resolution.
1189        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    // ---- Handler-path resolution ----
1201
1202    #[test]
1203    fn handler_path_resolves_quoted_subcommand() {
1204        // `git $'status'` should resolve to `git status` and let the git handler
1205        // classify it normally (status is safe).
1206        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    // ---- Default with literal ----
1213
1214    #[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    // ---- Safety: variable values containing shell metacharacters ----
1223
1224    #[test]
1225    fn var_value_with_command_substitution_stays_literal() {
1226        // If a variable's value LOOKS like command substitution (`$(whoami)`),
1227        // shell_join_arg must single-quote it so the re-parsed command sees a
1228        // literal string, not an expansion. echo is safe regardless of arg
1229        // content, so this should Allow with the value treated as data.
1230        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        // The resolved form quotes the value to keep it literal.
1238        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        // The killer test for the "content drives the verdict" claim:
1244        // a variable holding what LOOKS like `rm -rf /` is just a string when
1245        // passed to echo. echo is safe; the value is data, not execution.
1246        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        // The dangerous-looking string is single-quoted in the resolved form
1250        // so it's parsed as a single literal arg.
1251        assert_eq!(v.resolved_command.as_deref(), Some("echo 'rm -rf /'"));
1252    }
1253
1254    #[test]
1255    fn var_value_with_backticks_stays_literal() {
1256        // Similar to command sub: `\`whoami\`` in a variable value should
1257        // become a quoted literal arg, not a re-evaluated substitution.
1258        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    // ---- Safety limits ----
1265
1266    #[test]
1267    fn huge_brace_expansion_falls_back_to_ask() {
1268        // {1..100000} would produce 100k items; brace expansion is capped
1269        // at MAX_BRACE_EXPANSION (1024), so this returns Unresolvable → Ask.
1270        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        // {1..32}{1..32}{1..32} = 32k items, well over the cap.
1278        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        // $(cat <<'EOF' ... EOF) is a safe data-passing idiom — cat is SIMPLE_SAFE,
1287        // quoted delimiter prevents expansion, and echo is also safe.
1288        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        // git commit -m is Ask (git handler policy), but the heredoc substitution
1324        // should resolve rather than failing as "command substitution requires execution".
1325        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        // bash does NOT recursively expand variable values, and neither do we:
1346        // A="$B" stores the literal string "$B", not the expansion of $B.
1347        // When `echo $A` resolves, the result is `echo '$B'` — the value is
1348        // single-quoted in the resolved form so it stays literal, and the
1349        // re-parse sees a quoted string with no expansions to follow.
1350        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        // The literal `$B` ends up single-quoted to prevent re-expansion.
1354        assert_eq!(v.resolved_command.as_deref(), Some("echo '$B'"));
1355    }
1356}