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 ])
33}
34
35pub fn dispatch(tokens: &[Token]) -> Verdict {
36 let cmd = tokens[0].command_name();
37 None
38 .or_else(|| shell::dispatch(cmd, tokens))
39 .or_else(|| wrappers::dispatch(cmd, tokens))
40 .or_else(|| vcs::dispatch(cmd, tokens))
41 .or_else(|| forges::dispatch(cmd, tokens))
42 .or_else(|| node::dispatch(cmd, tokens))
43 .or_else(|| ruby::dispatch(cmd, tokens))
44 .or_else(|| jvm::dispatch(cmd, tokens))
45 .or_else(|| android::dispatch(cmd, tokens))
46 .or_else(|| network::dispatch(cmd, tokens))
47 .or_else(|| system::dispatch(cmd, tokens))
48 .or_else(|| xcode::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(vcs::command_docs());
115 docs.extend(forges::command_docs());
116 docs.extend(node::command_docs());
117 docs.extend(ruby::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(xcode::command_docs());
123 docs.extend(perl::command_docs());
124 docs.extend(coreutils::command_docs());
125 docs.extend(fuzzy::command_docs());
126 docs.extend(shell::command_docs());
127 docs.extend(wrappers::command_docs());
128 docs.extend(crate::registry::toml_command_docs());
129 docs
130}
131
132#[cfg(test)]
133#[derive(Debug)]
134pub(crate) enum CommandEntry {
135 Positional { cmd: &'static str },
136 Custom { cmd: &'static str, valid_prefix: Option<&'static str> },
137 Subcommand { cmd: &'static str, subs: &'static [SubEntry], bare_ok: bool },
138 Delegation { cmd: &'static str },
139}
140
141#[cfg(test)]
142#[derive(Debug)]
143pub(crate) enum SubEntry {
144 Policy { name: &'static str },
145 Nested { name: &'static str, subs: &'static [SubEntry] },
146 Custom { name: &'static str, valid_suffix: Option<&'static str> },
147 Positional,
148 Guarded { name: &'static str, valid_suffix: &'static str },
149}
150
151use crate::command::CommandDef;
152
153const COMMAND_DEFS: &[&CommandDef] = &[
154 &ruby::BUNDLE,
155 &vcs::GIT,
156];
157
158pub fn all_opencode_patterns() -> Vec<String> {
159 let mut patterns = Vec::new();
160 for def in COMMAND_DEFS {
161 patterns.extend(def.opencode_patterns());
162 }
163 for def in coreutils::all_flat_defs() {
164 patterns.extend(def.opencode_patterns());
165 }
166 for def in jvm::jvm_flat_defs() {
167 patterns.extend(def.opencode_patterns());
168 }
169 for def in android::android_flat_defs() {
170 patterns.extend(def.opencode_patterns());
171 }
172 for def in xcode::xcbeautify_flat_defs() {
173 patterns.extend(def.opencode_patterns());
174 }
175 for def in fuzzy::fuzzy_flat_defs() {
176 patterns.extend(def.opencode_patterns());
177 }
178 patterns.sort();
179 patterns.dedup();
180 patterns
181}
182
183#[cfg(test)]
184fn full_registry() -> Vec<&'static CommandEntry> {
185 let mut entries = Vec::new();
186 entries.extend(shell::REGISTRY);
187 entries.extend(wrappers::REGISTRY);
188 entries.extend(vcs::full_registry());
189 entries.extend(forges::full_registry());
190 entries.extend(node::full_registry());
191 entries.extend(jvm::full_registry());
192 entries.extend(android::full_registry());
193 entries.extend(network::REGISTRY);
194 entries.extend(system::full_registry());
195 entries.extend(xcode::full_registry());
196 entries.extend(perl::REGISTRY);
197 entries.extend(coreutils::full_registry());
198 entries.extend(fuzzy::full_registry());
199 entries
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205 use std::collections::HashSet;
206
207 const UNKNOWN_FLAG: &str = "--xyzzy-unknown-42";
208 const UNKNOWN_SUB: &str = "xyzzy-unknown-42";
209
210 fn check_entry(entry: &CommandEntry, failures: &mut Vec<String>) {
211 match entry {
212 CommandEntry::Positional { .. } | CommandEntry::Delegation { .. } => {}
213 CommandEntry::Custom { cmd, valid_prefix } => {
214 let base = valid_prefix.unwrap_or(cmd);
215 let test = format!("{base} {UNKNOWN_FLAG}");
216 if crate::is_safe_command(&test) {
217 failures.push(format!("{cmd}: accepted unknown flag: {test}"));
218 }
219 }
220 CommandEntry::Subcommand { cmd, subs, bare_ok } => {
221 if !bare_ok && crate::is_safe_command(cmd) {
222 failures.push(format!("{cmd}: accepted bare invocation"));
223 }
224 let test = format!("{cmd} {UNKNOWN_SUB}");
225 if crate::is_safe_command(&test) {
226 failures.push(format!("{cmd}: accepted unknown subcommand: {test}"));
227 }
228 for sub in *subs {
229 check_sub(cmd, sub, failures);
230 }
231 }
232 }
233 }
234
235 fn check_sub(prefix: &str, entry: &SubEntry, failures: &mut Vec<String>) {
236 match entry {
237 SubEntry::Policy { name } => {
238 let test = format!("{prefix} {name} {UNKNOWN_FLAG}");
239 if crate::is_safe_command(&test) {
240 failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
241 }
242 }
243 SubEntry::Nested { name, subs } => {
244 let path = format!("{prefix} {name}");
245 let test = format!("{path} {UNKNOWN_SUB}");
246 if crate::is_safe_command(&test) {
247 failures.push(format!("{path}: accepted unknown subcommand: {test}"));
248 }
249 for sub in *subs {
250 check_sub(&path, sub, failures);
251 }
252 }
253 SubEntry::Custom { name, valid_suffix } => {
254 let base = match valid_suffix {
255 Some(s) => format!("{prefix} {name} {s}"),
256 None => format!("{prefix} {name}"),
257 };
258 let test = format!("{base} {UNKNOWN_FLAG}");
259 if crate::is_safe_command(&test) {
260 failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
261 }
262 }
263 SubEntry::Positional => {}
264 SubEntry::Guarded { name, valid_suffix } => {
265 let test = format!("{prefix} {name} {valid_suffix} {UNKNOWN_FLAG}");
266 if crate::is_safe_command(&test) {
267 failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
268 }
269 }
270 }
271 }
272
273 #[test]
274 fn all_commands_reject_unknown() {
275 let registry = full_registry();
276 let mut failures = Vec::new();
277 for entry in ®istry {
278 check_entry(entry, &mut failures);
279 }
280 assert!(
281 failures.is_empty(),
282 "unknown flags/subcommands accepted:\n{}",
283 failures.join("\n")
284 );
285 }
286
287 #[test]
288 fn command_defs_reject_unknown() {
289 for def in COMMAND_DEFS {
290 def.auto_test_reject_unknown();
291 }
292 }
293
294 #[test]
295 fn flat_defs_reject_unknown() {
296 for def in coreutils::all_flat_defs() {
297 def.auto_test_reject_unknown();
298 }
299 for def in xcode::xcbeautify_flat_defs() {
300 def.auto_test_reject_unknown();
301 }
302 for def in jvm::jvm_flat_defs().into_iter().chain(android::android_flat_defs()).chain(fuzzy::fuzzy_flat_defs()) {
303 def.auto_test_reject_unknown();
304 }
305 }
306
307
308 #[test]
309 fn bare_false_rejects_bare_invocation() {
310 let check_def = |def: &crate::command::FlatDef| {
311 if !def.policy.bare {
312 assert!(
313 !crate::is_safe_command(def.name),
314 "{}: bare=false but bare invocation accepted",
315 def.name,
316 );
317 }
318 };
319 for def in coreutils::all_flat_defs()
320 .into_iter()
321 .chain(xcode::xcbeautify_flat_defs())
322 {
323 check_def(def);
324 }
325 for def in jvm::jvm_flat_defs().into_iter().chain(android::android_flat_defs()).chain(fuzzy::fuzzy_flat_defs()) {
326 check_def(def);
327 }
328 }
329
330 fn visit_subs(prefix: &str, subs: &[crate::command::SubDef], visitor: &mut dyn FnMut(&str, &crate::command::SubDef)) {
331 for sub in subs {
332 visitor(prefix, sub);
333 if let crate::command::SubDef::Nested { name, subs: inner } = sub {
334 visit_subs(&format!("{prefix} {name}"), inner, visitor);
335 }
336 }
337 }
338
339 #[test]
340 fn guarded_subs_require_guard() {
341 let mut failures = Vec::new();
342 for def in COMMAND_DEFS {
343 visit_subs(def.name, def.subs, &mut |prefix, sub| {
344 if let crate::command::SubDef::Guarded { name, guard_long, .. } = sub {
345 let without = format!("{prefix} {name}");
346 if crate::is_safe_command(&without) {
347 failures.push(format!("{without}: accepted without guard {guard_long}"));
348 }
349 let with = format!("{prefix} {name} {guard_long}");
350 if !crate::is_safe_command(&with) {
351 failures.push(format!("{with}: rejected with guard {guard_long}"));
352 }
353 }
354 });
355 }
356 assert!(failures.is_empty(), "guarded sub issues:\n{}", failures.join("\n"));
357 }
358
359 #[test]
360 fn guarded_subs_accept_guard_short() {
361 let mut failures = Vec::new();
362 for def in COMMAND_DEFS {
363 visit_subs(def.name, def.subs, &mut |prefix, sub| {
364 if let crate::command::SubDef::Guarded { name, guard_short: Some(short), .. } = sub {
365 let with_short = format!("{prefix} {name} {short}");
366 if !crate::is_safe_command(&with_short) {
367 failures.push(format!("{with_short}: rejected with guard_short"));
368 }
369 }
370 });
371 }
372 assert!(failures.is_empty(), "guard_short issues:\n{}", failures.join("\n"));
373 }
374
375 #[test]
376 fn nested_subs_reject_bare() {
377 let mut failures = Vec::new();
378 for def in COMMAND_DEFS {
379 visit_subs(def.name, def.subs, &mut |prefix, sub| {
380 if let crate::command::SubDef::Nested { name, .. } = sub {
381 let bare = format!("{prefix} {name}");
382 if crate::is_safe_command(&bare) {
383 failures.push(format!("{bare}: nested sub accepted bare invocation"));
384 }
385 }
386 });
387 }
388 assert!(failures.is_empty(), "nested bare issues:\n{}", failures.join("\n"));
389 }
390
391 #[test]
392 fn process_substitution_blocked() {
393 let cmds = ["echo <(cat /etc/passwd)", "echo >(rm -rf /)", "grep pattern <(ls)"];
394 for cmd in &cmds {
395 assert!(
396 !crate::is_safe_command(cmd),
397 "process substitution not blocked: {cmd}",
398 );
399 }
400 }
401
402 #[test]
403 fn positional_style_accepts_unknown_args() {
404 use crate::policy::FlagStyle;
405 for def in coreutils::all_flat_defs() {
406 if def.policy.flag_style == FlagStyle::Positional {
407 let test = format!("{} --unknown-xyz", def.name);
408 assert!(
409 crate::is_safe_command(&test),
410 "{}: FlagStyle::Positional but rejected unknown arg",
411 def.name,
412 );
413 }
414 }
415 }
416
417 fn visit_policies(prefix: &str, subs: &[crate::command::SubDef], visitor: &mut dyn FnMut(&str, &crate::policy::FlagPolicy)) {
418 for sub in subs {
419 match sub {
420 crate::command::SubDef::Policy { name, policy, .. } => {
421 visitor(&format!("{prefix} {name}"), policy);
422 }
423 crate::command::SubDef::Guarded { name, guard_long, policy, .. } => {
424 visitor(&format!("{prefix} {name} {guard_long}"), policy);
425 }
426 crate::command::SubDef::Nested { name, subs: inner } => {
427 visit_policies(&format!("{prefix} {name}"), inner, visitor);
428 }
429 _ => {}
430 }
431 }
432 }
433
434 #[test]
435 fn valued_flags_accept_eq_syntax() {
436 let mut failures = Vec::new();
437
438 let check_flat = |def: &crate::command::FlatDef, failures: &mut Vec<String>| {
439 for flag in def.policy.valued.iter() {
440 let cmd = format!("{} {flag}=test_value", def.name);
441 if !crate::is_safe_command(&cmd) {
442 failures.push(format!("{cmd}: valued flag rejected with = syntax"));
443 }
444 }
445 };
446 for def in coreutils::all_flat_defs()
447 .into_iter()
448 .chain(xcode::xcbeautify_flat_defs())
449 {
450 check_flat(def, &mut failures);
451 }
452 for def in jvm::jvm_flat_defs().into_iter().chain(android::android_flat_defs()).chain(fuzzy::fuzzy_flat_defs()) {
453 check_flat(def, &mut failures);
454 }
455
456 for def in COMMAND_DEFS {
457 visit_policies(def.name, def.subs, &mut |prefix, policy| {
458 for flag in policy.valued.iter() {
459 let cmd = format!("{prefix} {flag}=test_value");
460 if !crate::is_safe_command(&cmd) {
461 failures.push(format!("{cmd}: valued flag rejected with = syntax"));
462 }
463 }
464 });
465 }
466
467 assert!(failures.is_empty(), "valued = syntax issues:\n{}", failures.join("\n"));
468 }
469
470 #[test]
471 fn max_positional_enforced() {
472 let mut failures = Vec::new();
473
474 let check_flat = |def: &crate::command::FlatDef, failures: &mut Vec<String>| {
475 if let Some(max) = def.policy.max_positional {
476 let args: Vec<&str> = (0..=max).map(|_| "testarg").collect();
477 let cmd = format!("{} {}", def.name, args.join(" "));
478 if crate::is_safe_command(&cmd) {
479 failures.push(format!(
480 "{}: max_positional={max} but accepted {} positional args",
481 def.name,
482 max + 1,
483 ));
484 }
485 }
486 };
487 for def in coreutils::all_flat_defs()
488 .into_iter()
489 .chain(xcode::xcbeautify_flat_defs())
490 {
491 check_flat(def, &mut failures);
492 }
493 for def in jvm::jvm_flat_defs().into_iter().chain(android::android_flat_defs()).chain(fuzzy::fuzzy_flat_defs()) {
494 check_flat(def, &mut failures);
495 }
496
497 for def in COMMAND_DEFS {
498 visit_policies(def.name, def.subs, &mut |prefix, policy| {
499 if let Some(max) = policy.max_positional {
500 let args: Vec<&str> = (0..=max).map(|_| "testarg").collect();
501 let cmd = format!("{prefix} {}", args.join(" "));
502 if crate::is_safe_command(&cmd) {
503 failures.push(format!(
504 "{prefix}: max_positional={max} but accepted {} positional args",
505 max + 1,
506 ));
507 }
508 }
509 });
510 }
511
512 assert!(failures.is_empty(), "max_positional issues:\n{}", failures.join("\n"));
513 }
514
515 #[test]
516 fn doc_generation_non_empty() {
517 let mut failures = Vec::new();
518
519 for def in COMMAND_DEFS {
520 let doc = def.to_doc();
521 if doc.description.trim().is_empty() {
522 failures.push(format!("{}: CommandDef produced empty doc", def.name));
523 }
524 if doc.url.is_empty() {
525 failures.push(format!("{}: CommandDef has empty URL", def.name));
526 }
527 }
528
529 let check_flat = |def: &crate::command::FlatDef, failures: &mut Vec<String>| {
530 let doc = def.to_doc();
531 if doc.description.trim().is_empty() && !def.policy.bare {
532 failures.push(format!("{}: FlatDef produced empty doc", def.name));
533 }
534 if doc.url.is_empty() {
535 failures.push(format!("{}: FlatDef has empty URL", def.name));
536 }
537 };
538 for def in coreutils::all_flat_defs()
539 .into_iter()
540 .chain(xcode::xcbeautify_flat_defs())
541 {
542 check_flat(def, &mut failures);
543 }
544 for def in jvm::jvm_flat_defs().into_iter().chain(android::android_flat_defs()).chain(fuzzy::fuzzy_flat_defs()) {
545 check_flat(def, &mut failures);
546 }
547
548 assert!(failures.is_empty(), "doc generation issues:\n{}", failures.join("\n"));
549 }
550
551 #[test]
552 fn registry_covers_handled_commands() {
553 let registry = full_registry();
554 let mut all_cmds: HashSet<&str> = registry
555 .iter()
556 .map(|e| match e {
557 CommandEntry::Positional { cmd }
558 | CommandEntry::Custom { cmd, .. }
559 | CommandEntry::Subcommand { cmd, .. }
560 | CommandEntry::Delegation { cmd } => *cmd,
561 })
562 .collect();
563 for def in COMMAND_DEFS {
564 all_cmds.insert(def.name);
565 }
566 for def in coreutils::all_flat_defs() {
567 all_cmds.insert(def.name);
568 }
569 for def in xcode::xcbeautify_flat_defs() {
570 all_cmds.insert(def.name);
571 }
572 for def in jvm::jvm_flat_defs().into_iter().chain(android::android_flat_defs()).chain(fuzzy::fuzzy_flat_defs()) {
573 all_cmds.insert(def.name);
574 }
575 for name in crate::registry::toml_command_names() {
576 all_cmds.insert(name);
577 }
578 let handled: HashSet<&str> = HANDLED_CMDS.iter().copied().collect();
579
580 let missing: Vec<_> = handled.difference(&all_cmds).collect();
581 assert!(missing.is_empty(), "not in registry or COMMAND_DEFS: {missing:?}");
582
583 let extra: Vec<_> = all_cmds.difference(&handled).collect();
584 assert!(extra.is_empty(), "not in HANDLED_CMDS: {extra:?}");
585 }
586
587}