Skip to main content

elizaos_plugin_shell/
path_utils.rs

1#![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}