Skip to main content

safe_chains/handlers/
mod.rs

1macro_rules! handler_module {
2    ($($sub:ident),+ $(,)?) => {
3        $(mod $sub;)+
4
5        pub(crate) fn dispatch(cmd: &str, tokens: &[crate::parse::Token]) -> Option<crate::verdict::Verdict> {
6            None$(.or_else(|| $sub::dispatch(cmd, tokens)))+
7        }
8
9        pub fn command_docs() -> Vec<crate::docs::CommandDoc> {
10            let mut docs = Vec::new();
11            $(docs.extend($sub::command_docs());)+
12            docs
13        }
14
15        #[cfg(test)]
16        pub(super) fn full_registry() -> Vec<&'static super::CommandEntry> {
17            let mut v = Vec::new();
18            $(v.extend($sub::REGISTRY);)+
19            v
20        }
21    };
22}
23
24pub mod android;
25pub mod coreutils;
26pub mod forges;
27pub mod fuzzy;
28pub mod jvm;
29pub mod magick;
30pub mod network;
31pub mod node;
32pub mod perl;
33pub mod php;
34pub mod ruby;
35pub mod shell;
36pub mod system;
37pub mod vcs;
38pub mod wrappers;
39
40use std::collections::HashMap;
41
42use crate::parse::Token;
43use crate::verdict::Verdict;
44
45type HandlerFn = fn(&[Token]) -> Verdict;
46
47pub fn custom_cmd_handlers() -> HashMap<&'static str, HandlerFn> {
48    HashMap::from([
49        ("magick", magick::is_safe_magick as HandlerFn),
50        ("php", php::is_safe_php as HandlerFn),
51        ("sysctl", system::sysctl::is_safe_sysctl as HandlerFn),
52    ])
53}
54
55pub fn custom_sub_handlers() -> HashMap<&'static str, HandlerFn> {
56    HashMap::from([
57        ("bun_x", node::bun::check_bun_x as HandlerFn),
58        ("bundle_config", ruby::bundle::check_bundle_config as HandlerFn),
59        ("bundle_exec", ruby::bundle::check_bundle_exec as HandlerFn),
60        ("git_remote", vcs::git::check_git_remote as HandlerFn),
61        ("laravel_cache_clear", php::check_laravel_cache_clear as HandlerFn),
62        ("plutil_convert", system::plutil::check_plutil_convert as HandlerFn),
63    ])
64}
65
66pub fn dispatch(tokens: &[Token]) -> Verdict {
67    let cmd = tokens[0].command_name();
68    None
69        .or_else(|| shell::dispatch(cmd, tokens))
70        .or_else(|| wrappers::dispatch(cmd, tokens))
71        .or_else(|| forges::dispatch(cmd, tokens))
72        .or_else(|| node::dispatch(cmd, tokens))
73        .or_else(|| jvm::dispatch(cmd, tokens))
74        .or_else(|| android::dispatch(cmd, tokens))
75        .or_else(|| network::dispatch(cmd, tokens))
76        .or_else(|| system::dispatch(cmd, tokens))
77        .or_else(|| perl::dispatch(cmd, tokens))
78        .or_else(|| coreutils::dispatch(cmd, tokens))
79        .or_else(|| fuzzy::dispatch(cmd, tokens))
80        .or_else(|| vcs::dispatch(cmd, tokens))
81        .or_else(|| crate::registry::toml_dispatch(tokens))
82        .unwrap_or(Verdict::Denied)
83}
84
85#[cfg(test)]
86const HANDLED_CMDS: &[&str] = &[
87    "sh", "bash", "xargs", "timeout", "time", "env", "nice", "ionice", "hyperfine", "dotenv", "jai",
88    "git", "jj", "gh", "glab", "jjpr", "tea", "basecamp",
89    "jira", "linear", "notion", "td", "todoist", "trello",
90    "npm", "yarn", "pnpm", "bun", "deno", "npx", "bunx", "nvm", "fnm", "volta", "mocha",
91    "ruby", "ri", "bundle", "gem", "importmap", "rails", "rbenv", "rvm", "brakeman", "rspec",
92    "standardrb", "erb_lint", "erblint", "herb",
93    "reek", "flay", "flog", "fasterer", "haml-lint", "slim-lint",
94    "bundler-audit", "bundle-audit", "ruby-audit", "rdoc", "yard", "yardoc", "rubycritic",
95    "annotaterb", "annotate", "jekyll", "bridgetown", "middleman", "foreman", "guard",
96    "spring", "overcommit", "pry", "byebug", "thor", "m", "rake", "sdoc", "license_finder",
97    "danger", "kamal", "mutant", "whenever", "haml", "slimrb", "railroady", "erd",
98    "parallel_test", "parallel_rspec", "parallel_cucumber", "parallel_spinach",
99    "racc", "rex",
100    "steep", "srb", "rbs", "typeprof", "stree", "rufo", "packwerk", "debride",
101    "i18n-tasks", "asciidoctor", "kramdown", "dawn", "fpm", "stackprof",
102    "pip", "pip3", "uv", "poetry", "pyenv", "conda", "coverage", "tox", "nox", "bandit", "pip-audit", "pdm",
103    "cargo", "rustup",
104    "go",
105    "gradle", "gradlew", "mvn", "mvnw", "ktlint", "detekt",
106    "javap", "jar", "keytool", "jarsigner", "jenv", "sdk",
107    "adb", "apkanalyzer", "apksigner", "bundletool", "aapt2",
108    "emulator", "avdmanager", "sdkmanager", "zipalign", "lint",
109    "fastlane", "firebase",
110    "artisan", "composer", "craft", "pest", "phpstan", "phpunit", "please", "valet",
111    "swift",
112    "dotnet",
113    "curl",
114    "docker", "podman", "kubectl", "orbctl", "orb", "qemu-img", "helm", "skopeo", "crane", "cosign", "kustomize", "stern", "kubectx", "kubens", "kind", "minikube",
115    "ollama", "llm", "hf", "claude", "aider", "codex", "opencode", "vibe",
116    "ddev", "dcli",
117    "brew", "mise", "asdf", "crontab", "defaults", "pmset", "sysctl", "cmake", "psql", "pg_isready",
118    "pg_dump", "bazel", "meson", "ninja",
119    "terraform", "heroku", "vercel", "fly", "flyctl", "pulumi", "netlify", "railway", "render",
120    "northflank", "porter", "platform", "upsun", "koyeb", "scalingo", "clever",
121    "cx", "hey", "wrangler", "cf", "newrelic",
122    "aws", "gcloud", "az",
123    "doctl", "hcloud", "vultr-cli", "exo", "scw", "linode-cli",
124    "ansible-playbook", "ansible-inventory", "ansible-doc", "ansible-config", "ansible-galaxy",
125    "overmind", "tailscale", "tmux", "wg", "systemctl", "journalctl", "zellij",
126    "kafka-topics", "kafka-console-consumer", "kafka-consumer-groups",
127    "monolith",
128    "cloudflared", "ngrok", "ssh",
129    "networksetup", "launchctl", "diskutil", "security", "csrutil", "log",
130    "xcodebuild", "plutil", "xcode-select", "xcrun", "pkgutil", "lipo", "codesign", "spctl",
131    "xcodegen", "tuist", "pod", "swiftlint", "swiftformat", "periphery", "xcbeautify", "agvtool", "simctl",
132    "perl",
133    "R", "Rscript",
134    "grep", "egrep", "fgrep", "rg", "ag", "ack", "zgrep", "zegrep", "zfgrep", "locate", "mlocate", "plocate",
135    "cat", "gzcat", "head", "tail", "wc", "cut", "tr", "uniq", "less", "more", "zcat",
136    "diff", "comm", "paste", "tac", "rev", "nl",
137    "expand", "unexpand", "fold", "fmt", "col", "column", "iconv", "nroff",
138    "echo", "printf", "seq", "test", "[", "expr", "bc", "factor", "bat", "glow",
139    "arch", "command", "hostname",
140    "find", "sed", "shuf", "sort", "yq", "xmllint", "awk", "gawk", "mawk", "nawk",
141    "magick", "convert", "frames",
142    "fd", "eza", "exa", "ls", "delta", "colordiff",
143    "dirname", "basename", "realpath", "readlink",
144    "file", "stat", "du", "df", "tree", "cmp", "zipinfo", "tar", "unzip", "gzip",
145    "true", "false", ":", "shopt",
146    "alias", "break", "continue", "declare", "exit", "export", "hash", "printenv", "read", "type", "typeset", "wait", "whereis", "which", "whoami", "date", "pwd", "cd", "unset",
147    "uname", "nproc", "uptime", "id", "groups", "tty", "locale", "cal", "sleep",
148    "who", "w", "last", "lastlog",
149    "ps", "top", "htop", "iotop", "procs", "dust", "lsof", "pgrep", "pstree", "lsblk", "free", "sample", "kill",
150    "jq", "jaq", "gojq", "fx", "jless", "htmlq", "xq", "tomlq", "mlr", "dasel",
151    "base64", "xxd", "getconf", "uuidgen",
152    "md5sum", "md5", "sha256sum", "shasum", "sha1sum", "sha512sum",
153    "cksum", "b2sum", "sum", "strings", "hexdump", "od", "size", "sips",
154    "sw_vers", "mdls", "otool", "nm", "system_profiler", "ioreg", "vm_stat", "mdfind", "man",
155    "dig", "nslookup", "host", "whois", "netstat", "ss", "ifconfig", "route", "ping",
156    "traceroute", "traceroute6", "mtr", "nc", "ncat", "nmap",
157    "xv",
158    "fzf", "fzy", "peco", "pick", "selecta", "sk", "zf",
159    "identify", "shellcheck", "cloc", "tokei", "cucumber", "branchdiff", "specdiff", "workon", "safe-chains", "snyk", "mdbook", "devbox", "pup",
160    "tldr", "ldd", "objdump", "readelf", "just",
161    "prettier", "black", "ruff", "mypy", "pyright", "pylint", "flake8", "isort",
162    "rubocop", "eslint", "biome", "stylelint", "zoxide",
163    "@herb-tools/linter", "@biomejs/biome", "@commitlint/cli", "@redocly/cli",
164    "@axe-core/cli", "@arethetypeswrong/cli", "@taplo/cli", "@johnnymorganz/stylua-bin",
165    "@shopify/theme-check", "@graphql-inspector/cli", "@apidevtools/swagger-cli",
166    "@astrojs/check", "@changesets/cli",
167    "@stoplight/spectral-cli", "@ibm/openapi-validator", "@openapitools/openapi-generator-cli",
168    "@ls-lint/ls-lint", "@htmlhint/htmlhint", "@manypkg/cli",
169    "@microsoft/api-extractor", "@asyncapi/cli",
170    "svelte-check", "secretlint", "oxlint", "knip", "size-limit",
171    "depcheck", "madge", "license-checker",
172    "pytest", "jest", "vitest", "golangci-lint", "staticcheck", "govulncheck", "semgrep", "next", "turbo", "nx",
173    "direnv", "make", "packer", "vagrant",
174    "node", "python3", "python", "rustc", "java", "php",
175    "gcc", "g++", "cc", "c++", "clang", "clang++",
176    "elixir", "erl", "mix", "zig", "lua", "tsc",
177    "jc", "gron", "difft", "difftastic", "duf", "xsv", "qsv",
178    "git-cliff", "git-lfs", "tig",
179    "trivy", "gitleaks", "grype", "syft", "watchexec", "act",
180];
181
182pub fn handler_docs() -> Vec<crate::docs::CommandDoc> {
183    let mut docs = Vec::new();
184    docs.extend(forges::command_docs());
185    docs.extend(node::command_docs());
186    docs.extend(jvm::command_docs());
187    docs.extend(android::command_docs());
188    docs.extend(network::command_docs());
189    docs.extend(system::command_docs());
190    docs.extend(perl::command_docs());
191    docs.extend(coreutils::command_docs());
192    docs.extend(fuzzy::command_docs());
193    docs.extend(shell::command_docs());
194    docs.extend(wrappers::command_docs());
195    docs.extend(vcs::command_docs());
196    docs.extend(crate::registry::toml_command_docs());
197    docs
198}
199
200#[cfg(test)]
201#[derive(Debug)]
202pub(crate) enum CommandEntry {
203    Positional { cmd: &'static str },
204    Custom { cmd: &'static str, valid_prefix: Option<&'static str> },
205    Paths { cmd: &'static str, bare_ok: bool, paths: &'static [&'static str] },
206    Delegation { cmd: &'static str },
207}
208
209pub fn all_opencode_patterns() -> Vec<String> {
210    let mut patterns = Vec::new();
211    patterns.sort();
212    patterns.dedup();
213    patterns
214}
215
216#[cfg(test)]
217fn full_registry() -> Vec<&'static CommandEntry> {
218    let mut entries = Vec::new();
219    entries.extend(shell::REGISTRY);
220    entries.extend(wrappers::REGISTRY);
221    entries.extend(forges::full_registry());
222    entries.extend(node::full_registry());
223    entries.extend(jvm::full_registry());
224    entries.extend(android::full_registry());
225    entries.extend(network::REGISTRY);
226    entries.extend(system::full_registry());
227    entries.extend(perl::REGISTRY);
228    entries.extend(coreutils::full_registry());
229    entries.extend(fuzzy::full_registry());
230    entries
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use std::collections::HashSet;
237
238    const UNKNOWN_FLAG: &str = "--xyzzy-unknown-42";
239    const UNKNOWN_SUB: &str = "xyzzy-unknown-42";
240
241    fn check_entry(entry: &CommandEntry, failures: &mut Vec<String>) {
242        match entry {
243            CommandEntry::Positional { .. } | CommandEntry::Delegation { .. } => {}
244            CommandEntry::Custom { cmd, valid_prefix } => {
245                let base = valid_prefix.unwrap_or(cmd);
246                let test = format!("{base} {UNKNOWN_FLAG}");
247                if crate::is_safe_command(&test) {
248                    failures.push(format!("{cmd}: accepted unknown flag: {test}"));
249                }
250            }
251            CommandEntry::Paths { cmd, bare_ok, paths } => {
252                if !bare_ok && crate::is_safe_command(cmd) {
253                    failures.push(format!("{cmd}: accepted bare invocation"));
254                }
255                let test = format!("{cmd} {UNKNOWN_SUB}");
256                if crate::is_safe_command(&test) {
257                    failures.push(format!("{cmd}: accepted unknown subcommand: {test}"));
258                }
259                for path in *paths {
260                    let test = format!("{path} {UNKNOWN_FLAG}");
261                    if crate::is_safe_command(&test) {
262                        failures.push(format!("{path}: accepted unknown flag: {test}"));
263                    }
264                }
265            }
266        }
267    }
268
269    #[test]
270    fn all_commands_reject_unknown() {
271        let registry = full_registry();
272        let mut failures = Vec::new();
273        for entry in &registry {
274            check_entry(entry, &mut failures);
275        }
276        assert!(
277            failures.is_empty(),
278            "unknown flags/subcommands accepted:\n{}",
279            failures.join("\n")
280        );
281    }
282
283    #[test]
284    fn process_substitution_safe_inner() {
285        let safe = ["echo <(cat /etc/passwd)", "grep pattern <(ls)", "diff <(sort a.txt) <(sort b.txt)", "comm -23 file.txt <(sort other.txt)"];
286        for cmd in &safe {
287            assert!(crate::is_safe_command(cmd), "safe process substitution rejected: {cmd}");
288        }
289    }
290
291    #[test]
292    fn process_substitution_unsafe_inner() {
293        let unsafe_cmds = ["echo >(rm -rf /)", "diff <(sort a.txt) <(rm -rf /)"];
294        for cmd in &unsafe_cmds {
295            assert!(!crate::is_safe_command(cmd), "unsafe process substitution approved: {cmd}");
296        }
297    }
298
299    #[test]
300    fn registry_covers_handled_commands() {
301        let registry = full_registry();
302        let mut all_cmds: HashSet<&str> = registry
303            .iter()
304            .map(|e| match e {
305                CommandEntry::Positional { cmd }
306                | CommandEntry::Custom { cmd, .. }
307                | CommandEntry::Paths { cmd, .. }
308                | CommandEntry::Delegation { cmd } => *cmd,
309            })
310            .collect();
311        for name in crate::registry::toml_command_names() {
312            all_cmds.insert(name);
313        }
314        let handled: HashSet<&str> = HANDLED_CMDS.iter().copied().collect();
315
316        let missing: Vec<_> = handled.difference(&all_cmds).collect();
317        assert!(missing.is_empty(), "not in registry: {missing:?}");
318
319        let extra: Vec<_> = all_cmds.difference(&handled).collect();
320        assert!(extra.is_empty(), "not in HANDLED_CMDS: {extra:?}");
321    }
322
323}