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