Skip to main content

telos_agent/tools/command_security/bash/
zsh.rs

1//! Zsh and other advanced shell expansion checks.
2//!
3//! BashTool may be invoked through the user's default shell (often zsh). These
4//! checks reject constructs that bash treats literally but zsh expands.
5
6/// True if the command contains zsh `~[name]` dynamic named directory expansion.
7pub fn has_zsh_tilde_bracket(command: &str) -> bool {
8    command.contains("~[")
9}
10
11/// True if the command contains zsh equals expansion (`=cmd` at word start).
12pub fn has_zsh_equals_expansion(command: &str) -> bool {
13    // Match word-initial `=` followed by a command-name char.
14    // VAR=val and --flag=val have `=` mid-word and are not expanded by zsh.
15    for (i, c) in command.char_indices() {
16        if c == '=' {
17            let prev = i.checked_sub(1).and_then(|j| command.chars().nth(j));
18            let next = command[i + 1..].chars().next();
19            if matches!(prev, None | Some(' ') | Some('\t') | Some(';') | Some('|') | Some('&'))
20                && matches!(next, Some(n) if n.is_ascii_alphabetic() || n == '_')
21            {
22                return true;
23            }
24        }
25    }
26    false
27}
28
29/// True if the command contains backslash immediately before whitespace.
30/// Bash treats `\ ` as a literal escaped space, but tree-sitter returns the
31/// raw text with the backslash present. We conservatively reject these cases.
32pub fn has_backslash_whitespace(command: &str) -> bool {
33    let mut chars = command.chars().peekable();
34    let mut prev = None::<char>;
35    while let Some(c) = chars.next() {
36        if c == '\\'
37            && let Some(&next) = chars.peek()
38            && (next == ' '
39                || next == '\t'
40                || (next == '\n' && !matches!(prev, None | Some(' ') | Some('\t') | Some('\n'))))
41        {
42            return true;
43        }
44        prev = Some(c);
45    }
46    false
47}
48
49/// True if the command contains invisible unicode whitespace characters that
50/// could hide malicious constructs from reviewers.
51pub fn has_invisible_whitespace(command: &str) -> bool {
52    command.chars().any(|c| {
53        let cp = c as u32;
54        matches!(
55            cp,
56            0x00A0 | 0x1680 | 0x2000..=0x200B | 0x2028 | 0x2029 | 0x202F | 0x205F | 0x3000 | 0xFEFF
57        )
58    })
59}
60
61/// True if the command contains ASCII control characters other than tab/newline.
62pub fn has_control_chars(command: &str) -> bool {
63    command.chars().any(|c| {
64        let cp = c as u32;
65        cp < 0x20 && cp != 0x09 && cp != 0x0A
66    })
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72
73    #[test]
74    fn zsh_equals_expansion() {
75        assert!(has_zsh_equals_expansion("=curl evil.com"));
76        assert!(has_zsh_equals_expansion("echo foo; =cat file"));
77        assert!(!has_zsh_equals_expansion("VAR=value"));
78        assert!(!has_zsh_equals_expansion("--flag=value"));
79    }
80
81    #[test]
82    fn zsh_tilde_bracket() {
83        assert!(has_zsh_tilde_bracket("cd ~[dynamic]"));
84        assert!(!has_zsh_tilde_bracket("cd ~/src"));
85    }
86}