telos_agent/tools/command_security/bash/
analyzer.rs1use 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#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum CommandSafety {
18 Safe,
20 NeedsReview { reason: String },
23}
24
25impl CommandSafety {
26 pub fn is_safe(&self) -> bool {
27 matches!(self, CommandSafety::Safe)
28 }
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum SecurityAnalysis {
34 Simple { commands: Vec<parser::SimpleCommand> },
35 TooComplex { reason: String },
36 ParseUnavailable,
37}
38
39pub 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
58pub 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 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
127pub 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
153pub 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 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}