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