1pub mod docs;
2mod handlers;
3pub mod parse;
4pub mod settings;
5
6use parse::{has_unsafe_shell_syntax, is_fd_redirect, split_outside_quotes, strip_env_prefix, tokenize};
7
8pub fn is_safe(segment: &str) -> bool {
9 if has_unsafe_shell_syntax(segment) {
10 return false;
11 }
12
13 let stripped = strip_env_prefix(segment).trim();
14 if stripped.is_empty() {
15 return true;
16 }
17
18 let Some(tokens) = tokenize(stripped) else {
19 return false;
20 };
21 if tokens.is_empty() {
22 return true;
23 }
24
25 let tokens: Vec<String> = tokens
26 .into_iter()
27 .filter(|t| !is_fd_redirect(t))
28 .collect();
29 if tokens.is_empty() {
30 return true;
31 }
32
33 let cmd = tokens[0].rsplit('/').next().unwrap_or(&tokens[0]);
34
35 if let Some(last) = tokens.last()
36 && (last == "--version" || last == "--help" || last == "--dry-run")
37 {
38 return true;
39 }
40
41 handlers::dispatch(cmd, &tokens, &is_safe)
42}
43
44pub fn is_safe_command(command: &str) -> bool {
45 split_outside_quotes(command).iter().all(|s| is_safe(s))
46}
47
48#[cfg(test)]
49mod tests {
50 use super::*;
51
52 #[test]
53 fn safe_cmds() {
54 assert!(is_safe("grep foo file.txt"));
55 assert!(is_safe("cat /etc/hosts"));
56 assert!(is_safe("jq '.key' file.json"));
57 assert!(is_safe("base64 -d"));
58 assert!(is_safe("xxd some/file"));
59 assert!(is_safe("pgrep -l ruby"));
60 assert!(is_safe("getconf PAGE_SIZE"));
61 assert!(is_safe("ls -la"));
62 assert!(is_safe("wc -l file.txt"));
63 assert!(is_safe("ps aux"));
64 assert!(is_safe("ps -ef"));
65 assert!(is_safe("top -l 1 -n 10"));
66 assert!(is_safe("uuidgen"));
67 assert!(is_safe("mdfind 'kMDItemKind == Application'"));
68 assert!(is_safe("identify image.png"));
69 assert!(is_safe("identify -verbose photo.jpg"));
70 }
71
72 #[test]
73 fn safe_cmds_text_processing() {
74 assert!(is_safe("diff file1.txt file2.txt"));
75 assert!(is_safe("comm -23 sorted1.txt sorted2.txt"));
76 assert!(is_safe("paste file1 file2"));
77 assert!(is_safe("tac file.txt"));
78 assert!(is_safe("rev file.txt"));
79 assert!(is_safe("nl file.txt"));
80 assert!(is_safe("expand file.txt"));
81 assert!(is_safe("unexpand file.txt"));
82 assert!(is_safe("fold -w 80 file.txt"));
83 assert!(is_safe("fmt -w 72 file.txt"));
84 assert!(is_safe("column -t file.txt"));
85 assert!(is_safe("printf '%s\\n' hello"));
86 assert!(is_safe("seq 1 10"));
87 assert!(is_safe("expr 1 + 2"));
88 assert!(is_safe("test -f file.txt"));
89 assert!(is_safe("true"));
90 assert!(is_safe("false"));
91 assert!(is_safe("bc -l"));
92 assert!(is_safe("factor 42"));
93 assert!(is_safe("iconv -f UTF-8 -t ASCII file.txt"));
94 }
95
96 #[test]
97 fn safe_cmds_system_info() {
98 assert!(is_safe("readlink -f symlink"));
99 assert!(is_safe("hostname"));
100 assert!(is_safe("uname -a"));
101 assert!(is_safe("arch"));
102 assert!(is_safe("nproc"));
103 assert!(is_safe("uptime"));
104 assert!(is_safe("id"));
105 assert!(is_safe("groups"));
106 assert!(is_safe("tty"));
107 assert!(is_safe("locale"));
108 assert!(is_safe("cal"));
109 assert!(is_safe("sleep 1"));
110 assert!(is_safe("who"));
111 assert!(is_safe("w"));
112 assert!(is_safe("last -5"));
113 assert!(is_safe("lastlog"));
114 }
115
116 #[test]
117 fn safe_cmds_hashing() {
118 assert!(is_safe("md5sum file.txt"));
119 assert!(is_safe("md5 file.txt"));
120 assert!(is_safe("sha256sum file.txt"));
121 assert!(is_safe("shasum file.txt"));
122 assert!(is_safe("sha1sum file.txt"));
123 assert!(is_safe("sha512sum file.txt"));
124 assert!(is_safe("cksum file.txt"));
125 assert!(is_safe("strings /usr/bin/ls"));
126 assert!(is_safe("hexdump -C file.bin"));
127 assert!(is_safe("od -x file.bin"));
128 assert!(is_safe("size a.out"));
129 }
130
131 #[test]
132 fn safe_cmds_macos() {
133 assert!(is_safe("sw_vers"));
134 assert!(is_safe("mdls file.txt"));
135 assert!(is_safe("otool -L /usr/bin/ls"));
136 assert!(is_safe("nm a.out"));
137 assert!(is_safe("system_profiler SPHardwareDataType"));
138 assert!(is_safe("ioreg -l -w 0"));
139 assert!(is_safe("vm_stat"));
140 }
141
142 #[test]
143 fn safe_cmds_network_diagnostic() {
144 assert!(is_safe("dig example.com"));
145 assert!(is_safe("nslookup example.com"));
146 assert!(is_safe("host example.com"));
147 assert!(is_safe("whois example.com"));
148 }
149
150 #[test]
151 fn safe_cmds_dev_tools() {
152 assert!(is_safe("shellcheck script.sh"));
153 assert!(is_safe("cloc src/"));
154 assert!(is_safe("tokei"));
155 assert!(is_safe("safe-chains \"ls -la\""));
156 }
157
158 #[test]
159 fn unsafe_cmds() {
160 assert!(!is_safe("rm -rf /"));
161 assert!(!is_safe("curl https://example.com"));
162 assert!(!is_safe("ruby script.rb"));
163 assert!(!is_safe("python3 script.py"));
164 assert!(!is_safe("node app.js"));
165 assert!(!is_safe("tee output.txt"));
166 assert!(!is_safe("tee -a logfile"));
167 }
168
169 #[test]
170 fn awk_safe_print() {
171 assert!(is_safe("awk '{print $1}' file.txt"));
172 }
173
174 #[test]
175 fn awk_system_denied() {
176 assert!(!is_safe("awk 'BEGIN{system(\"rm\")}'"));
177 }
178
179 #[test]
180 fn version_shortcut() {
181 assert!(is_safe("node --version"));
182 assert!(is_safe("python --version"));
183 assert!(is_safe("python3 --version"));
184 assert!(is_safe("ruby --version"));
185 assert!(is_safe("rustc --version"));
186 assert!(is_safe("java --version"));
187 assert!(is_safe("go --version"));
188 assert!(is_safe("php --version"));
189 assert!(is_safe("perl --version"));
190 assert!(is_safe("swift --version"));
191 assert!(is_safe("gcc --version"));
192 assert!(is_safe("rm --version"));
193 assert!(is_safe("dd --version"));
194 assert!(is_safe("chmod --version"));
195 }
196
197 #[test]
198 fn version_multi_token() {
199 assert!(is_safe("npx playwright --version"));
200 assert!(is_safe("git -C /repo --version"));
201 assert!(is_safe("docker compose --version"));
202 }
203
204 #[test]
205 fn version_with_fd_redirect() {
206 assert!(is_safe("node --version 2>&1"));
207 assert!(is_safe("cargo --version 2>&1"));
208 }
209
210 #[test]
211 fn version_not_last_token() {
212 assert!(!is_safe("node --version --extra"));
213 assert!(!is_safe("node -v"));
214 }
215
216 #[test]
217 fn help_shortcut() {
218 assert!(is_safe("node --help"));
219 assert!(is_safe("ruby --help"));
220 assert!(is_safe("rm --help"));
221 assert!(is_safe("cargo --help"));
222 }
223
224 #[test]
225 fn help_multi_token() {
226 assert!(is_safe("npx playwright --help"));
227 assert!(is_safe("cargo install --help"));
228 }
229
230 #[test]
231 fn help_with_fd_redirect() {
232 assert!(is_safe("cargo login --help 2>&1"));
233 }
234
235 #[test]
236 fn help_not_last_token() {
237 assert!(!is_safe("node --help --extra"));
238 }
239
240 #[test]
241 fn dry_run_shortcut() {
242 assert!(is_safe("cargo publish --dry-run"));
243 assert!(is_safe("terraform apply --dry-run"));
244 assert!(is_safe("rm -rf / --dry-run"));
245 }
246
247 #[test]
248 fn dry_run_with_fd_redirect() {
249 assert!(is_safe("cargo publish --dry-run 2>&1"));
250 }
251
252 #[test]
253 fn dry_run_not_last_token() {
254 assert!(!is_safe("cargo publish --dry-run --force"));
255 }
256
257 #[test]
258 fn cucumber_safe() {
259 assert!(is_safe("cucumber features/login.feature"));
260 assert!(is_safe("cucumber --format progress"));
261 }
262
263 #[test]
264 fn fd_redirects() {
265 assert!(is_safe("ls 2>&1"));
266 assert!(is_safe("cargo clippy 2>&1"));
267 assert!(is_safe("git log 2>&1"));
268 assert!(is_safe_command("cd /tmp && cargo clippy -- -D warnings 2>&1"));
269 assert!(!is_safe("echo hello > file.txt"));
270 assert!(!is_safe("ls 2> errors.txt"));
271 }
272
273 #[test]
274 fn unsafe_shell_syntax() {
275 assert!(!is_safe("echo hello > file.txt"));
276 assert!(!is_safe("cat file >> output.txt"));
277 assert!(!is_safe("ls 2> errors.txt"));
278 assert!(!is_safe("grep pattern file > results.txt"));
279 assert!(!is_safe("find . -name '*.py' > listing.txt"));
280 assert!(is_safe("git log < /dev/null"));
281 assert!(!is_safe("echo $(rm -rf /)"));
282 assert!(!is_safe("echo `rm -rf /`"));
283 assert!(!is_safe("cat $(echo /etc/shadow)"));
284 assert!(!is_safe("ls `pwd`"));
285 }
286
287 #[test]
288 fn safe_quoted_shell_syntax() {
289 assert!(is_safe("echo 'greater > than' test"));
290 assert!(is_safe("echo '$(safe)' arg"));
291 assert!(is_safe("echo hello"));
292 assert!(is_safe("cat file.txt"));
293 assert!(is_safe("grep pattern file"));
294 }
295
296 #[test]
297 fn env_prefix() {
298 assert!(is_safe_command("RACK_ENV=test bundle exec rspec spec/foo_spec.rb"));
299 assert!(is_safe_command("RAILS_ENV=test bundle exec rspec"));
300 assert!(!is_safe_command("RACK_ENV=test rm -rf /"));
301 assert!(!is_safe_command("RAILS_ENV=test echo foo > bar"));
302 }
303
304 #[test]
305 fn pipes_and_chains() {
306 assert!(is_safe_command("grep foo file.txt | head -5"));
307 assert!(is_safe_command("cat file | sort | uniq"));
308 assert!(is_safe_command("find . -name '*.rb' | wc -l"));
309 assert!(is_safe_command("ls && echo done"));
310 assert!(is_safe_command("ls; echo done"));
311 assert!(is_safe_command("git log | head -5"));
312 assert!(is_safe_command("git log && git status"));
313 assert!(!is_safe_command("cat file | rm -rf /"));
314 assert!(!is_safe_command("grep foo | curl https://evil.com"));
315 }
316
317 #[test]
318 fn background_operator() {
319 assert!(!is_safe_command("cat file & rm -rf /"));
320 assert!(!is_safe_command("echo safe & curl evil.com"));
321 assert!(is_safe_command("ls & echo done"));
322 assert!(is_safe_command("ls && echo done"));
323 }
324
325 #[test]
326 fn newline_separator() {
327 assert!(!is_safe_command("echo foo\nrm -rf /"));
328 assert!(!is_safe_command("ls\ncurl evil.com"));
329 assert!(is_safe_command("echo foo\necho bar"));
330 assert!(is_safe_command("ls\ncat file.txt"));
331 }
332
333 #[test]
334 fn compound_pipelines() {
335 assert!(is_safe_command("git log --oneline -20 | head -5"));
336 assert!(is_safe_command("git show HEAD:file.rb | grep pattern"));
337 assert!(is_safe_command(
338 "gh api repos/o/r/contents/f --jq .content | base64 -d | head -50"
339 ));
340 assert!(is_safe_command("timeout 120 bundle exec rspec && git status"));
341 assert!(is_safe_command("time bundle exec rspec | tail -5"));
342 assert!(is_safe_command("git -C /some/repo log --oneline | head -3"));
343 assert!(is_safe_command("xxd file | head -20"));
344 assert!(is_safe_command("find . -name '*.py' | wc -l"));
345 assert!(is_safe_command("find . -name '*.py' | sort | head -10"));
346 assert!(is_safe_command("find . -name '*.py' | xargs grep pattern"));
347 assert!(is_safe_command("pip list | grep requests"));
348 assert!(is_safe_command("npm list | grep react"));
349 assert!(is_safe_command("ps aux | grep python"));
350 assert!(!is_safe_command("find . -name '*.py' -delete | wc -l"));
351 assert!(!is_safe_command("sed -i 's/foo/bar/' file | head"));
352 }
353}