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