Skip to main content

safe_chains/
lib.rs

1// The generated test names spell the command and its flags verbatim, and FLAG CASE IS MEANINGFUL:
2// find takes both `-D` and `-d`, `-P` and `-p`, `-L` and `-l`, and commands like `asn1Decoding` and
3// `checkLocalKDC` are camel-case upstream. Lowercasing to satisfy `non_snake_case` would erase the
4// distinction the test exists to pin, so the generated items opt out instead.
5#[cfg(test)]
6macro_rules! safe {
7    ($($name:ident: $cmd:expr),* $(,)?) => {
8        $(#[test] #[allow(non_snake_case)] fn $name() { assert!(check($cmd), "expected safe: {}", $cmd); })*
9    };
10}
11
12#[cfg(test)]
13macro_rules! denied {
14    ($($name:ident: $cmd:expr),* $(,)?) => {
15        $(#[test] #[allow(non_snake_case)] fn $name() { assert!(!check($cmd), "expected denied: {}", $cmd); })*
16    };
17}
18
19#[cfg(test)]
20macro_rules! inert {
21    ($($name:ident: $cmd:expr),* $(,)?) => {
22        $(#[test] #[allow(non_snake_case)] fn $name() {
23            assert_eq!(
24                crate::command_verdict($cmd),
25                crate::verdict::Verdict::Allowed(crate::verdict::SafetyLevel::Inert),
26                "expected Inert: {}", $cmd,
27            );
28        })*
29    };
30}
31
32#[cfg(test)]
33macro_rules! safe_read {
34    ($($name:ident: $cmd:expr),* $(,)?) => {
35        $(#[test] #[allow(non_snake_case)] fn $name() {
36            assert_eq!(
37                crate::command_verdict($cmd),
38                crate::verdict::Verdict::Allowed(crate::verdict::SafetyLevel::SafeRead),
39                "expected SafeRead: {}", $cmd,
40            );
41        })*
42    };
43}
44
45#[cfg(test)]
46macro_rules! safe_write {
47    ($($name:ident: $cmd:expr),* $(,)?) => {
48        $(#[test] #[allow(non_snake_case)] fn $name() {
49            assert_eq!(
50                crate::command_verdict($cmd),
51                crate::verdict::Verdict::Allowed(crate::verdict::SafetyLevel::SafeWrite),
52                "expected SafeWrite: {}", $cmd,
53            );
54        })*
55    };
56}
57
58pub mod cli;
59#[cfg(test)]
60mod composition;
61pub mod cst;
62#[cfg(test)]
63mod handler_property_tests;
64pub mod docs;
65pub mod engine;
66mod envvars;
67mod handlers;
68pub mod netloc;
69pub mod parse;
70pub mod pathctx;
71pub mod pathgate;
72pub mod policy;
73pub mod registry;
74pub mod suggest;
75pub mod allowlist;
76pub mod targets;
77pub mod verdict;
78
79pub use verdict::{SafetyLevel, Verdict};
80
81/// The facet profile behind a verdict, rendered for `--explain`.
82///
83/// Answers the question the boolean cannot: not "is this allowed" but "on which axis was it
84/// refused". Empty when no resolver claims the command — that is the answer too, since it means the
85/// legacy classifier decided and there are no facets to show.
86pub fn facet_breakdown(command: &str) -> String {
87    // One simple command only. `shell_words` has no idea what `&&` means, so on a chain it hands
88    // back one flat token list and the resolver reads the SECOND command's arguments as flags of
89    // the first — `aws dynamodb scan --table-name t && rm -rf /` produced a single worst-case
90    // profile belonging to neither segment. A diagnostic that invents a capability set no resolver
91    // emitted is worse than silence, and `render()` above already breaks the chain down per segment.
92    if cst::explain(command).segments.len() != 1 {
93        return "\n  (facet breakdown covers one command at a time; run --explain on a single segment)\n"
94            .to_string();
95    }
96    let Ok(words) = shell_words::split(command) else {
97        return String::new();
98    };
99    if words.is_empty() {
100        return String::new();
101    }
102    let tokens: Vec<parse::Token> = words.into_iter().map(parse::Token::from_raw).collect();
103    let Some(ex) = engine::bridge::explain_profile(&tokens) else {
104        return String::new();
105    };
106    let mut out = String::from("\n  resolved profile:\n");
107    for (because, facets) in &ex.capabilities {
108        out.push_str(&format!("    · {because}\n"));
109        for (name, term) in facets {
110            out.push_str(&format!("        {name:<28} {term}\n"));
111        }
112    }
113    match &ex.blocked_by {
114        Some((level, mismatch)) => {
115            out.push_str(&format!(
116                "\n  refused by `{level}` (the most permissive auto-approving level):\n    {mismatch}\n",
117            ));
118        }
119        None => out.push_str("\n  admitted by the auto-approve band.\n"),
120    }
121    out
122}
123
124pub fn is_safe_command(command: &str) -> bool {
125    command_verdict(command).is_allowed()
126}
127
128pub fn command_verdict(command: &str) -> Verdict {
129    cst::command_verdict(command)
130}
131
132/// Classify `command` against an UPPER-band level (`local-admin`/`network-admin`/`yolo`), which
133/// has no 3-value legacy ceiling. Every engine-resolved leaf is decided by `Level::admits`
134/// against `level` instead of the lower-band projection; a `Denied` on any segment dominates.
135/// Legacy (unresolved) leaves keep their local-safe `SafeWrite`-or-below verdict, which every
136/// upper level admits. The result is `Allowed(SafeWrite)` (accepted by the shared upper ceiling)
137/// or `Denied`.
138pub fn command_verdict_at_level(command: &str, level: &'static engine::level::Level) -> Verdict {
139    let _guard = engine::bridge::enter_eval_level(level);
140    cst::command_verdict(command)
141}
142
143/// The `&'static Level` for an UPPER-band level name, or `None` for the lower band (which the
144/// 3-value ceiling already handles) or an unknown name. The caller passes the CANONICAL name
145/// (legacy aliases already resolved).
146pub fn upper_level_by_name(name: &str) -> Option<&'static engine::level::Level> {
147    if !matches!(name, "local-admin" | "network-admin" | "yolo") {
148        return None;
149    }
150    engine::authoring::default_levels().iter().find(|l| l.name == name)
151}
152
153/// Resolve a level NAME to its `(3-band ceiling, engine level for admits)`, or `None` for an unknown
154/// name. The ceiling gates the projected verdict; the engine level (when present) classifies per-level
155/// via `admits`, exposing distinctions the 3-band projection flattens — `editor` (no destroy, no
156/// sibling write) vs `developer`, and the upper band (git push, bulk-object-read, sudo). `paranoid`/
157/// `reader` are pure ceilings (their read/inert bands need no `admits`), and `developer` IS the default
158/// band, so those carry no engine level. Legacy aliases (`safe-write`) canonicalize first.
159pub fn level_ceiling(name: &str) -> Option<(SafetyLevel, Option<&'static engine::level::Level>)> {
160    let (ceiling, legacy_of) = verdict::SafetyLevel::resolve_threshold(name)?;
161    let canonical = legacy_of.unwrap_or(name);
162    // Levels whose rule the 3-band projection can't express classify per-level via `admits`:
163    // `editor` (no destroy, no sibling write — distinct from developer) and the UPPER band (git push,
164    // bulk-object-read, sudo — above the band). `paranoid`/`reader` are pure ceilings (their
165    // inert/read bands need no `admits`; the `<= threshold` gate tightens), and `developer` IS the
166    // default band — those carry no engine level.
167    let engine_level = match canonical {
168        "editor" | "local-admin" | "network-admin" | "yolo" => {
169            engine::authoring::default_levels().iter().find(|l| l.name == canonical)
170        }
171        _ => None,
172    };
173    Some((ceiling, engine_level))
174}
175
176/// The ceilinged verdict: classify `command` at `(threshold, engine_level)`, gating the projected
177/// level `<= threshold`. The single seam both the CLI (`--level`) and the hook (configured `level`)
178/// funnel through. `engine_level = Some` classifies via `Level::admits` (the fine per-level model);
179/// `None` uses the 3-band projection. Either way the result is gated to `threshold`, so a legacy leaf
180/// that bypasses the engine (a redirect write → `SafeWrite`) is still held under a lower ceiling.
181pub fn command_verdict_ceilinged(
182    command: &str,
183    threshold: SafetyLevel,
184    engine_level: Option<&'static engine::level::Level>,
185) -> Verdict {
186    let verdict = match engine_level {
187        Some(level) => command_verdict_at_level(command, level),
188        None => command_verdict(command),
189    };
190    match verdict {
191        Verdict::Allowed(level) if level <= threshold => Verdict::Allowed(level),
192        _ => Verdict::Denied,
193    }
194}
195
196/// The coverage-fallback explanation (built-in classifier + the user's `permissions.allow` patterns),
197/// computed UNDER the configured engine level so a covered command honors that level's rule — a
198/// worktree destroy an `editor` plan forbids classifies as denied here too, not re-admitted. `None`
199/// engine level → the plain 3-band coverage (paranoid/reader/default). The caller still gates the
200/// result's `overall <= threshold`; running under the level closes the last path a lower plan's
201/// tighter rule could leak through.
202pub fn explain_with_coverage_at_level(
203    command: &str,
204    engine_level: Option<&'static engine::level::Level>,
205) -> cst::Explanation {
206    let patterns = allowlist::Matcher::load();
207    let _guard = engine_level.map(engine::bridge::enter_eval_level);
208    cst::explain_with_coverage(command, &patterns)
209}
210
211/// The auto-approve ceiling the HOOK evaluates at, from the write-protected user config
212/// (`~/.config/safe-chains.toml`, `level = "…"`). No config, or an unknown name → the default
213/// `developer` band (`SafeWrite`, no engine level) — fail-safe. Honored ONLY from the user config,
214/// never a repo `.safe-chains.toml`; the file is write-denied, so an agent cannot set its own ceiling.
215pub fn configured_hook_ceiling() -> (SafetyLevel, Option<&'static engine::level::Level>) {
216    registry::user_config_level()
217        .and_then(|name| level_ceiling(&name))
218        .unwrap_or((SafetyLevel::SafeWrite, None))
219}
220
221/// Classify `command` with the harness-supplied directory context installed (HP-19), so
222/// relative paths resolve against the real `cwd`/`root`. `command_verdict(cmd)` is the
223/// no-context form (`PathCtx::default()`), preserving every existing caller.
224pub fn command_verdict_in(command: &str, ctx: pathctx::PathCtx) -> Verdict {
225    let _guard = pathctx::enter(ctx);
226    cst::command_verdict(command)
227}
228
229/// Why a not-auto-approved command's path reach was flagged — so the nudge can explain the actual
230/// reason instead of a one-size-fits-all "outside the working directory". A peer's hidden file and a
231/// path genuinely above cwd both deny, but the remedy differs, and conflating them is what reads as
232/// "directory parsing is broken".
233#[derive(Debug, Clone, Copy, PartialEq, Eq)]
234pub enum ReachReason {
235    /// A known credential store (`.ssh`, `.aws`, keychain…).
236    Credential,
237    /// A HIDDEN file inside a co-located peer project — the peer's ordinary source is readable as
238    /// `adjacent`, but its dotfiles/dotdirs are shielded.
239    /// Genuinely above/outside the working directory.
240    OutsideWorkspace,
241    /// A path built by an interpolation that nothing confines (`./out/$i`, `> $(cmd)`). It is not
242    /// outside anything — it names WHATEVER the value turns out to be, which is why it cannot be
243    /// admitted — so the remedy is to constrain the spelling, not to grant a directory.
244    Unconfined,
245    /// A temp path that is NOT this session's scratchpad. Reading and writing it is fine; RUNNING
246    /// code from it is not, because anonymous `/tmp` is where downloaded/foreign code lands. This
247    /// is the one reach whose remedy is usually "that IS my working directory" — so the nudge says
248    /// how to bless it rather than implying the agent did something wrong.
249    ForeignTemp,
250}
251
252/// Render command-derived text safely INSIDE one of our messages.
253///
254/// The explanation is read by a human deciding whether to approve, and on the Claude and Qwen
255/// targets it is injected into the model's context as `additionalContext`. Command text is not
256/// trustworthy input for either job: a command routinely carries data the agent picked up from a
257/// file, an issue title, a downloaded manifest. Echoed raw, a newline in it forged a whole extra
258/// line of our OWN output —
259///
260/// ```text
261///   ✗  cat "/etc/x
262///   ✓  ls   safe-chains: auto-approves.
263/// ```
264///
265/// — so the reader saw an approval that never happened, in our voice. Escaping the control
266/// characters keeps any echoed text to a single line of literal content, which is the property
267/// that makes forging a second line impossible. Bidi controls go too: they reorder what is
268/// DISPLAYED without changing the bytes, which is the same forgery by other means.
269///
270/// This neutralizes our own OUTPUT. It is not a check on the command and decides nothing.
271/// Render our INTERNAL substitution markers back as `$(…)` before any of them reach a human.
272///
273/// A path carrying a substitution is classified through a sentinel, and the operand the nudge
274/// reports is the expanded form — so the reader of `cat ~/p/out/$(seq 1 1)` was being shown
275/// `~/p/out/__SAFE_CHAINS_CMDSUB_ATOM__`, a path they never wrote. On the Claude and Qwen targets
276/// this text is injected into the MODEL's context, where an internal marker is worse than noise:
277/// it is a magic string the model can learn and start emitting, and a nudge that describes a path
278/// the user cannot find in their own command is one they have no reason to trust.
279///
280/// Covers every sentinel spelling at once — opaque, atom, and the locus-tagged forms — by keying
281/// on the shared prefix and consuming through the terminating `__`, so a sentinel added later is
282/// rendered without touching this. Text that merely LOOKS like a sentinel keeps its tail — only
283/// the marker itself is replaced — because what follows a bare prefix is the user's path, not our
284/// internals, and dropping it handed a crafted filename control over how much of the path the
285/// reader saw.
286fn render_sentinels(s: &str) -> std::borrow::Cow<'_, str> {
287    let prefix = cst::eval::TAGGED_PREFIX;
288    if !s.contains(prefix) {
289        return std::borrow::Cow::Borrowed(s);
290    }
291    let mut out = String::with_capacity(s.len());
292    let mut rest = s;
293    while let Some(at) = rest.find(prefix) {
294        out.push_str(&rest[..at]);
295        out.push_str("$(…)");
296        let after = &rest[at + prefix.len()..];
297        rest = if let Some(tail) = after.strip_prefix('_') {
298            // The opaque marker is the prefix plus a single `_`.
299            tail
300        } else if let Some(i) = after.find("__") {
301            // Atom and locus-tagged markers are the prefix, a term, then `__`.
302            &after[i + 2..]
303        } else {
304            // Not one of ours. Keep the text: dropping it let a CRAFTED filename decide how much
305            // of the path a human was shown — `cat ~/__SAFE_CHAINS_CMDSUB_.ssh/id_rsa` reported
306            // reaching `~/$(…)`, hiding `.ssh/id_rsa` from the one message used to decide.
307            after
308        };
309    }
310    out.push_str(rest);
311    std::borrow::Cow::Owned(out)
312}
313
314pub fn sanitize_display(s: &str) -> String {
315    let s = &render_sentinels(s);
316    let mut out = String::with_capacity(s.len());
317    for c in s.chars() {
318        match c {
319            '\n' => out.push_str("\\n"),
320            '\r' => out.push_str("\\r"),
321            '\t' => out.push_str("\\t"),
322            // C0/C1 controls, and the bidi overrides/isolates/marks.
323            c if c.is_control()
324                || matches!(c, '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}' | '\u{200E}' | '\u{200F}') =>
325            {
326                out.push_str(&format!("\\u{{{:04x}}}", c as u32));
327            }
328            c => out.push(c),
329        }
330    }
331    out
332}
333
334impl ReachReason {
335    /// The self-contained nudge body ("it reaches `X`, …") including the reason-appropriate remedy.
336    /// Callers add their own framing (block / please-confirm) and the docs link.
337    pub fn message(self, path: &str) -> String {
338        let path = &sanitize_display(path);
339        match self {
340            ReachReason::Credential => format!(
341                "it reaches `{path}`, a credential store the agent should almost certainly not touch. \
342                 If this was not intended, stop it"
343            ),
344            ReachReason::ForeignTemp => format!(
345                "it runs code from `{path}`, a temporary directory that is not this session's \
346                 scratchpad. Temp files can be read and written freely, but code there is treated \
347                 as FOREIGN (a downloaded script lands in the same place), so running it is not \
348                 auto-approved. If this is a working directory you trust, grant it in \
349                 ~/.config/safe-chains.toml; a scratchpad the harness reports for this session is \
350                 recognized automatically and needs no grant"
351            ),
352            ReachReason::Unconfined => format!(
353                "the path `{path}` is built by an interpolation, so what it names depends on a \
354                 value that is not visible in the command — it could be anywhere, which is why it \
355                 cannot be auto-approved. If the interpolated part cannot contain a `/`, putting \
356                 literal text beside it in the same path component is enough to confine it: \
357                 `out/dx_$i.txt` is approved where `out/$i` is not, because the first is a \
358                 filename whatever `$i` holds and the second could be `..`"
359            ),
360            ReachReason::OutsideWorkspace => match pathctx::cwd().map(|c| sanitize_display(&c)) {
361                Some(cwd) => format!(
362                    "it reaches `{path}`, outside the working directory `{cwd}`. If the agent is \
363                     running from the wrong directory — an easy thing to forget — relaunch it where \
364                     you meant to be; to allow it from here, grant that path in \
365                     ~/.config/safe-chains.toml"
366                ),
367                None => format!(
368                    "it reaches `{path}`, outside the working directory. To allow it, grant that \
369                     path in ~/.config/safe-chains.toml"
370                ),
371            },
372        }
373    }
374}
375
376/// If a NOT-auto-approved command reaches a path OUTSIDE the workspace, return that path (its
377/// original spelling) and WHY, so the hook can nudge instead of silently prompting. Resolves against
378/// the ambient `cwd`/`root`: relative worktree paths, `/tmp`, and `/dev` streams are admitted and
379/// skipped; an absolute or home path that isn't admitted for read *or* write is the reach. A
380/// credential store outranks the hidden-peer wording; a hidden peer path outranks the generic
381/// outside-workspace reason.
382pub fn workspace_overreach(command: &str) -> Option<(String, ReachReason)> {
383    let tokens = operand_words(command)?;
384    tokens.into_iter().find_map(|t| {
385        if !policy::looks_like_path(&t) {
386            return None;
387        }
388        let resolved = pathctx::resolve(&t).into_owned();
389        // A temp path is READ/WRITE admitted, so the outside-test below never fires on it — but it
390        // is not EXECUTABLE unless it is this session's scratchpad. When the command was denied,
391        // that is the likely reason, and it is the one case where the fix is a grant rather than a
392        // correction, so surface it with those instructions.
393        if pathctx::under_temp_root(&resolved) && !pathctx::in_session_scratchpad(&resolved) {
394            return Some((t, ReachReason::ForeignTemp));
395        }
396        let outside = (resolved.starts_with('/') || resolved.starts_with('~'))
397            && (!engine::resolve::read_content_verdict(&resolved).is_allowed()
398                || !engine::resolve::write_target_verdict(&resolved).is_allowed());
399        if !outside {
400            return None;
401        }
402        // Asked on the LITERAL structure, so an interpolated component cannot strip the credential
403        // warning off a path that plainly names one. `cat ~/.ssh/$(id)` was reported as merely
404        // "built by an interpolation" — offering to flank it, which can never help, while dropping
405        // the one sentence that matters — and the CONFINED spelling fell through to
406        // "outside the working directory", whose remedy is to GRANT the path. That advised the
407        // user to grant `~/.ssh` to make the prompt stop.
408        let reason = if engine::resolve::names_credential_store(&resolved) {
409            ReachReason::Credential
410        } else if engine::resolve::anchoring_of(&resolved) == crate::engine::facet::Anchoring::Opaque {
411            // Ahead of OutsideWorkspace because it is the more specific diagnosis of the SAME
412            // refusal, and the generic wording actively misleads here: it names a working-directory
413            // problem the user does not have and a remedy (grant the path) that cannot work,
414            // since the path is not a fixed path at all.
415            ReachReason::Unconfined
416        } else {
417            ReachReason::OutsideWorkspace
418        };
419        Some((t, reason))
420    })
421}
422
423/// The words a command actually RUNS with, for explaining a denial.
424///
425/// This must agree with the parse the verdict came from, so it walks the CST. Splitting the raw
426/// string instead (`shell_words::split`) tokenizes text the shell never treats as an argument —
427/// above all a heredoc BODY, which is data. `git commit -m "$(cat <<'EOF' … EOF)"` whose message
428/// merely MENTIONS `/etc/hosts` was reported as "reaches /etc/hosts", naming a false reason for the
429/// denial and advising the reader to grant that path — a config widening the command never needed.
430///
431/// Falls back to the raw split only when the command does not parse, where a best-effort nudge on
432/// approximate tokens still beats none.
433fn operand_words(command: &str) -> Option<Vec<String>> {
434    let Some(script) = cst::parse(command) else {
435        return shell_words::split(command).ok();
436    };
437    let mut out = Vec::new();
438    collect_script_words(&script, &mut out);
439    Some(out)
440}
441
442/// A word contributes its own expansions AND the words of any command substitution inside it: the
443/// inner command runs, so `notacommand $(cat /etc/shadow)` really does read the file, even though
444/// `expand()` renders the substitution as an opaque stand-in and hides the path.
445fn collect_word(word: &cst::Word, out: &mut Vec<String>) {
446    out.extend(word.expand());
447    for part in &word.0 {
448        collect_part_subs(part, out);
449    }
450}
451
452/// The words of any command SUBSTITUTION inside a word part, and nothing else — the part's own
453/// literal text is the caller's business, because whether it counts as an operand depends on where
454/// the word came from (a heredoc body's literal text never does).
455fn collect_part_subs(part: &cst::WordPart, out: &mut Vec<String>) {
456    use cst::WordPart;
457    match part {
458        WordPart::CmdSub(script) | WordPart::ProcSub(script) => collect_script_words(script, out),
459        WordPart::DQuote(inner) => collect_word(inner, out),
460        WordPart::Lit(_)
461        | WordPart::Escape(_)
462        | WordPart::SQuote(_)
463        | WordPart::Backtick(_)
464        | WordPart::Arith(_) => {}
465    }
466}
467
468fn collect_script_words(script: &cst::Script, out: &mut Vec<String>) {
469    for stmt in &script.0 {
470        for cmd in &stmt.pipeline.commands {
471            collect_cmd_words(cmd, out);
472        }
473    }
474}
475
476/// A redirect TARGET is a path the command opens, so it is a reach and must be reported —
477/// `notacommand > /etc/passwd` names `/etc/passwd`. A heredoc DELIMITER is not a path at all, and
478/// its body never appears in the CST, which is the whole point.
479fn collect_redir_words(redirs: &[cst::Redir], out: &mut Vec<String>) {
480    use cst::Redir;
481    for redir in redirs {
482        match redir {
483            Redir::Write { target, .. }
484            | Redir::Read { target, .. }
485            | Redir::ReadWrite { target, .. }
486            | Redir::HereStr(target) => collect_word(target, out),
487            // Only the body's SUBSTITUTIONS, never its literal text. Behind a bare delimiter a
488            // `$(cat /etc/shadow)` in the body really runs, so it is a reach worth naming; the
489            // prose around it is data and naming it would state a false reason for the denial.
490            Redir::HereDoc { body, .. } => {
491                for part in &body.0 {
492                    collect_part_subs(part, out);
493                }
494            }
495            Redir::DupFd { .. } => {}
496        }
497    }
498}
499
500fn collect_cmd_words(cmd: &cst::Cmd, out: &mut Vec<String>) {
501    use cst::Cmd;
502    let words = |ws: &[cst::Word], out: &mut Vec<String>| {
503        for w in ws {
504            collect_word(w, out);
505        }
506    };
507    match cmd {
508        Cmd::Simple(s) => {
509            words(&s.words, out);
510            collect_redir_words(&s.redirs, out);
511        }
512        Cmd::Subshell { body, redirs } | Cmd::BraceGroup { body, redirs } => {
513            collect_script_words(body, out);
514            collect_redir_words(redirs, out);
515        }
516        Cmd::For {
517            items,
518            body,
519            redirs,
520            ..
521        } => {
522            words(items, out);
523            collect_script_words(body, out);
524            collect_redir_words(redirs, out);
525        }
526        Cmd::While { cond, body, redirs } | Cmd::Until { cond, body, redirs } => {
527            collect_script_words(cond, out);
528            collect_script_words(body, out);
529            collect_redir_words(redirs, out);
530        }
531        Cmd::If {
532            branches,
533            else_body,
534            redirs,
535        } => {
536            collect_redir_words(redirs, out);
537            for branch in branches {
538                collect_script_words(&branch.cond, out);
539                collect_script_words(&branch.body, out);
540            }
541            if let Some(body) = else_body {
542                collect_script_words(body, out);
543            }
544        }
545        Cmd::DoubleBracket { words: ws, redirs } => {
546            words(ws, out);
547            collect_redir_words(redirs, out);
548        }
549        Cmd::Case {
550            subject,
551            arms,
552            redirs,
553        } => {
554            collect_word(subject, out);
555            for arm in arms {
556                collect_script_words(&arm.body, out);
557            }
558            collect_redir_words(redirs, out);
559        }
560        Cmd::FunctionDef { body, .. } => collect_script_words(body, out),
561    }
562}
563
564#[cfg(test)]
565mod tests;