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