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 "@herb-tools/linter", "@biomejs/biome", "@commitlint/cli", "@redocly/cli",
143 "@axe-core/cli", "@arethetypeswrong/cli", "@taplo/cli", "@johnnymorganz/stylua-bin",
144 "@shopify/theme-check", "@graphql-inspector/cli", "@apidevtools/swagger-cli",
145 "@astrojs/check", "@changesets/cli",
146 "@stoplight/spectral-cli", "@ibm/openapi-validator", "@openapitools/openapi-generator-cli",
147 "@ls-lint/ls-lint", "@htmlhint/htmlhint", "@manypkg/cli",
148 "@microsoft/api-extractor", "@asyncapi/cli",
149 "svelte-check", "secretlint", "oxlint", "knip", "size-limit",
150 "depcheck", "madge", "license-checker",
151 "pytest", "jest", "vitest", "golangci-lint", "staticcheck", "govulncheck", "semgrep", "next", "turbo", "nx",
152 "direnv", "make", "packer", "vagrant",
153 "node", "python3", "python", "rustc", "java", "php",
154 "gcc", "g++", "cc", "c++", "clang", "clang++",
155 "elixir", "erl", "mix", "zig", "lua", "tsc",
156 "jc", "gron", "difft", "difftastic", "duf", "xsv", "qsv",
157 "git-lfs", "tig",
158 "trivy", "gitleaks", "grype", "syft", "watchexec", "act",
159];
160
161pub fn handler_docs() -> Vec<crate::docs::CommandDoc> {
162 let mut docs = Vec::new();
163 docs.extend(forges::command_docs());
164 docs.extend(node::command_docs());
165 docs.extend(jvm::command_docs());
166 docs.extend(android::command_docs());
167 docs.extend(network::command_docs());
168 docs.extend(system::command_docs());
169 docs.extend(perl::command_docs());
170 docs.extend(coreutils::command_docs());
171 docs.extend(fuzzy::command_docs());
172 docs.extend(shell::command_docs());
173 docs.extend(wrappers::command_docs());
174 docs.extend(crate::registry::toml_command_docs());
175 docs
176}
177
178#[cfg(test)]
179#[derive(Debug)]
180pub(crate) enum CommandEntry {
181 Positional { cmd: &'static str },
182 Custom { cmd: &'static str, valid_prefix: Option<&'static str> },
183 Paths { cmd: &'static str, bare_ok: bool, paths: &'static [&'static str] },
184 Delegation { cmd: &'static str },
185}
186
187pub fn all_opencode_patterns() -> Vec<String> {
188 let mut patterns = Vec::new();
189 patterns.sort();
190 patterns.dedup();
191 patterns
192}
193
194#[cfg(test)]
195fn full_registry() -> Vec<&'static CommandEntry> {
196 let mut entries = Vec::new();
197 entries.extend(shell::REGISTRY);
198 entries.extend(wrappers::REGISTRY);
199 entries.extend(forges::full_registry());
200 entries.extend(node::full_registry());
201 entries.extend(jvm::full_registry());
202 entries.extend(android::full_registry());
203 entries.extend(network::REGISTRY);
204 entries.extend(system::full_registry());
205 entries.extend(perl::REGISTRY);
206 entries.extend(coreutils::full_registry());
207 entries.extend(fuzzy::full_registry());
208 entries
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214 use std::collections::HashSet;
215
216 const UNKNOWN_FLAG: &str = "--xyzzy-unknown-42";
217 const UNKNOWN_SUB: &str = "xyzzy-unknown-42";
218
219 fn check_entry(entry: &CommandEntry, failures: &mut Vec<String>) {
220 match entry {
221 CommandEntry::Positional { .. } | CommandEntry::Delegation { .. } => {}
222 CommandEntry::Custom { cmd, valid_prefix } => {
223 let base = valid_prefix.unwrap_or(cmd);
224 let test = format!("{base} {UNKNOWN_FLAG}");
225 if crate::is_safe_command(&test) {
226 failures.push(format!("{cmd}: accepted unknown flag: {test}"));
227 }
228 }
229 CommandEntry::Paths { cmd, bare_ok, paths } => {
230 if !bare_ok && crate::is_safe_command(cmd) {
231 failures.push(format!("{cmd}: accepted bare invocation"));
232 }
233 let test = format!("{cmd} {UNKNOWN_SUB}");
234 if crate::is_safe_command(&test) {
235 failures.push(format!("{cmd}: accepted unknown subcommand: {test}"));
236 }
237 for path in *paths {
238 let test = format!("{path} {UNKNOWN_FLAG}");
239 if crate::is_safe_command(&test) {
240 failures.push(format!("{path}: accepted unknown flag: {test}"));
241 }
242 }
243 }
244 }
245 }
246
247 #[test]
248 fn all_commands_reject_unknown() {
249 let registry = full_registry();
250 let mut failures = Vec::new();
251 for entry in ®istry {
252 check_entry(entry, &mut failures);
253 }
254 assert!(
255 failures.is_empty(),
256 "unknown flags/subcommands accepted:\n{}",
257 failures.join("\n")
258 );
259 }
260
261 #[test]
262 fn process_substitution_blocked() {
263 let cmds = ["echo <(cat /etc/passwd)", "echo >(rm -rf /)", "grep pattern <(ls)"];
264 for cmd in &cmds {
265 assert!(
266 !crate::is_safe_command(cmd),
267 "process substitution not blocked: {cmd}",
268 );
269 }
270 }
271
272 #[test]
273 fn registry_covers_handled_commands() {
274 let registry = full_registry();
275 let mut all_cmds: HashSet<&str> = registry
276 .iter()
277 .map(|e| match e {
278 CommandEntry::Positional { cmd }
279 | CommandEntry::Custom { cmd, .. }
280 | CommandEntry::Paths { cmd, .. }
281 | CommandEntry::Delegation { cmd } => *cmd,
282 })
283 .collect();
284 for name in crate::registry::toml_command_names() {
285 all_cmds.insert(name);
286 }
287 let handled: HashSet<&str> = HANDLED_CMDS.iter().copied().collect();
288
289 let missing: Vec<_> = handled.difference(&all_cmds).collect();
290 assert!(missing.is_empty(), "not in registry: {missing:?}");
291
292 let extra: Vec<_> = all_cmds.difference(&handled).collect();
293 assert!(extra.is_empty(), "not in HANDLED_CMDS: {extra:?}");
294 }
295
296}