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];
120
121pub fn handler_docs() -> Vec<crate::docs::CommandDoc> {
122 let mut docs = Vec::new();
123 docs.extend(forges::command_docs());
124 docs.extend(node::command_docs());
125 docs.extend(jvm::command_docs());
126 docs.extend(android::command_docs());
127 docs.extend(network::command_docs());
128 docs.extend(system::command_docs());
129 docs.extend(perl::command_docs());
130 docs.extend(coreutils::command_docs());
131 docs.extend(fuzzy::command_docs());
132 docs.extend(shell::command_docs());
133 docs.extend(wrappers::command_docs());
134 docs.extend(crate::registry::toml_command_docs());
135 docs
136}
137
138#[cfg(test)]
139#[derive(Debug)]
140pub(crate) enum CommandEntry {
141 Positional { cmd: &'static str },
142 Custom { cmd: &'static str, valid_prefix: Option<&'static str> },
143 Subcommand { cmd: &'static str, subs: &'static [SubEntry], bare_ok: bool },
144 Delegation { cmd: &'static str },
145}
146
147#[cfg(test)]
148#[derive(Debug)]
149pub(crate) enum SubEntry {
150 Policy { name: &'static str },
151 Nested { name: &'static str, subs: &'static [SubEntry] },
152 Custom { name: &'static str, valid_suffix: Option<&'static str> },
153 Positional,
154 Guarded { name: &'static str, valid_suffix: &'static str },
155}
156
157pub fn all_opencode_patterns() -> Vec<String> {
158 let mut patterns = Vec::new();
159 patterns.sort();
160 patterns.dedup();
161 patterns
162}
163
164#[cfg(test)]
165fn full_registry() -> Vec<&'static CommandEntry> {
166 let mut entries = Vec::new();
167 entries.extend(shell::REGISTRY);
168 entries.extend(wrappers::REGISTRY);
169 entries.extend(forges::full_registry());
170 entries.extend(node::full_registry());
171 entries.extend(jvm::full_registry());
172 entries.extend(android::full_registry());
173 entries.extend(network::REGISTRY);
174 entries.extend(system::full_registry());
175 entries.extend(perl::REGISTRY);
176 entries.extend(coreutils::full_registry());
177 entries.extend(fuzzy::full_registry());
178 entries
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184 use std::collections::HashSet;
185
186 const UNKNOWN_FLAG: &str = "--xyzzy-unknown-42";
187 const UNKNOWN_SUB: &str = "xyzzy-unknown-42";
188
189 fn check_entry(entry: &CommandEntry, failures: &mut Vec<String>) {
190 match entry {
191 CommandEntry::Positional { .. } | CommandEntry::Delegation { .. } => {}
192 CommandEntry::Custom { cmd, valid_prefix } => {
193 let base = valid_prefix.unwrap_or(cmd);
194 let test = format!("{base} {UNKNOWN_FLAG}");
195 if crate::is_safe_command(&test) {
196 failures.push(format!("{cmd}: accepted unknown flag: {test}"));
197 }
198 }
199 CommandEntry::Subcommand { cmd, subs, bare_ok } => {
200 if !bare_ok && crate::is_safe_command(cmd) {
201 failures.push(format!("{cmd}: accepted bare invocation"));
202 }
203 let test = format!("{cmd} {UNKNOWN_SUB}");
204 if crate::is_safe_command(&test) {
205 failures.push(format!("{cmd}: accepted unknown subcommand: {test}"));
206 }
207 for sub in *subs {
208 check_sub(cmd, sub, failures);
209 }
210 }
211 }
212 }
213
214 fn check_sub(prefix: &str, entry: &SubEntry, failures: &mut Vec<String>) {
215 match entry {
216 SubEntry::Policy { name } => {
217 let test = format!("{prefix} {name} {UNKNOWN_FLAG}");
218 if crate::is_safe_command(&test) {
219 failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
220 }
221 }
222 SubEntry::Nested { name, subs } => {
223 let path = format!("{prefix} {name}");
224 let test = format!("{path} {UNKNOWN_SUB}");
225 if crate::is_safe_command(&test) {
226 failures.push(format!("{path}: accepted unknown subcommand: {test}"));
227 }
228 for sub in *subs {
229 check_sub(&path, sub, failures);
230 }
231 }
232 SubEntry::Custom { name, valid_suffix } => {
233 let base = match valid_suffix {
234 Some(s) => format!("{prefix} {name} {s}"),
235 None => format!("{prefix} {name}"),
236 };
237 let test = format!("{base} {UNKNOWN_FLAG}");
238 if crate::is_safe_command(&test) {
239 failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
240 }
241 }
242 SubEntry::Positional => {}
243 SubEntry::Guarded { name, valid_suffix } => {
244 let test = format!("{prefix} {name} {valid_suffix} {UNKNOWN_FLAG}");
245 if crate::is_safe_command(&test) {
246 failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
247 }
248 }
249 }
250 }
251
252 #[test]
253 fn all_commands_reject_unknown() {
254 let registry = full_registry();
255 let mut failures = Vec::new();
256 for entry in ®istry {
257 check_entry(entry, &mut failures);
258 }
259 assert!(
260 failures.is_empty(),
261 "unknown flags/subcommands accepted:\n{}",
262 failures.join("\n")
263 );
264 }
265
266 #[test]
267 fn process_substitution_blocked() {
268 let cmds = ["echo <(cat /etc/passwd)", "echo >(rm -rf /)", "grep pattern <(ls)"];
269 for cmd in &cmds {
270 assert!(
271 !crate::is_safe_command(cmd),
272 "process substitution not blocked: {cmd}",
273 );
274 }
275 }
276
277 #[test]
278 fn registry_covers_handled_commands() {
279 let registry = full_registry();
280 let mut all_cmds: HashSet<&str> = registry
281 .iter()
282 .map(|e| match e {
283 CommandEntry::Positional { cmd }
284 | CommandEntry::Custom { cmd, .. }
285 | CommandEntry::Subcommand { cmd, .. }
286 | CommandEntry::Delegation { cmd } => *cmd,
287 })
288 .collect();
289 for name in crate::registry::toml_command_names() {
290 all_cmds.insert(name);
291 }
292 let handled: HashSet<&str> = HANDLED_CMDS.iter().copied().collect();
293
294 let missing: Vec<_> = handled.difference(&all_cmds).collect();
295 assert!(missing.is_empty(), "not in registry: {missing:?}");
296
297 let extra: Vec<_> = all_cmds.difference(&handled).collect();
298 assert!(extra.is_empty(), "not in HANDLED_CMDS: {extra:?}");
299 }
300
301}