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 length (bytes) of a resolved command string. Resolution that
20/// would produce a longer string falls back to Ask, preventing pathological
21/// expansions (e.g., variables that contain other expansions, deeply
22/// recursive aliases) from blowing up memory.
23const MAX_RESOLVED_LEN: usize = 16_384;
24
25/// Maximum number of nested resolution passes. Each call to `try_resolve`
26/// re-parses the resolved command and may resolve again; this cap is
27/// independent of `MAX_DEPTH` (which bounds AST node nesting) and prevents
28/// `A=$B; B=$C; C=$A` cycles from blowing the stack.
29const MAX_RESOLUTION_DEPTH: usize = 8;
30
31/// The core analysis engine: parses a command and produces a safety verdict.
32pub 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    /// Cached current git branch name.
40    git_branch: Option<String>,
41    /// Set to true when analyzing a command that receives piped input.
42    piped: bool,
43    /// Variable lookup used for static expansion resolution.
44    /// Defaults to `EnvLookup` (real process environment); tests inject mocks.
45    var_lookup: Box<dyn VarLookup>,
46    /// Tracks how many nested expansion-resolution passes have run for the
47    /// current command. Bounded by `MAX_RESOLUTION_DEPTH` to prevent cycles.
48    resolution_depth: usize,
49}
50
51impl Analyzer {
52    /// Create a new analyzer from an [`Environment`] struct.
53    ///
54    /// This is the preferred constructor — it takes all external dependencies
55    /// as an explicit struct, making tests deterministic without env-var hacks.
56    ///
57    /// # Errors
58    ///
59    /// Returns `RippyError::Parse` if the bash parser cannot be initialized.
60    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    /// Create a new analyzer using the real process environment for variable lookups.
78    ///
79    /// Convenience wrapper around [`Analyzer::from_env`] that reads `$HOME`
80    /// and process env vars automatically.
81    ///
82    /// # Errors
83    ///
84    /// Returns `RippyError::Parse` if the bash parser cannot be initialized.
85    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    /// Create a new analyzer with a custom variable lookup (used by tests
96    /// to inject deterministic env values via `MockLookup`).
97    ///
98    /// # Errors
99    ///
100    /// Returns `RippyError::Parse` if the bash parser cannot be initialized.
101    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    /// Build a `MatchContext` for condition evaluation.
114    fn match_ctx(&self) -> MatchContext<'_> {
115        MatchContext {
116            branch: self.git_branch.as_deref(),
117            cwd: &self.working_directory,
118        }
119    }
120
121    /// Analyze a shell command string and return a safety verdict.
122    ///
123    /// # Errors
124    ///
125    /// Returns `RippyError::Parse` if the command cannot be parsed.
126    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, &current_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            // In `|| true` patterns, only include the fallback if it's non-trivial
316            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        // Static expansion resolution: if any words contain expansions, attempt
359        // to resolve them and re-classify the resolved command through the full
360        // pipeline. This applies uniformly to safe-list, wrapper, and handler
361        // paths — the resolved command goes back through analyze_inner_command.
362        if let Some(resolved_verdict) = self.try_resolve(words, cwd, depth) {
363            // Use Verdict::combine (not most_restrictive) so the resolved_command
364            // field is preserved even when a redirect verdict dominates the
365            // decision — combine borrows resolved_command from any input verdict.
366            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    /// Attempt to statically resolve any shell expansions in `words` and
514    /// re-classify the resolved command through the full pipeline.
515    ///
516    /// Returns:
517    /// - `None` when there are no expansions to resolve (caller proceeds normally)
518    /// - `Some(verdict)` when expansions were present:
519    ///   - On unresolvable expansions, an `Ask` verdict with a diagnostic reason
520    ///   - On command-position dynamic execution (`$cmd args`), an `Ask` verdict
521    ///     regardless of whether resolution succeeded
522    ///   - Otherwise, the verdict of re-analyzing the resolved command
523    ///     (annotated with the resolved form for transparency)
524    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        // Bail out on runaway resolution before doing any work. Each nested
529        // call increments `resolution_depth`; cycles like `A=$B; B=$A` are
530        // caught here even if individual depths are small.
531        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        // Refuse to materialize pathologically large resolved commands.
544        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        // Track nesting around the recursive analyze_inner_command call.
559        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
631/// Annotate a verdict with the resolved command form: appends `(resolved: <cmd>)`
632/// to the reason (idempotent) and stores the resolved command in `resolved_command`.
633fn 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        // Use an empty MockLookup so default tests are deterministic regardless
667        // of the host environment.
668        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); // subshell is transparent
829    }
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        // Even though ls is safe, command substitution has an Ask floor
1020        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    // ---- Expansion resolution tests ----
1031
1032    #[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    // ---- Heredoc tests (resolution NOT in scope for heredocs in this PR) ----
1082
1083    #[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    // ---- Tests for unresolvable expansions (still Ask) ----
1113
1114    #[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        // Command substitution can never be resolved statically.
1143        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    // ---- Resolution that triggers handler-side Ask ----
1156
1157    #[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        // rm always asks via the handler, regardless of arg
1162        assert_eq!(v.decision, Decision::Ask);
1163        // But the verdict carries the resolved form
1164        assert_eq!(v.resolved_command.as_deref(), Some("rm /tmp/file"));
1165    }
1166
1167    // ---- Command-position protection ----
1168
1169    #[test]
1170    fn dynamic_command_position_asks_even_when_resolved() {
1171        // `$cmd args` with cmd=ls would normally allow ls, but command-position
1172        // dynamic execution is always Ask regardless of resolution.
1173        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    // ---- Handler-path resolution ----
1185
1186    #[test]
1187    fn handler_path_resolves_quoted_subcommand() {
1188        // `git $'status'` should resolve to `git status` and let the git handler
1189        // classify it normally (status is safe).
1190        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    // ---- Default with literal ----
1197
1198    #[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    // ---- Safety: variable values containing shell metacharacters ----
1207
1208    #[test]
1209    fn var_value_with_command_substitution_stays_literal() {
1210        // If a variable's value LOOKS like command substitution (`$(whoami)`),
1211        // shell_join_arg must single-quote it so the re-parsed command sees a
1212        // literal string, not an expansion. echo is safe regardless of arg
1213        // content, so this should Allow with the value treated as data.
1214        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        // The resolved form quotes the value to keep it literal.
1222        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        // The killer test for the "content drives the verdict" claim:
1228        // a variable holding what LOOKS like `rm -rf /` is just a string when
1229        // passed to echo. echo is safe; the value is data, not execution.
1230        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        // The dangerous-looking string is single-quoted in the resolved form
1234        // so it's parsed as a single literal arg.
1235        assert_eq!(v.resolved_command.as_deref(), Some("echo 'rm -rf /'"));
1236    }
1237
1238    #[test]
1239    fn var_value_with_backticks_stays_literal() {
1240        // Similar to command sub: `\`whoami\`` in a variable value should
1241        // become a quoted literal arg, not a re-evaluated substitution.
1242        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    // ---- Safety limits ----
1249
1250    #[test]
1251    fn huge_brace_expansion_falls_back_to_ask() {
1252        // {1..100000} would produce 100k items; brace expansion is capped
1253        // at MAX_BRACE_EXPANSION (1024), so this returns Unresolvable → Ask.
1254        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        // {1..32}{1..32}{1..32} = 32k items, well over the cap.
1262        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        // $(cat <<'EOF' ... EOF) is a safe data-passing idiom — cat is SIMPLE_SAFE,
1271        // quoted delimiter prevents expansion, and echo is also safe.
1272        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        // git commit -m is Ask (git handler policy), but the heredoc substitution
1308        // should resolve rather than failing as "command substitution requires execution".
1309        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        // bash does NOT recursively expand variable values, and neither do we:
1330        // A="$B" stores the literal string "$B", not the expansion of $B.
1331        // When `echo $A` resolves, the result is `echo '$B'` — the value is
1332        // single-quoted in the resolved form so it stays literal, and the
1333        // re-parse sees a quoted string with no expansions to follow.
1334        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        // The literal `$B` ends up single-quoted to prevent re-expansion.
1338        assert_eq!(v.resolved_command.as_deref(), Some("echo '$B'"));
1339    }
1340}