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 ®istry {
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}