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