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