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",
61 "ruby", "ri", "bundle", "gem", "importmap", "rbenv",
62 "pip", "uv", "poetry", "pyenv", "conda",
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",
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",
115 "tldr", "ldd", "objdump", "readelf", "just",
116 "prettier", "black", "ruff", "mypy", "pyright", "pylint", "flake8", "isort",
117 "rubocop", "eslint", "biome", "stylelint", "zoxide",
118 "direnv", "make", "packer", "vagrant",
119 "node", "python3", "python", "rustc", "java", "php",
120 "gcc", "g++", "cc", "c++", "clang", "clang++",
121 "elixir", "erl", "mix", "zig", "lua", "tsc",
122 "jc", "gron", "difft", "difftastic", "duf", "xsv", "qsv",
123 "git-lfs",
124];
125
126pub fn handler_docs() -> Vec<crate::docs::CommandDoc> {
127 let mut docs = Vec::new();
128 docs.extend(forges::command_docs());
129 docs.extend(node::command_docs());
130 docs.extend(jvm::command_docs());
131 docs.extend(android::command_docs());
132 docs.extend(network::command_docs());
133 docs.extend(system::command_docs());
134 docs.extend(perl::command_docs());
135 docs.extend(coreutils::command_docs());
136 docs.extend(fuzzy::command_docs());
137 docs.extend(shell::command_docs());
138 docs.extend(wrappers::command_docs());
139 docs.extend(crate::registry::toml_command_docs());
140 docs
141}
142
143#[cfg(test)]
144#[derive(Debug)]
145pub(crate) enum CommandEntry {
146 Positional { cmd: &'static str },
147 Custom { cmd: &'static str, valid_prefix: Option<&'static str> },
148 Subcommand { cmd: &'static str, subs: &'static [SubEntry], bare_ok: bool },
149 Delegation { cmd: &'static str },
150}
151
152#[cfg(test)]
153#[derive(Debug)]
154pub(crate) enum SubEntry {
155 Policy { name: &'static str },
156 Nested { name: &'static str, subs: &'static [SubEntry] },
157 Custom { name: &'static str, valid_suffix: Option<&'static str> },
158 Positional,
159 Guarded { name: &'static str, valid_suffix: &'static str },
160}
161
162pub fn all_opencode_patterns() -> Vec<String> {
163 let mut patterns = Vec::new();
164 patterns.sort();
165 patterns.dedup();
166 patterns
167}
168
169#[cfg(test)]
170fn full_registry() -> Vec<&'static CommandEntry> {
171 let mut entries = Vec::new();
172 entries.extend(shell::REGISTRY);
173 entries.extend(wrappers::REGISTRY);
174 entries.extend(forges::full_registry());
175 entries.extend(node::full_registry());
176 entries.extend(jvm::full_registry());
177 entries.extend(android::full_registry());
178 entries.extend(network::REGISTRY);
179 entries.extend(system::full_registry());
180 entries.extend(perl::REGISTRY);
181 entries.extend(coreutils::full_registry());
182 entries.extend(fuzzy::full_registry());
183 entries
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189 use std::collections::HashSet;
190
191 const UNKNOWN_FLAG: &str = "--xyzzy-unknown-42";
192 const UNKNOWN_SUB: &str = "xyzzy-unknown-42";
193
194 fn check_entry(entry: &CommandEntry, failures: &mut Vec<String>) {
195 match entry {
196 CommandEntry::Positional { .. } | CommandEntry::Delegation { .. } => {}
197 CommandEntry::Custom { cmd, valid_prefix } => {
198 let base = valid_prefix.unwrap_or(cmd);
199 let test = format!("{base} {UNKNOWN_FLAG}");
200 if crate::is_safe_command(&test) {
201 failures.push(format!("{cmd}: accepted unknown flag: {test}"));
202 }
203 }
204 CommandEntry::Subcommand { cmd, subs, bare_ok } => {
205 if !bare_ok && crate::is_safe_command(cmd) {
206 failures.push(format!("{cmd}: accepted bare invocation"));
207 }
208 let test = format!("{cmd} {UNKNOWN_SUB}");
209 if crate::is_safe_command(&test) {
210 failures.push(format!("{cmd}: accepted unknown subcommand: {test}"));
211 }
212 for sub in *subs {
213 check_sub(cmd, sub, failures);
214 }
215 }
216 }
217 }
218
219 fn check_sub(prefix: &str, entry: &SubEntry, failures: &mut Vec<String>) {
220 match entry {
221 SubEntry::Policy { name } => {
222 let test = format!("{prefix} {name} {UNKNOWN_FLAG}");
223 if crate::is_safe_command(&test) {
224 failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
225 }
226 }
227 SubEntry::Nested { name, subs } => {
228 let path = format!("{prefix} {name}");
229 let test = format!("{path} {UNKNOWN_SUB}");
230 if crate::is_safe_command(&test) {
231 failures.push(format!("{path}: accepted unknown subcommand: {test}"));
232 }
233 for sub in *subs {
234 check_sub(&path, sub, failures);
235 }
236 }
237 SubEntry::Custom { name, valid_suffix } => {
238 let base = match valid_suffix {
239 Some(s) => format!("{prefix} {name} {s}"),
240 None => format!("{prefix} {name}"),
241 };
242 let test = format!("{base} {UNKNOWN_FLAG}");
243 if crate::is_safe_command(&test) {
244 failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
245 }
246 }
247 SubEntry::Positional => {}
248 SubEntry::Guarded { name, valid_suffix } => {
249 let test = format!("{prefix} {name} {valid_suffix} {UNKNOWN_FLAG}");
250 if crate::is_safe_command(&test) {
251 failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
252 }
253 }
254 }
255 }
256
257 #[test]
258 fn all_commands_reject_unknown() {
259 let registry = full_registry();
260 let mut failures = Vec::new();
261 for entry in ®istry {
262 check_entry(entry, &mut failures);
263 }
264 assert!(
265 failures.is_empty(),
266 "unknown flags/subcommands accepted:\n{}",
267 failures.join("\n")
268 );
269 }
270
271 #[test]
272 fn process_substitution_blocked() {
273 let cmds = ["echo <(cat /etc/passwd)", "echo >(rm -rf /)", "grep pattern <(ls)"];
274 for cmd in &cmds {
275 assert!(
276 !crate::is_safe_command(cmd),
277 "process substitution not blocked: {cmd}",
278 );
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::Subcommand { 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}