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",
92    "pip", "pip3", "uv", "poetry", "pyenv", "conda", "coverage", "tox", "nox", "bandit", "pip-audit", "pdm",
93    "cargo", "rustup",
94    "go",
95    "gradle", "gradlew", "mvn", "mvnw", "ktlint", "detekt",
96    "javap", "jar", "keytool", "jarsigner",
97    "adb", "apkanalyzer", "apksigner", "bundletool", "aapt2",
98    "emulator", "avdmanager", "sdkmanager", "zipalign", "lint",
99    "fastlane", "firebase",
100    "artisan", "composer", "craft", "pest", "phpstan", "phpunit", "please", "valet",
101    "swift",
102    "dotnet",
103    "curl",
104    "docker", "podman", "kubectl", "orbctl", "orb", "qemu-img", "helm", "skopeo", "crane", "cosign", "kustomize", "stern", "kubectx", "kubens", "kind", "minikube",
105    "ollama", "llm", "hf", "claude", "aider", "codex", "opencode", "vibe",
106    "ddev", "dcli",
107    "brew", "mise", "asdf", "crontab", "defaults", "pmset", "sysctl", "cmake", "psql", "pg_isready",
108    "pg_dump", "bazel", "meson", "ninja",
109    "terraform", "heroku", "vercel", "fly", "flyctl", "pulumi", "netlify", "railway", "render",
110    "northflank", "cx", "hey", "wrangler", "cf", "newrelic",
111    "aws", "gcloud", "az",
112    "doctl", "hcloud", "vultr-cli", "exo", "scw", "linode-cli",
113    "ansible-playbook", "ansible-inventory", "ansible-doc", "ansible-config", "ansible-galaxy",
114    "overmind", "tailscale", "tmux", "wg", "systemctl", "journalctl", "zellij",
115    "kafka-topics", "kafka-console-consumer", "kafka-consumer-groups",
116    "monolith",
117    "cloudflared", "ngrok", "ssh",
118    "networksetup", "launchctl", "diskutil", "security", "csrutil", "log",
119    "xcodebuild", "plutil", "xcode-select", "xcrun", "pkgutil", "lipo", "codesign", "spctl",
120    "xcodegen", "tuist", "pod", "swiftlint", "swiftformat", "periphery", "xcbeautify", "agvtool", "simctl",
121    "perl",
122    "R", "Rscript",
123    "grep", "egrep", "fgrep", "rg", "ag", "ack", "zgrep", "zegrep", "zfgrep", "locate", "mlocate", "plocate",
124    "cat", "gzcat", "head", "tail", "wc", "cut", "tr", "uniq", "less", "more", "zcat",
125    "diff", "comm", "paste", "tac", "rev", "nl",
126    "expand", "unexpand", "fold", "fmt", "col", "column", "iconv", "nroff",
127    "echo", "printf", "seq", "test", "[", "expr", "bc", "factor", "bat", "glow",
128    "arch", "command", "hostname",
129    "find", "sed", "shuf", "sort", "yq", "xmllint", "awk", "gawk", "mawk", "nawk",
130    "magick", "convert", "frames",
131    "fd", "eza", "exa", "ls", "delta", "colordiff",
132    "dirname", "basename", "realpath", "readlink",
133    "file", "stat", "du", "df", "tree", "cmp", "zipinfo", "tar", "unzip", "gzip",
134    "true", "false", ":", "shopt",
135    "alias", "break", "continue", "declare", "exit", "export", "hash", "printenv", "read", "type", "typeset", "wait", "whereis", "which", "whoami", "date", "pwd", "cd", "unset",
136    "uname", "nproc", "uptime", "id", "groups", "tty", "locale", "cal", "sleep",
137    "who", "w", "last", "lastlog",
138    "ps", "top", "htop", "iotop", "procs", "dust", "lsof", "pgrep", "pstree", "lsblk", "free", "sample",
139    "jq", "jaq", "gojq", "fx", "jless", "htmlq", "xq", "tomlq", "mlr", "dasel",
140    "base64", "xxd", "getconf", "uuidgen",
141    "md5sum", "md5", "sha256sum", "shasum", "sha1sum", "sha512sum",
142    "cksum", "b2sum", "sum", "strings", "hexdump", "od", "size", "sips",
143    "sw_vers", "mdls", "otool", "nm", "system_profiler", "ioreg", "vm_stat", "mdfind", "man",
144    "dig", "nslookup", "host", "whois", "netstat", "ss", "ifconfig", "route", "ping",
145    "traceroute", "traceroute6", "mtr", "nc", "ncat", "nmap",
146    "xv",
147    "fzf", "fzy", "peco", "pick", "selecta", "sk", "zf",
148    "identify", "shellcheck", "cloc", "tokei", "cucumber", "branchdiff", "specdiff", "workon", "safe-chains", "snyk", "mdbook", "devbox", "pup",
149    "tldr", "ldd", "objdump", "readelf", "just",
150    "prettier", "black", "ruff", "mypy", "pyright", "pylint", "flake8", "isort",
151    "rubocop", "eslint", "biome", "stylelint", "zoxide",
152    "@herb-tools/linter", "@biomejs/biome", "@commitlint/cli", "@redocly/cli",
153    "@axe-core/cli", "@arethetypeswrong/cli", "@taplo/cli", "@johnnymorganz/stylua-bin",
154    "@shopify/theme-check", "@graphql-inspector/cli", "@apidevtools/swagger-cli",
155    "@astrojs/check", "@changesets/cli",
156    "@stoplight/spectral-cli", "@ibm/openapi-validator", "@openapitools/openapi-generator-cli",
157    "@ls-lint/ls-lint", "@htmlhint/htmlhint", "@manypkg/cli",
158    "@microsoft/api-extractor", "@asyncapi/cli",
159    "svelte-check", "secretlint", "oxlint", "knip", "size-limit",
160    "depcheck", "madge", "license-checker",
161    "pytest", "jest", "vitest", "golangci-lint", "staticcheck", "govulncheck", "semgrep", "next", "turbo", "nx",
162    "direnv", "make", "packer", "vagrant",
163    "node", "python3", "python", "rustc", "java", "php",
164    "gcc", "g++", "cc", "c++", "clang", "clang++",
165    "elixir", "erl", "mix", "zig", "lua", "tsc",
166    "jc", "gron", "difft", "difftastic", "duf", "xsv", "qsv",
167    "git-cliff", "git-lfs", "tig",
168    "trivy", "gitleaks", "grype", "syft", "watchexec", "act",
169];
170
171pub fn handler_docs() -> Vec<crate::docs::CommandDoc> {
172    let mut docs = Vec::new();
173    docs.extend(forges::command_docs());
174    docs.extend(node::command_docs());
175    docs.extend(jvm::command_docs());
176    docs.extend(android::command_docs());
177    docs.extend(network::command_docs());
178    docs.extend(system::command_docs());
179    docs.extend(perl::command_docs());
180    docs.extend(coreutils::command_docs());
181    docs.extend(fuzzy::command_docs());
182    docs.extend(shell::command_docs());
183    docs.extend(wrappers::command_docs());
184    docs.extend(vcs::command_docs());
185    docs.extend(crate::registry::toml_command_docs());
186    docs
187}
188
189#[cfg(test)]
190#[derive(Debug)]
191pub(crate) enum CommandEntry {
192    Positional { cmd: &'static str },
193    Custom { cmd: &'static str, valid_prefix: Option<&'static str> },
194    Paths { cmd: &'static str, bare_ok: bool, paths: &'static [&'static str] },
195    Delegation { cmd: &'static str },
196}
197
198pub fn all_opencode_patterns() -> Vec<String> {
199    let mut patterns = Vec::new();
200    patterns.sort();
201    patterns.dedup();
202    patterns
203}
204
205#[cfg(test)]
206fn full_registry() -> Vec<&'static CommandEntry> {
207    let mut entries = Vec::new();
208    entries.extend(shell::REGISTRY);
209    entries.extend(wrappers::REGISTRY);
210    entries.extend(forges::full_registry());
211    entries.extend(node::full_registry());
212    entries.extend(jvm::full_registry());
213    entries.extend(android::full_registry());
214    entries.extend(network::REGISTRY);
215    entries.extend(system::full_registry());
216    entries.extend(perl::REGISTRY);
217    entries.extend(coreutils::full_registry());
218    entries.extend(fuzzy::full_registry());
219    entries
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use std::collections::HashSet;
226
227    const UNKNOWN_FLAG: &str = "--xyzzy-unknown-42";
228    const UNKNOWN_SUB: &str = "xyzzy-unknown-42";
229
230    fn check_entry(entry: &CommandEntry, failures: &mut Vec<String>) {
231        match entry {
232            CommandEntry::Positional { .. } | CommandEntry::Delegation { .. } => {}
233            CommandEntry::Custom { cmd, valid_prefix } => {
234                let base = valid_prefix.unwrap_or(cmd);
235                let test = format!("{base} {UNKNOWN_FLAG}");
236                if crate::is_safe_command(&test) {
237                    failures.push(format!("{cmd}: accepted unknown flag: {test}"));
238                }
239            }
240            CommandEntry::Paths { cmd, bare_ok, paths } => {
241                if !bare_ok && crate::is_safe_command(cmd) {
242                    failures.push(format!("{cmd}: accepted bare invocation"));
243                }
244                let test = format!("{cmd} {UNKNOWN_SUB}");
245                if crate::is_safe_command(&test) {
246                    failures.push(format!("{cmd}: accepted unknown subcommand: {test}"));
247                }
248                for path in *paths {
249                    let test = format!("{path} {UNKNOWN_FLAG}");
250                    if crate::is_safe_command(&test) {
251                        failures.push(format!("{path}: accepted unknown flag: {test}"));
252                    }
253                }
254            }
255        }
256    }
257
258    #[test]
259    fn all_commands_reject_unknown() {
260        let registry = full_registry();
261        let mut failures = Vec::new();
262        for entry in &registry {
263            check_entry(entry, &mut failures);
264        }
265        assert!(
266            failures.is_empty(),
267            "unknown flags/subcommands accepted:\n{}",
268            failures.join("\n")
269        );
270    }
271
272    #[test]
273    fn process_substitution_safe_inner() {
274        let safe = ["echo <(cat /etc/passwd)", "grep pattern <(ls)", "diff <(sort a.txt) <(sort b.txt)", "comm -23 file.txt <(sort other.txt)"];
275        for cmd in &safe {
276            assert!(crate::is_safe_command(cmd), "safe process substitution rejected: {cmd}");
277        }
278    }
279
280    #[test]
281    fn process_substitution_unsafe_inner() {
282        let unsafe_cmds = ["echo >(rm -rf /)", "diff <(sort a.txt) <(rm -rf /)"];
283        for cmd in &unsafe_cmds {
284            assert!(!crate::is_safe_command(cmd), "unsafe process substitution approved: {cmd}");
285        }
286    }
287
288    #[test]
289    fn registry_covers_handled_commands() {
290        let registry = full_registry();
291        let mut all_cmds: HashSet<&str> = registry
292            .iter()
293            .map(|e| match e {
294                CommandEntry::Positional { cmd }
295                | CommandEntry::Custom { cmd, .. }
296                | CommandEntry::Paths { cmd, .. }
297                | CommandEntry::Delegation { cmd } => *cmd,
298            })
299            .collect();
300        for name in crate::registry::toml_command_names() {
301            all_cmds.insert(name);
302        }
303        let handled: HashSet<&str> = HANDLED_CMDS.iter().copied().collect();
304
305        let missing: Vec<_> = handled.difference(&all_cmds).collect();
306        assert!(missing.is_empty(), "not in registry: {missing:?}");
307
308        let extra: Vec<_> = all_cmds.difference(&handled).collect();
309        assert!(extra.is_empty(), "not in HANDLED_CMDS: {extra:?}");
310    }
311
312}