telos_agent/tools/command_security/bash/
prefix.rs1use super::parser::{self, Node};
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum PrefixResult {
13 Prefix(String),
15 None,
17 NeedsReview,
19}
20
21pub 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 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 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 if prefix_tokens.len() == 1
103 && matches!(token, "run" | "test" | "start" | "build" | "lint")
104 {
105 return true;
106 }
107 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 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(¤t);
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}