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