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