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