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 "terraform", "heroku", "vercel", "flyctl", "pulumi", "netlify", "railway", "wrangler", "cf", "newrelic",
79 "doctl", "hcloud", "vultr-cli", "exo", "scw", "linode-cli",
80 "ansible-playbook", "ansible-inventory", "ansible-doc", "ansible-config", "ansible-galaxy",
81 "overmind", "tailscale", "tmux", "wg", "systemctl", "journalctl",
82 "networksetup", "launchctl", "diskutil", "security", "csrutil", "log",
83 "xcodebuild", "plutil", "xcode-select", "xcrun", "pkgutil", "lipo", "codesign", "spctl",
84 "xcodegen", "tuist", "pod", "swiftlint", "swiftformat", "periphery", "xcbeautify", "agvtool", "simctl",
85 "perl",
86 "R", "Rscript",
87 "grep", "egrep", "fgrep", "rg", "ag", "ack", "zgrep", "zegrep", "zfgrep", "locate", "mlocate", "plocate",
88 "cat", "gzcat", "head", "tail", "wc", "cut", "tr", "uniq", "less", "more", "zcat",
89 "diff", "comm", "paste", "tac", "rev", "nl",
90 "expand", "unexpand", "fold", "fmt", "col", "column", "iconv", "nroff",
91 "echo", "printf", "seq", "test", "[", "expr", "bc", "factor", "bat",
92 "arch", "command", "hostname",
93 "find", "sed", "shuf", "sort", "yq", "xmllint", "awk", "gawk", "mawk", "nawk",
94 "magick",
95 "fd", "eza", "exa", "ls", "delta", "colordiff",
96 "dirname", "basename", "realpath", "readlink",
97 "file", "stat", "du", "df", "tree", "cmp", "zipinfo", "tar", "unzip", "gzip",
98 "true", "false",
99 "alias", "export", "printenv", "read", "type", "wait", "whereis", "which", "whoami", "date", "pwd", "cd", "unset",
100 "uname", "nproc", "uptime", "id", "groups", "tty", "locale", "cal", "sleep",
101 "who", "w", "last", "lastlog",
102 "ps", "top", "htop", "iotop", "procs", "dust", "lsof", "pgrep", "lsblk", "free",
103 "jq", "jaq", "gojq", "fx", "jless", "htmlq", "xq", "tomlq", "mlr", "dasel",
104 "base64", "xxd", "getconf", "uuidgen",
105 "md5sum", "md5", "sha256sum", "shasum", "sha1sum", "sha512sum",
106 "cksum", "b2sum", "sum", "strings", "hexdump", "od", "size", "sips",
107 "sw_vers", "mdls", "otool", "nm", "system_profiler", "ioreg", "vm_stat", "mdfind", "man",
108 "dig", "nslookup", "host", "whois", "netstat", "ss", "ifconfig", "route", "ping",
109 "xv",
110 "fzf", "fzy", "peco", "pick", "selecta", "sk", "zf",
111 "identify", "shellcheck", "cloc", "tokei", "cucumber", "branchdiff", "workon", "safe-chains",
112 "tldr", "ldd", "objdump", "readelf", "just",
113 "direnv", "make", "packer", "vagrant",
114];
115
116pub fn handler_docs() -> Vec<crate::docs::CommandDoc> {
117 let mut docs = Vec::new();
118 docs.extend(forges::command_docs());
119 docs.extend(node::command_docs());
120 docs.extend(jvm::command_docs());
121 docs.extend(android::command_docs());
122 docs.extend(network::command_docs());
123 docs.extend(system::command_docs());
124 docs.extend(perl::command_docs());
125 docs.extend(coreutils::command_docs());
126 docs.extend(fuzzy::command_docs());
127 docs.extend(shell::command_docs());
128 docs.extend(wrappers::command_docs());
129 docs.extend(crate::registry::toml_command_docs());
130 docs
131}
132
133#[cfg(test)]
134#[derive(Debug)]
135pub(crate) enum CommandEntry {
136 Positional { cmd: &'static str },
137 Custom { cmd: &'static str, valid_prefix: Option<&'static str> },
138 Subcommand { cmd: &'static str, subs: &'static [SubEntry], bare_ok: bool },
139 Delegation { cmd: &'static str },
140}
141
142#[cfg(test)]
143#[derive(Debug)]
144pub(crate) enum SubEntry {
145 Policy { name: &'static str },
146 Nested { name: &'static str, subs: &'static [SubEntry] },
147 Custom { name: &'static str, valid_suffix: Option<&'static str> },
148 Positional,
149 Guarded { name: &'static str, valid_suffix: &'static str },
150}
151
152pub fn all_opencode_patterns() -> Vec<String> {
153 let mut patterns = Vec::new();
154 patterns.sort();
155 patterns.dedup();
156 patterns
157}
158
159#[cfg(test)]
160fn full_registry() -> Vec<&'static CommandEntry> {
161 let mut entries = Vec::new();
162 entries.extend(shell::REGISTRY);
163 entries.extend(wrappers::REGISTRY);
164 entries.extend(forges::full_registry());
165 entries.extend(node::full_registry());
166 entries.extend(jvm::full_registry());
167 entries.extend(android::full_registry());
168 entries.extend(network::REGISTRY);
169 entries.extend(system::full_registry());
170 entries.extend(perl::REGISTRY);
171 entries.extend(coreutils::full_registry());
172 entries.extend(fuzzy::full_registry());
173 entries
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179 use std::collections::HashSet;
180
181 const UNKNOWN_FLAG: &str = "--xyzzy-unknown-42";
182 const UNKNOWN_SUB: &str = "xyzzy-unknown-42";
183
184 fn check_entry(entry: &CommandEntry, failures: &mut Vec<String>) {
185 match entry {
186 CommandEntry::Positional { .. } | CommandEntry::Delegation { .. } => {}
187 CommandEntry::Custom { cmd, valid_prefix } => {
188 let base = valid_prefix.unwrap_or(cmd);
189 let test = format!("{base} {UNKNOWN_FLAG}");
190 if crate::is_safe_command(&test) {
191 failures.push(format!("{cmd}: accepted unknown flag: {test}"));
192 }
193 }
194 CommandEntry::Subcommand { cmd, subs, bare_ok } => {
195 if !bare_ok && crate::is_safe_command(cmd) {
196 failures.push(format!("{cmd}: accepted bare invocation"));
197 }
198 let test = format!("{cmd} {UNKNOWN_SUB}");
199 if crate::is_safe_command(&test) {
200 failures.push(format!("{cmd}: accepted unknown subcommand: {test}"));
201 }
202 for sub in *subs {
203 check_sub(cmd, sub, failures);
204 }
205 }
206 }
207 }
208
209 fn check_sub(prefix: &str, entry: &SubEntry, failures: &mut Vec<String>) {
210 match entry {
211 SubEntry::Policy { name } => {
212 let test = format!("{prefix} {name} {UNKNOWN_FLAG}");
213 if crate::is_safe_command(&test) {
214 failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
215 }
216 }
217 SubEntry::Nested { name, subs } => {
218 let path = format!("{prefix} {name}");
219 let test = format!("{path} {UNKNOWN_SUB}");
220 if crate::is_safe_command(&test) {
221 failures.push(format!("{path}: accepted unknown subcommand: {test}"));
222 }
223 for sub in *subs {
224 check_sub(&path, sub, failures);
225 }
226 }
227 SubEntry::Custom { name, valid_suffix } => {
228 let base = match valid_suffix {
229 Some(s) => format!("{prefix} {name} {s}"),
230 None => format!("{prefix} {name}"),
231 };
232 let test = format!("{base} {UNKNOWN_FLAG}");
233 if crate::is_safe_command(&test) {
234 failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
235 }
236 }
237 SubEntry::Positional => {}
238 SubEntry::Guarded { name, valid_suffix } => {
239 let test = format!("{prefix} {name} {valid_suffix} {UNKNOWN_FLAG}");
240 if crate::is_safe_command(&test) {
241 failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
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::Subcommand { 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}