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