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