1use serde::{Deserialize, Serialize};
9use std::fmt;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
13pub enum AutonomyLevel {
14 #[serde(rename = "Manual")]
16 Manual,
17 #[serde(rename = "Semi-Auto")]
19 #[default]
20 SemiAuto,
21 #[serde(rename = "Auto")]
23 Auto,
24}
25
26impl fmt::Display for AutonomyLevel {
27 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28 match self {
29 AutonomyLevel::Manual => write!(f, "Manual"),
30 AutonomyLevel::SemiAuto => write!(f, "Semi-Auto"),
31 AutonomyLevel::Auto => write!(f, "Auto"),
32 }
33 }
34}
35
36impl AutonomyLevel {
37 pub fn from_str_loose(s: &str) -> Option<Self> {
39 match s.to_lowercase().as_str() {
40 "manual" => Some(Self::Manual),
41 "semi-auto" | "semiauto" | "semi" => Some(Self::SemiAuto),
42 "auto" | "full" => Some(Self::Auto),
43 _ => None,
44 }
45 }
46}
47
48pub const SAFE_COMMANDS: &[&str] = &[
53 "cd",
55 "ls",
56 "cat",
57 "head",
58 "tail",
59 "grep",
60 "find",
61 "wc",
62 "pwd",
63 "echo",
64 "which",
65 "type",
66 "file",
67 "stat",
68 "du",
69 "df",
70 "tree",
71 "diff",
72 "md5sum",
73 "sha256sum",
74 "readlink",
75 "basename",
76 "dirname",
77 "realpath",
78 "sort",
79 "uniq",
80 "cut",
81 "awk",
82 "sed",
83 "tr",
84 "jq",
85 "yq",
86 "column",
87 "hexdump",
88 "xxd",
89 "strings",
90 "nm",
91 "objdump",
92 "ldd",
93 "tar tf",
94 "zip -l",
95 "unzip -l",
96 "git status",
98 "git log",
99 "git diff",
100 "git branch",
101 "git show",
102 "git remote",
103 "git tag",
104 "git stash list",
105 "git blame",
106 "git rev-parse",
107 "git ls-files",
108 "git config --get",
109 "git stash show",
110 "git shortlog",
111 "git describe",
112 "cargo check",
114 "cargo build",
115 "cargo test",
116 "cargo clippy",
117 "cargo fmt",
118 "cargo doc",
119 "cargo add",
120 "cargo update",
121 "cargo install",
122 "npm run",
123 "npm test",
124 "npm ci",
125 "npm install",
126 "npm list",
127 "npm outdated",
128 "npx",
129 "yarn install",
130 "yarn list",
131 "pnpm install",
132 "bun install",
133 "make",
134 "cmake",
135 "ninja",
136 "go build",
137 "go test",
138 "go vet",
139 "go get",
140 "go mod tidy",
141 "go mod download",
142 "pip install",
143 "pip list",
144 "pip show",
145 "pip freeze",
146 "pipenv install",
147 "poetry install",
148 "poetry show",
149 "gem install",
150 "gem list",
151 "bundle install",
152 "bundle list",
153 "composer install",
154 "composer show",
155 "brew install",
156 "brew list",
157 "brew info",
158 "bazel build",
159 "bazel test",
160 "gradle build",
161 "gradle test",
162 "mvn compile",
163 "mvn test",
164 "sbt compile",
165 "sbt test",
166 "python --version",
168 "python3 --version",
169 "node --version",
170 "npm --version",
171 "cargo --version",
172 "go version",
173 "ruby --version",
174 "ruby -v",
175 "java --version",
176 "javac --version",
177 "dotnet --version",
178 "php --version",
179 "perl --version",
180 "swift --version",
181 "kotlin -version",
182 "scala -version",
183 "elixir --version",
184 "lua -v",
185 "deno --version",
186 "bun --version",
187 "rustc --version",
188 "rustup show",
189 "eslint",
191 "prettier",
192 "black",
193 "ruff",
194 "flake8",
195 "mypy",
196 "pylint",
197 "rubocop",
198 "gofmt",
199 "golangci-lint",
200 "shellcheck",
201 "tsc",
202 "biome",
203 "pytest",
205 "jest",
206 "vitest",
207 "mocha",
208 "rspec",
209 "phpunit",
210 "dotnet test",
211 "flutter test",
212 "docker ps",
214 "docker images",
215 "docker logs",
216 "docker inspect",
217 "docker compose ps",
218 "kubectl get",
219 "kubectl describe",
220 "kubectl logs",
221 "gh pr list",
222 "gh pr view",
223 "gh issue list",
224 "gh issue view",
225 "gh run list",
226 "gh run view",
227 "terraform plan",
228 "terraform show",
229 "uname",
231 "env",
232 "printenv",
233 "whoami",
234 "hostname",
235 "date",
236 "uptime",
237 "id",
238 "lsof",
239 "netstat",
240 "ss",
241 "dig",
242 "nslookup",
243 "ping",
244 "traceroute",
245 "ifconfig",
246 "ip addr",
247 "ps",
248 "pgrep",
249 "free",
250 "vmstat",
251 "iostat",
252 "top -l 1",
253 "curl",
254 "wget",
255];
256
257pub fn is_safe_command(command: &str) -> bool {
265 let trimmed = command.trim();
266 if trimmed.is_empty() {
267 return false;
268 }
269
270 if contains_shell_injection(trimmed) {
272 return false;
273 }
274
275 let segments = split_shell_segments(trimmed);
277 if segments.is_empty() {
278 return false;
279 }
280 segments.iter().all(|seg| is_segment_safe(seg))
281}
282
283fn contains_shell_injection(cmd: &str) -> bool {
285 if cmd.contains("$(") || cmd.contains('`') {
287 return true;
288 }
289 if cmd.contains("<(") || cmd.contains(">(") {
291 return true;
292 }
293 if contains_file_redirect(cmd) {
295 return true;
296 }
297 false
298}
299
300fn contains_file_redirect(cmd: &str) -> bool {
303 let bytes = cmd.as_bytes();
304 let len = bytes.len();
305 let mut i = 0;
306 while i < len {
307 if bytes[i] == b'\'' {
308 i += 1;
309 while i < len && bytes[i] != b'\'' {
310 i += 1;
311 }
312 i += 1;
313 continue;
314 }
315 if bytes[i] == b'"' {
316 i += 1;
317 while i < len {
318 if bytes[i] == b'\\' && i + 1 < len {
319 i += 2;
320 continue;
321 }
322 if bytes[i] == b'"' {
323 break;
324 }
325 i += 1;
326 }
327 i += 1;
328 continue;
329 }
330 if bytes[i] == b'>' {
331 if i + 1 < len && bytes[i + 1] == b'&' {
332 i += 2;
333 continue;
334 }
335 if i > 0 && bytes[i - 1].is_ascii_digit() && i + 1 < len && bytes[i + 1] == b'&' {
336 i += 2;
337 continue;
338 }
339 return true;
340 }
341 i += 1;
342 }
343 false
344}
345
346fn split_shell_segments(cmd: &str) -> Vec<&str> {
348 let mut segments = Vec::new();
349 let mut start = 0;
350 let bytes = cmd.as_bytes();
351 let len = bytes.len();
352 let mut i = 0;
353
354 while i < len {
355 if bytes[i] == b'\'' {
356 i += 1;
357 while i < len && bytes[i] != b'\'' {
358 i += 1;
359 }
360 i += 1;
361 continue;
362 }
363 if bytes[i] == b'"' {
364 i += 1;
365 while i < len {
366 if bytes[i] == b'\\' && i + 1 < len {
367 i += 2;
368 continue;
369 }
370 if bytes[i] == b'"' {
371 break;
372 }
373 i += 1;
374 }
375 i += 1;
376 continue;
377 }
378
379 if i + 1 < len
380 && ((bytes[i] == b'&' && bytes[i + 1] == b'&')
381 || (bytes[i] == b'|' && bytes[i + 1] == b'|'))
382 {
383 let seg = cmd[start..i].trim();
384 if !seg.is_empty() {
385 segments.push(seg);
386 }
387 i += 2;
388 start = i;
389 continue;
390 }
391 if bytes[i] == b';' || (bytes[i] == b'|' && (i + 1 >= len || bytes[i + 1] != b'|')) {
392 let seg = cmd[start..i].trim();
393 if !seg.is_empty() {
394 segments.push(seg);
395 }
396 i += 1;
397 start = i;
398 continue;
399 }
400 i += 1;
401 }
402
403 let seg = cmd[start..].trim();
404 if !seg.is_empty() {
405 segments.push(seg);
406 }
407 segments
408}
409
410fn is_segment_safe(segment: &str) -> bool {
412 let normalized = normalize_segment(segment);
413 if normalized.is_empty() {
414 return false;
415 }
416 let cmd_lower = normalized.to_lowercase();
417 SAFE_COMMANDS.iter().any(|safe| {
418 let safe_lower = safe.to_lowercase();
419 cmd_lower == safe_lower || cmd_lower.starts_with(&format!("{safe_lower} "))
420 })
421}
422
423fn normalize_segment(segment: &str) -> String {
425 let mut parts: Vec<&str> = segment.split_whitespace().collect();
426 if parts.is_empty() {
427 return String::new();
428 }
429
430 while !parts.is_empty() && is_env_assignment(parts[0]) {
431 parts.remove(0);
432 }
433 if parts.is_empty() {
434 return String::new();
435 }
436
437 if let Some(basename) = parts[0].rsplit('/').next()
438 && !basename.is_empty()
439 {
440 parts[0] = basename;
441 }
442
443 parts.join(" ")
444}
445
446fn is_env_assignment(token: &str) -> bool {
448 if let Some(eq_pos) = token.find('=') {
449 if eq_pos == 0 {
450 return false;
451 }
452 let name = &token[..eq_pos];
453 let mut chars = name.chars();
454 if let Some(first) = chars.next()
455 && (first.is_ascii_alphabetic() || first == '_')
456 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
457 {
458 return true;
459 }
460 }
461 false
462}
463
464const MULTI_WORD_TOOLS: &[&str] = &[
466 "cargo",
467 "git",
468 "npm",
469 "yarn",
470 "pnpm",
471 "go",
472 "pip",
473 "pipenv",
474 "poetry",
475 "gem",
476 "bundle",
477 "composer",
478 "brew",
479 "bazel",
480 "gradle",
481 "mvn",
482 "sbt",
483 "docker",
484 "kubectl",
485 "gh",
486 "terraform",
487 "dotnet",
488 "flutter",
489];
490
491pub fn extract_command_prefix(command: &str) -> String {
497 let parts: Vec<&str> = command.split_whitespace().collect();
498 if parts.is_empty() {
499 return String::new();
500 }
501
502 let mut start = 0;
503 while start < parts.len() && is_env_assignment(parts[start]) {
504 start += 1;
505 }
506
507 if start >= parts.len() {
508 return String::new();
509 }
510
511 let binary = parts[start].rsplit('/').next().unwrap_or(parts[start]);
512
513 let bin_lower = binary.to_lowercase();
514 if MULTI_WORD_TOOLS.contains(&bin_lower.as_str())
515 && start + 1 < parts.len()
516 && !parts[start + 1].starts_with('-')
517 {
518 return format!("{} {}", binary, parts[start + 1]);
519 }
520
521 binary.to_string()
522}
523
524#[cfg(test)]
525#[path = "constants_tests.rs"]
526mod tests;