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;
14pub mod xcode;
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(|| vcs::dispatch(cmd, tokens))
44 .or_else(|| forges::dispatch(cmd, tokens))
45 .or_else(|| node::dispatch(cmd, tokens))
46 .or_else(|| ruby::dispatch(cmd, tokens))
47 .or_else(|| jvm::dispatch(cmd, tokens))
48 .or_else(|| android::dispatch(cmd, tokens))
49 .or_else(|| network::dispatch(cmd, tokens))
50 .or_else(|| system::dispatch(cmd, tokens))
51 .or_else(|| xcode::dispatch(cmd, tokens))
52 .or_else(|| perl::dispatch(cmd, tokens))
53 .or_else(|| coreutils::dispatch(cmd, tokens))
54 .or_else(|| fuzzy::dispatch(cmd, tokens))
55 .or_else(|| crate::registry::toml_dispatch(tokens))
56 .unwrap_or(Verdict::Denied)
57}
58
59#[cfg(test)]
60const HANDLED_CMDS: &[&str] = &[
61 "sh", "bash", "xargs", "timeout", "time", "env", "nice", "ionice", "hyperfine", "dotenv",
62 "git", "jj", "gh", "glab", "jjpr", "tea",
63 "npm", "yarn", "pnpm", "bun", "deno", "npx", "bunx", "nvm", "fnm", "volta",
64 "ruby", "ri", "bundle", "gem", "importmap", "rbenv",
65 "pip", "uv", "poetry", "pyenv", "conda",
66 "cargo", "rustup",
67 "go",
68 "gradle", "mvn", "mvnw", "ktlint", "detekt",
69 "javap", "jar", "keytool", "jarsigner",
70 "adb", "apkanalyzer", "apksigner", "bundletool", "aapt2",
71 "emulator", "avdmanager", "sdkmanager", "zipalign", "lint",
72 "fastlane", "firebase",
73 "composer", "craft",
74 "swift",
75 "dotnet",
76 "curl",
77 "docker", "podman", "kubectl", "orbctl", "orb", "qemu-img",
78 "ollama", "llm", "hf", "claude", "aider", "codex", "opencode", "vibe",
79 "ddev", "dcli",
80 "brew", "mise", "asdf", "crontab", "defaults", "pmset", "sysctl", "cmake", "psql", "pg_isready",
81 "terraform", "heroku", "vercel", "flyctl",
82 "overmind", "tailscale", "tmux", "wg",
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];
114
115pub fn handler_docs() -> Vec<crate::docs::CommandDoc> {
116 let mut docs = Vec::new();
117 docs.extend(vcs::command_docs());
118 docs.extend(forges::command_docs());
119 docs.extend(node::command_docs());
120 docs.extend(ruby::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(xcode::command_docs());
126 docs.extend(perl::command_docs());
127 docs.extend(coreutils::command_docs());
128 docs.extend(fuzzy::command_docs());
129 docs.extend(shell::command_docs());
130 docs.extend(wrappers::command_docs());
131 docs.extend(crate::registry::toml_command_docs());
132 docs
133}
134
135#[cfg(test)]
136#[derive(Debug)]
137pub(crate) enum CommandEntry {
138 Positional { cmd: &'static str },
139 Custom { cmd: &'static str, valid_prefix: Option<&'static str> },
140 Subcommand { cmd: &'static str, subs: &'static [SubEntry], bare_ok: bool },
141 Delegation { cmd: &'static str },
142}
143
144#[cfg(test)]
145#[derive(Debug)]
146pub(crate) enum SubEntry {
147 Policy { name: &'static str },
148 Nested { name: &'static str, subs: &'static [SubEntry] },
149 Custom { name: &'static str, valid_suffix: Option<&'static str> },
150 Positional,
151 Guarded { name: &'static str, valid_suffix: &'static str },
152}
153
154pub fn all_opencode_patterns() -> Vec<String> {
155 let mut patterns = Vec::new();
156 patterns.sort();
157 patterns.dedup();
158 patterns
159}
160
161#[cfg(test)]
162fn full_registry() -> Vec<&'static CommandEntry> {
163 let mut entries = Vec::new();
164 entries.extend(shell::REGISTRY);
165 entries.extend(wrappers::REGISTRY);
166 entries.extend(vcs::full_registry());
167 entries.extend(forges::full_registry());
168 entries.extend(node::full_registry());
169 entries.extend(jvm::full_registry());
170 entries.extend(android::full_registry());
171 entries.extend(network::REGISTRY);
172 entries.extend(system::full_registry());
173 entries.extend(xcode::full_registry());
174 entries.extend(perl::REGISTRY);
175 entries.extend(coreutils::full_registry());
176 entries.extend(fuzzy::full_registry());
177 entries
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183 use std::collections::HashSet;
184
185 const UNKNOWN_FLAG: &str = "--xyzzy-unknown-42";
186 const UNKNOWN_SUB: &str = "xyzzy-unknown-42";
187
188 fn check_entry(entry: &CommandEntry, failures: &mut Vec<String>) {
189 match entry {
190 CommandEntry::Positional { .. } | CommandEntry::Delegation { .. } => {}
191 CommandEntry::Custom { cmd, valid_prefix } => {
192 let base = valid_prefix.unwrap_or(cmd);
193 let test = format!("{base} {UNKNOWN_FLAG}");
194 if crate::is_safe_command(&test) {
195 failures.push(format!("{cmd}: accepted unknown flag: {test}"));
196 }
197 }
198 CommandEntry::Subcommand { cmd, subs, bare_ok } => {
199 if !bare_ok && crate::is_safe_command(cmd) {
200 failures.push(format!("{cmd}: accepted bare invocation"));
201 }
202 let test = format!("{cmd} {UNKNOWN_SUB}");
203 if crate::is_safe_command(&test) {
204 failures.push(format!("{cmd}: accepted unknown subcommand: {test}"));
205 }
206 for sub in *subs {
207 check_sub(cmd, sub, failures);
208 }
209 }
210 }
211 }
212
213 fn check_sub(prefix: &str, entry: &SubEntry, failures: &mut Vec<String>) {
214 match entry {
215 SubEntry::Policy { name } => {
216 let test = format!("{prefix} {name} {UNKNOWN_FLAG}");
217 if crate::is_safe_command(&test) {
218 failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
219 }
220 }
221 SubEntry::Nested { name, subs } => {
222 let path = format!("{prefix} {name}");
223 let test = format!("{path} {UNKNOWN_SUB}");
224 if crate::is_safe_command(&test) {
225 failures.push(format!("{path}: accepted unknown subcommand: {test}"));
226 }
227 for sub in *subs {
228 check_sub(&path, sub, failures);
229 }
230 }
231 SubEntry::Custom { name, valid_suffix } => {
232 let base = match valid_suffix {
233 Some(s) => format!("{prefix} {name} {s}"),
234 None => format!("{prefix} {name}"),
235 };
236 let test = format!("{base} {UNKNOWN_FLAG}");
237 if crate::is_safe_command(&test) {
238 failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
239 }
240 }
241 SubEntry::Positional => {}
242 SubEntry::Guarded { name, valid_suffix } => {
243 let test = format!("{prefix} {name} {valid_suffix} {UNKNOWN_FLAG}");
244 if crate::is_safe_command(&test) {
245 failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
246 }
247 }
248 }
249 }
250
251 #[test]
252 fn all_commands_reject_unknown() {
253 let registry = full_registry();
254 let mut failures = Vec::new();
255 for entry in ®istry {
256 check_entry(entry, &mut failures);
257 }
258 assert!(
259 failures.is_empty(),
260 "unknown flags/subcommands accepted:\n{}",
261 failures.join("\n")
262 );
263 }
264
265 #[test]
266 fn process_substitution_blocked() {
267 let cmds = ["echo <(cat /etc/passwd)", "echo >(rm -rf /)", "grep pattern <(ls)"];
268 for cmd in &cmds {
269 assert!(
270 !crate::is_safe_command(cmd),
271 "process substitution not blocked: {cmd}",
272 );
273 }
274 }
275
276 #[test]
277 fn registry_covers_handled_commands() {
278 let registry = full_registry();
279 let mut all_cmds: HashSet<&str> = registry
280 .iter()
281 .map(|e| match e {
282 CommandEntry::Positional { cmd }
283 | CommandEntry::Custom { cmd, .. }
284 | CommandEntry::Subcommand { cmd, .. }
285 | CommandEntry::Delegation { cmd } => *cmd,
286 })
287 .collect();
288 for name in crate::registry::toml_command_names() {
289 all_cmds.insert(name);
290 }
291 let handled: HashSet<&str> = HANDLED_CMDS.iter().copied().collect();
292
293 let missing: Vec<_> = handled.difference(&all_cmds).collect();
294 assert!(missing.is_empty(), "not in registry: {missing:?}");
295
296 let extra: Vec<_> = all_cmds.difference(&handled).collect();
297 assert!(extra.is_empty(), "not in HANDLED_CMDS: {extra:?}");
298 }
299
300}