elizaos_plugin_shell/
path_utils.rs1#![allow(missing_docs)]
2
3use regex::Regex;
4use std::path::{Path, PathBuf};
5use tracing::warn;
6
7pub const DEFAULT_FORBIDDEN_COMMANDS: &[&str] = &[
8 "rm -rf /",
9 "rmdir",
10 "chmod 777",
11 "chown",
12 "chgrp",
13 "shutdown",
14 "reboot",
15 "halt",
16 "poweroff",
17 "kill -9",
18 "killall",
19 "pkill",
20 "sudo rm -rf",
21 "su",
22 "passwd",
23 "useradd",
24 "userdel",
25 "groupadd",
26 "groupdel",
27 "format",
28 "fdisk",
29 "mkfs",
30 "dd if=/dev/zero",
31 "shred",
32 ":(){:|:&};:",
33];
34
35pub fn validate_path(
36 command_path: &str,
37 allowed_dir: &Path,
38 current_dir: &Path,
39) -> Option<PathBuf> {
40 let resolved_path = if Path::new(command_path).is_absolute() {
41 PathBuf::from(command_path)
42 } else {
43 current_dir.join(command_path)
44 };
45
46 let normalized = match resolved_path.canonicalize() {
47 Ok(p) => p,
48 Err(_) => normalize_path(&resolved_path),
49 };
50
51 let normalized_allowed = match allowed_dir.canonicalize() {
52 Ok(p) => p,
53 Err(_) => normalize_path(allowed_dir),
54 };
55
56 if normalized.starts_with(&normalized_allowed) {
57 Some(normalized)
58 } else {
59 warn!(
60 "Path validation failed: {} is outside allowed directory {}",
61 normalized.display(),
62 normalized_allowed.display()
63 );
64 None
65 }
66}
67
68fn normalize_path(path: &Path) -> PathBuf {
69 let mut components = Vec::new();
70
71 for component in path.components() {
72 match component {
73 std::path::Component::ParentDir => {
74 components.pop();
75 }
76 std::path::Component::CurDir => {}
77 c => components.push(c),
78 }
79 }
80
81 components.iter().collect()
82}
83
84pub fn is_safe_command(command: &str) -> bool {
85 let path_traversal_patterns = [r"\.\./", r"\.\.\\", r"/\.\.", r"\\\.\."];
86
87 let dangerous_patterns = [
88 r"\$\(",
89 r"`[^']*`",
90 r"\|\s*sudo",
91 r";\s*sudo",
92 r"&\s*&",
93 r"\|\s*\|",
94 ];
95
96 for pattern in &path_traversal_patterns {
97 if let Ok(re) = Regex::new(pattern) {
98 if re.is_match(command) {
99 warn!("Path traversal detected in command: {}", command);
100 return false;
101 }
102 }
103 }
104
105 for pattern in &dangerous_patterns {
106 if let Ok(re) = Regex::new(pattern) {
107 if re.is_match(command) {
108 warn!("Dangerous pattern detected in command: {}", command);
109 return false;
110 }
111 }
112 }
113
114 let pipe_count = command.matches('|').count();
115 if pipe_count > 1 {
116 warn!("Multiple pipes detected in command: {}", command);
117 return false;
118 }
119
120 true
121}
122
123pub fn extract_base_command(full_command: &str) -> &str {
124 full_command.split_whitespace().next().unwrap_or("")
125}
126
127pub fn is_forbidden_command(command: &str, forbidden_commands: &[String]) -> bool {
128 let normalized_command = command.trim().to_lowercase();
129
130 for forbidden in forbidden_commands {
131 let forbidden_lower = forbidden.to_lowercase();
132
133 if normalized_command.starts_with(&forbidden_lower) {
134 return true;
135 }
136
137 if !forbidden.contains(' ') {
138 let base_command = extract_base_command(command).to_lowercase();
139 if base_command == forbidden_lower {
140 return true;
141 }
142 }
143 }
144
145 false
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151
152 #[test]
153 fn test_is_safe_command_allows_safe() {
154 assert!(is_safe_command("ls -la"));
155 assert!(is_safe_command("echo hello"));
156 assert!(is_safe_command("pwd"));
157 assert!(is_safe_command("touch newfile.txt"));
158 assert!(is_safe_command("mkdir newdir"));
159 }
160
161 #[test]
162 fn test_is_safe_command_rejects_traversal() {
163 assert!(!is_safe_command("cd ../.."));
164 assert!(!is_safe_command("ls ../../../etc"));
165 }
166
167 #[test]
168 fn test_is_safe_command_rejects_dangerous() {
169 assert!(!is_safe_command("echo $(malicious)"));
170 assert!(!is_safe_command("ls | grep test | wc -l"));
171 assert!(!is_safe_command("cmd1 && cmd2"));
172 assert!(!is_safe_command("cmd1 || cmd2"));
173 }
174
175 #[test]
176 fn test_extract_base_command() {
177 assert_eq!(extract_base_command("ls -la"), "ls");
178 assert_eq!(extract_base_command("git status"), "git");
179 assert_eq!(extract_base_command(" npm test "), "npm");
180 assert_eq!(extract_base_command(""), "");
181 }
182
183 #[test]
184 fn test_is_forbidden_command() {
185 let forbidden = vec![
186 "rm -rf /".to_string(),
187 "shutdown".to_string(),
188 "chmod 777".to_string(),
189 ];
190
191 assert!(is_forbidden_command("rm -rf /", &forbidden));
192 assert!(is_forbidden_command("shutdown now", &forbidden));
193 assert!(is_forbidden_command("chmod 777 /etc", &forbidden));
194
195 assert!(!is_forbidden_command("rm file.txt", &forbidden));
196 assert!(!is_forbidden_command("ls -la", &forbidden));
197 assert!(!is_forbidden_command("chmod 644 file", &forbidden));
198 }
199
200 #[test]
201 fn test_is_forbidden_case_insensitive() {
202 let forbidden = vec!["shutdown".to_string()];
203 assert!(is_forbidden_command("SHUTDOWN", &forbidden));
204 assert!(is_forbidden_command("Shutdown", &forbidden));
205 }
206}