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