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