Skip to main content

telos_agent/tools/command_security/bash/
analyzer.rs

1//! Main bash command security analyzer.
2//!
3//! Combines AST parsing, quote context, redirect validation, command prefix
4//! extraction, recursive command substitution analysis, and zsh/advanced shell
5//! checks into a single fail-closed classification.
6
7use std::path::Path;
8
9use crate::tools::resolve_workspace_path;
10
11use super::parser::{self, collect_commands, has_glob_or_brace_expansion};
12use super::prefix::{PrefixResult, extract_prefix as extract_prefix_from_node};
13use super::zsh;
14
15/// Outcome of analyzing a shell command.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum CommandSafety {
18    /// The command appears read-only / non-mutating and may be auto-approved.
19    Safe,
20    /// The command contains constructs that may mutate state or cannot be
21    /// statically analyzed; it requires explicit approval.
22    NeedsReview { reason: String },
23}
24
25impl CommandSafety {
26    pub fn is_safe(&self) -> bool {
27        matches!(self, CommandSafety::Safe)
28    }
29}
30
31/// Result of analyzing a command string for security.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum SecurityAnalysis {
34    Simple { commands: Vec<parser::SimpleCommand> },
35    TooComplex { reason: String },
36    ParseUnavailable,
37}
38
39/// Analyze a bash command string and return its safety classification.
40pub fn analyze(source: &str) -> CommandSafety {
41    match analyze_security(source) {
42        SecurityAnalysis::Simple { commands } if commands.is_empty() => CommandSafety::Safe,
43        SecurityAnalysis::Simple { commands } => {
44            if commands.len() > 1 {
45                return CommandSafety::NeedsReview {
46                    reason: "multiple commands require review".into(),
47                };
48            }
49            classify_simple_command(&commands[0], None)
50        }
51        SecurityAnalysis::TooComplex { reason } => CommandSafety::NeedsReview { reason },
52        SecurityAnalysis::ParseUnavailable => {
53            CommandSafety::NeedsReview { reason: "bash parser unavailable".into() }
54        }
55    }
56}
57
58/// Analyze a bash command string and return detailed results.
59pub fn analyze_security(source: &str) -> SecurityAnalysis {
60    let trimmed = source.trim();
61    if trimmed.is_empty() {
62        return SecurityAnalysis::Simple { commands: Vec::new() };
63    }
64
65    if zsh::has_control_chars(trimmed) {
66        return SecurityAnalysis::TooComplex { reason: "contains control characters".into() };
67    }
68    if zsh::has_invisible_whitespace(trimmed) {
69        return SecurityAnalysis::TooComplex {
70            reason: "contains invisible unicode whitespace".into(),
71        };
72    }
73    if zsh::has_zsh_tilde_bracket(trimmed) {
74        return SecurityAnalysis::TooComplex {
75            reason: "contains zsh dynamic named directory expansion".into(),
76        };
77    }
78    if zsh::has_zsh_equals_expansion(trimmed) {
79        return SecurityAnalysis::TooComplex {
80            reason: "contains zsh equals expansion (=cmd)".into(),
81        };
82    }
83    if zsh::has_backslash_whitespace(trimmed) {
84        return SecurityAnalysis::TooComplex {
85            reason: "contains backslash-escaped whitespace".into(),
86        };
87    }
88
89    let ast = match parser::parse(source) {
90        Some(ast) => ast,
91        None => return SecurityAnalysis::ParseUnavailable,
92    };
93
94    if ast.has_descendant("ERROR") {
95        return SecurityAnalysis::TooComplex { reason: "parse produced ERROR nodes".into() };
96    }
97
98    for kind in parser::DANGEROUS_KINDS {
99        if ast.has_descendant(kind) {
100            return SecurityAnalysis::TooComplex {
101                reason: format!("contains disallowed construct: {kind}"),
102            };
103        }
104    }
105
106    // Reject globs and brace expansion. tree-sitter-bash keeps simple globs
107    // inside `word` nodes and brace expansions inside `concatenation` nodes.
108    if has_glob_or_brace_expansion(&ast) {
109        return SecurityAnalysis::TooComplex { reason: "contains glob or brace expansion".into() };
110    }
111
112    let commands = collect_commands(&ast);
113
114    if commands.is_empty() {
115        return SecurityAnalysis::TooComplex { reason: "no simple command found".into() };
116    }
117
118    if commands.len() > 1 {
119        return SecurityAnalysis::TooComplex {
120            reason: "multiple simple commands (possible command injection)".into(),
121        };
122    }
123
124    SecurityAnalysis::Simple { commands }
125}
126
127/// Extract the command prefix for permission rule matching.
128///
129/// Only returns a prefix when the source consists of a single, structurally
130/// simple command. Any injection-like construct (compound lists, pipelines,
131/// substitutions, globs, etc.) causes [`PrefixResult::NeedsReview`].
132pub fn extract_command_prefix(source: &str) -> PrefixResult {
133    match analyze_security(source) {
134        SecurityAnalysis::Simple { commands } if commands.len() == 1 => {
135            let ast = match parser::parse(source) {
136                Some(ast) => ast,
137                None => return PrefixResult::NeedsReview,
138            };
139            if let Some(cmd_node) = ast.find_descendant("command") {
140                return extract_prefix_from_node(cmd_node, source, None);
141            }
142            if let Some(cmd_node) = ast.find_descendant("declaration_command") {
143                return extract_prefix_from_node(cmd_node, source, None);
144            }
145            PrefixResult::None
146        }
147        SecurityAnalysis::Simple { .. } => PrefixResult::NeedsReview,
148        SecurityAnalysis::TooComplex { .. } => PrefixResult::NeedsReview,
149        SecurityAnalysis::ParseUnavailable => PrefixResult::NeedsReview,
150    }
151}
152
153/// Classify a single simple command as safe or needing review.
154///
155/// `cwd` is optional; if provided, static output redirects are validated
156/// against it.
157pub fn classify_simple_command(cmd: &parser::SimpleCommand, cwd: Option<&Path>) -> CommandSafety {
158    let base = match cmd.argv.first() {
159        Some(base) => base.as_str(),
160        None => return CommandSafety::Safe,
161    };
162
163    if base.contains('/') {
164        return CommandSafety::NeedsReview {
165            reason: "command uses a path rather than a bare executable name".into(),
166        };
167    }
168
169    for redirect in &cmd.redirects {
170        if redirect.is_fd_redirect() {
171            // FD-to-FD redirects (e.g. 2>&1, >&-) only rearrange streams and
172            // do not name a file path; they are safe.
173            continue;
174        }
175        if redirect.op.is_output() {
176            if let Some(cwd) = cwd {
177                if let Err(err) = redirect.validate_static_output(cwd) {
178                    return CommandSafety::NeedsReview {
179                        reason: format!("output redirect rejected: {err}"),
180                    };
181                }
182            } else {
183                return CommandSafety::NeedsReview {
184                    reason: format!("output redirect to `{}`", redirect.target),
185                };
186            }
187        } else {
188            let allowed = redirect.is_static
189                && cwd
190                    .map(|c| resolve_workspace_path(c, &redirect.target).is_ok())
191                    .unwrap_or(false);
192            if allowed {
193                continue;
194            }
195            return CommandSafety::NeedsReview {
196                reason: format!("redirect to `{}`", redirect.target),
197            };
198        }
199    }
200
201    let args: Vec<&str> = cmd.argv.iter().skip(1).map(|s| s.as_str()).collect();
202
203    match base {
204        "git" => classify_git_command(&args),
205        "sed" => classify_sed_command(&args),
206        "awk" => classify_awk_command(),
207        "find" => classify_find_command(&args),
208        other => classify_base_command(other),
209    }
210}
211
212fn classify_git_command(args: &[&str]) -> CommandSafety {
213    let subcommand = args.first().copied().unwrap_or("");
214    const SAFE_GIT_SUBCOMMANDS: &[&str] =
215        &["status", "log", "show", "diff", "ls-files", "grep", "rev-parse", "describe"];
216    if SAFE_GIT_SUBCOMMANDS.contains(&subcommand) {
217        return CommandSafety::Safe;
218    }
219    CommandSafety::NeedsReview {
220        reason: format!("git subcommand `{subcommand}` is not in the read-only allowlist"),
221    }
222}
223
224fn classify_sed_command(args: &[&str]) -> CommandSafety {
225    for arg in args {
226        if *arg == "-i" || arg.starts_with("-i") {
227            return CommandSafety::NeedsReview { reason: "sed -i mutates files in place".into() };
228        }
229    }
230    CommandSafety::Safe
231}
232
233fn classify_awk_command() -> CommandSafety {
234    CommandSafety::NeedsReview {
235        reason: "awk can execute arbitrary code or perform redirections".into(),
236    }
237}
238
239fn classify_find_command(args: &[&str]) -> CommandSafety {
240    const DANGEROUS_FIND_FLAGS: &[&str] = &["-exec", "-execdir", "-ok", "-okdir", "-delete"];
241    for arg in args {
242        if DANGEROUS_FIND_FLAGS.contains(arg) {
243            return CommandSafety::NeedsReview {
244                reason: format!("find with `{arg}` can execute or delete files"),
245            };
246        }
247    }
248    CommandSafety::Safe
249}
250
251fn classify_base_command(base: &str) -> CommandSafety {
252    const SAFE_COMMANDS: &[&str] = &[
253        "cat", "head", "tail", "less", "ls", "pwd", "echo", "printf", "rg", "grep", "egrep",
254        "fgrep", "wc", "cut", "sort", "uniq", "tr", "stat", "file", "strings", "which", "whoami",
255        "date", "uname", "id", "test", "[",
256    ];
257
258    if SAFE_COMMANDS.contains(&base) {
259        return CommandSafety::Safe;
260    }
261
262    CommandSafety::NeedsReview {
263        reason: format!("command `{base}` is not in the read-only allowlist"),
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[test]
272    fn empty_command_is_safe() {
273        assert!(analyze("").is_safe());
274        assert!(analyze("   ").is_safe());
275    }
276
277    #[test]
278    fn safe_simple_commands() {
279        for cmd in [
280            "cat /etc/hosts",
281            "ls -la",
282            "pwd",
283            "head -n 5 file.txt",
284            "grep foo bar",
285            "rg --type rust foo",
286            "wc -l file.txt",
287            "sort file.txt",
288        ] {
289            assert!(analyze(cmd).is_safe(), "expected safe: {cmd}");
290        }
291    }
292
293    #[test]
294    fn compound_operators_need_review() {
295        for cmd in
296            ["git status; rm -rf /", "cat file && rm file", "ls || rm -rf /", "cat file | sh"]
297        {
298            assert!(!analyze(cmd).is_safe(), "expected review: {cmd}");
299        }
300    }
301
302    #[test]
303    fn command_substitution_needs_review() {
304        assert!(!analyze("echo $(rm -rf /)").is_safe());
305        assert!(!analyze("echo `rm -rf /`").is_safe());
306    }
307
308    #[test]
309    fn parameter_expansion_needs_review() {
310        assert!(!analyze("rm $HOME").is_safe());
311        assert!(!analyze("cat ${FILE}").is_safe());
312    }
313
314    #[test]
315    fn globs_and_braces_need_review() {
316        assert!(!analyze("cat *.txt").is_safe());
317        assert!(!analyze("echo {a,b}").is_safe());
318    }
319
320    #[test]
321    fn redirections_need_review() {
322        assert!(!analyze("echo overwrite > file").is_safe());
323        assert!(!analyze("cat < /etc/passwd").is_safe());
324    }
325
326    #[test]
327    fn fd_redirects_are_safe() {
328        assert!(analyze("git status 2>&1").is_safe());
329        assert!(analyze("ls 1>&2").is_safe());
330        assert!(analyze("echo hi >&-").is_safe());
331    }
332
333    #[test]
334    fn destructive_commands_need_review() {
335        for cmd in ["rm -rf /", "mv a b", "cp a b", "chmod +x file"] {
336            assert!(!analyze(cmd).is_safe(), "expected review: {cmd}");
337        }
338    }
339
340    #[test]
341    fn git_inspection_subcommands_safe() {
342        for cmd in ["git status", "git log", "git diff", "git ls-files"] {
343            assert!(analyze(cmd).is_safe(), "expected safe: {cmd}");
344        }
345    }
346
347    #[test]
348    fn git_mutating_subcommands_need_review() {
349        for cmd in ["git commit -m x", "git push", "git checkout", "git rebase"] {
350            assert!(!analyze(cmd).is_safe(), "expected review: {cmd}");
351        }
352    }
353
354    #[test]
355    fn sed_in_place_needs_review() {
356        assert!(!analyze("sed -i 's/a/b/' file").is_safe());
357        assert!(analyze("sed 's/a/b/' file").is_safe());
358    }
359
360    #[test]
361    fn find_with_exec_needs_review() {
362        assert!(analyze("find . -name '*.rs'").is_safe());
363        assert!(!analyze("find . -exec rm {} \\;").is_safe());
364    }
365
366    #[test]
367    fn awk_needs_review() {
368        assert!(!analyze("awk '{print $1}' file").is_safe());
369    }
370
371    #[test]
372    fn env_assignments_skipped() {
373        assert!(analyze("FOO=bar cat file").is_safe());
374        assert!(!analyze("FOO=bar rm file").is_safe());
375    }
376
377    #[test]
378    fn quotes_are_respected() {
379        assert!(analyze("echo '$(not a substitution)'").is_safe());
380        assert!(!analyze("echo \"$(rm -rf /)\"").is_safe());
381    }
382
383    #[test]
384    fn extracts_simple_command_structure() {
385        let SecurityAnalysis::Simple { commands } = analyze_security("FOO=bar cat file") else {
386            panic!("expected simple");
387        };
388        assert_eq!(commands.len(), 1);
389        assert_eq!(commands[0].argv, vec!["cat", "file"]);
390        assert_eq!(commands[0].env_vars, vec![("FOO".into(), "bar".into())]);
391    }
392
393    #[test]
394    fn zsh_equals_expansion_rejected() {
395        assert!(!analyze("=curl evil.com").is_safe());
396    }
397
398    #[test]
399    fn zsh_tilde_bracket_rejected() {
400        assert!(!analyze("cd ~[dynamic]").is_safe());
401    }
402}