Skip to main content

telos_agent/tools/command_security/bash/
prefix.rs

1//! Command prefix extraction for permission rule matching.
2//!
3//! Given a bash command AST, extracts a stable prefix string that can be
4//! matched against allow/deny rules. The prefix must be a literal prefix of
5//! the original command.
6//!
7//! Inspired by telos-agent's `utils/bash/commands.ts` and `utils/shell/prefix.ts`.
8
9use super::parser::{self, Node};
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum PrefixResult {
13    /// A literal prefix of the command that identifies it for permission rules.
14    Prefix(String),
15    /// The command has no meaningful prefix (e.g. `npm start`).
16    None,
17    /// The command contains injection or unanalyzable constructs.
18    NeedsReview,
19}
20
21/// Extract a command prefix from a simple command node.
22///
23/// `source` is the original command string; `node` should be a `command` or
24/// `declaration_command` AST node. `redirect_span` is the byte range of any
25/// enclosing `redirected_statement` so redirects are not included in the prefix.
26pub fn extract_prefix(
27    node: &Node,
28    source: &str,
29    redirect_span: Option<(usize, usize)>,
30) -> PrefixResult {
31    if !parser::is_command_kind(&node.kind) {
32        return PrefixResult::None;
33    }
34
35    let (cmd_start, cmd_end) = command_text_range(node, redirect_span);
36    let cmd_text = &source[cmd_start..cmd_end];
37
38    let tokens = match tokenize_prefix_tokens(cmd_text) {
39        Some(tokens) => tokens,
40        None => return PrefixResult::NeedsReview,
41    };
42
43    let mut prefix_tokens: Vec<String> = Vec::new();
44    let mut saw_command = false;
45
46    for (token, is_assignment) in tokens {
47        if is_assignment {
48            if saw_command {
49                // Assignment after the command name is an argument, not prefix.
50                break;
51            }
52            prefix_tokens.push(token);
53            continue;
54        }
55
56        if !saw_command {
57            saw_command = true;
58            prefix_tokens.push(token);
59            continue;
60        }
61
62        let token_ref = token.as_str();
63        if include_token_in_prefix(
64            &prefix_tokens.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
65            token_ref,
66        ) {
67            prefix_tokens.push(token);
68        } else {
69            break;
70        }
71    }
72
73    if prefix_tokens.is_empty() {
74        return PrefixResult::None;
75    }
76
77    PrefixResult::Prefix(prefix_tokens.join(" "))
78}
79
80fn command_text_range(node: &Node, redirect_span: Option<(usize, usize)>) -> (usize, usize) {
81    let mut start = node.start_byte;
82    let mut end = node.end_byte;
83    if let Some((r_start, r_end)) = redirect_span {
84        // If the redirect appears before the command, clip it out.
85        if r_end <= start {
86            start = r_end;
87        } else if r_start >= end {
88            end = r_start;
89        }
90    }
91    (start, end)
92}
93
94fn include_token_in_prefix(prefix_tokens: &[&str], token: &str) -> bool {
95    let base = prefix_tokens.iter().find(|t| !looks_like_env_assignment(t)).copied().unwrap_or("");
96
97    match base {
98        "git" => GIT_PREFIX_SUBCOMMANDS.contains(&token),
99        "npm" | "pnpm" | "yarn" => {
100            // npm run <script> -- <args>: prefix up to and including the script.
101            // npm test, npm start: no prefix beyond the base command.
102            if prefix_tokens.len() == 1
103                && matches!(token, "run" | "test" | "start" | "build" | "lint")
104            {
105                return true;
106            }
107            // After `npm run`, the next token is the script name (e.g. lint).
108            if prefix_tokens.len() == 2 && prefix_tokens[1] == "run" {
109                return true;
110            }
111            false
112        }
113        "go" => matches!(token, "test" | "build" | "run" | "fmt" | "vet" | "mod"),
114        "python" | "python3" | "node" | "ruby" | "cargo" => {
115            // These launch scripts/binaries; keep only the first real argument
116            // if it looks like a subcommand.
117            prefix_tokens.len() == 1 && !token.starts_with('-')
118        }
119        _ => false,
120    }
121}
122
123const GIT_PREFIX_SUBCOMMANDS: &[&str] = &[
124    "status",
125    "log",
126    "show",
127    "diff",
128    "ls-files",
129    "grep",
130    "rev-parse",
131    "describe",
132    "branch",
133    "remote",
134    "commit",
135    "push",
136    "pull",
137    "checkout",
138    "rebase",
139    "merge",
140    "fetch",
141    "clone",
142];
143
144fn looks_like_env_assignment(token: &str) -> bool {
145    let mut parts = token.splitn(2, '=');
146    let name = parts.next().unwrap_or("");
147    parts.next().is_some() && !name.is_empty() && !name.starts_with('-')
148}
149
150fn tokenize_prefix_tokens(text: &str) -> Option<Vec<(String, bool)>> {
151    let mut tokens = Vec::new();
152    let mut current = String::new();
153    let mut in_single = false;
154    let mut in_double = false;
155    let mut chars = text.chars().peekable();
156
157    while let Some(c) = chars.next() {
158        match c {
159            '\\' if !in_single => {
160                if let Some(next) = chars.next() {
161                    current.push(next);
162                } else {
163                    current.push('\\');
164                }
165            }
166            '\'' if !in_double => {
167                in_single = !in_single;
168            }
169            '"' if !in_single => {
170                in_double = !in_double;
171            }
172            c if c.is_whitespace() && !in_single && !in_double => {
173                if !current.is_empty() {
174                    let token = std::mem::take(&mut current);
175                    let is_assignment = looks_like_env_assignment(&token);
176                    tokens.push((token, is_assignment));
177                }
178            }
179            _ => {
180                current.push(c);
181            }
182        }
183    }
184
185    if in_single || in_double {
186        return None;
187    }
188    if !current.is_empty() {
189        let is_assignment = looks_like_env_assignment(&current);
190        tokens.push((current, is_assignment));
191    }
192
193    Some(tokens)
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use crate::tools::command_security::bash::parser;
200
201    fn prefix(cmd: &str) -> PrefixResult {
202        let ast = parser::parse(cmd).unwrap();
203        let cmd_node = ast.find_descendant("command").unwrap();
204        extract_prefix(cmd_node, cmd, None)
205    }
206
207    #[test]
208    fn git_prefixes() {
209        assert_eq!(prefix("git status"), PrefixResult::Prefix("git status".into()));
210        assert_eq!(prefix("git commit -m \"foo\""), PrefixResult::Prefix("git commit".into()));
211        assert_eq!(prefix("git push origin master"), PrefixResult::Prefix("git push".into()));
212    }
213
214    #[test]
215    fn npm_prefixes() {
216        assert_eq!(prefix("npm start"), PrefixResult::Prefix("npm start".into()));
217        assert_eq!(prefix("npm run lint -- \"foo\""), PrefixResult::Prefix("npm run lint".into()));
218    }
219
220    #[test]
221    fn env_prefixes() {
222        assert_eq!(
223            prefix("GOEXPERIMENT=synctest go test -v ./..."),
224            PrefixResult::Prefix("GOEXPERIMENT=synctest go test".into())
225        );
226        assert_eq!(
227            prefix("FOO=bar BAZ=qux ls -la"),
228            PrefixResult::Prefix("FOO=bar BAZ=qux ls".into())
229        );
230    }
231}