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