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