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