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::new()
31}
32
33pub fn dispatch(tokens: &[Token]) -> Verdict {
34 let cmd = tokens[0].command_name();
35 None
36 .or_else(|| shell::dispatch(cmd, tokens))
37 .or_else(|| wrappers::dispatch(cmd, tokens))
38 .or_else(|| vcs::dispatch(cmd, tokens))
39 .or_else(|| forges::dispatch(cmd, tokens))
40 .or_else(|| node::dispatch(cmd, tokens))
41 .or_else(|| ruby::dispatch(cmd, tokens))
42 .or_else(|| jvm::dispatch(cmd, tokens))
43 .or_else(|| android::dispatch(cmd, tokens))
44 .or_else(|| network::dispatch(cmd, tokens))
45 .or_else(|| system::dispatch(cmd, tokens))
46 .or_else(|| xcode::dispatch(cmd, tokens))
47 .or_else(|| perl::dispatch(cmd, tokens))
48 .or_else(|| coreutils::dispatch(cmd, tokens))
49 .or_else(|| fuzzy::dispatch(cmd, tokens))
50 .or_else(|| crate::registry::toml_dispatch(tokens))
51 .unwrap_or(Verdict::Denied)
52}
53
54#[cfg(test)]
55const HANDLED_CMDS: &[&str] = &[
56 "sh", "bash", "xargs", "timeout", "time", "env", "nice", "ionice", "hyperfine", "dotenv",
57 "git", "jj", "gh", "glab", "jjpr", "tea",
58 "npm", "yarn", "pnpm", "bun", "deno", "npx", "bunx", "nvm", "fnm", "volta",
59 "ruby", "ri", "bundle", "gem", "importmap", "rbenv",
60 "pip", "uv", "poetry", "pyenv", "conda",
61 "cargo", "rustup",
62 "go",
63 "gradle", "mvn", "mvnw", "ktlint", "detekt",
64 "javap", "jar", "keytool", "jarsigner",
65 "adb", "apkanalyzer", "apksigner", "bundletool", "aapt2",
66 "emulator", "avdmanager", "sdkmanager", "zipalign", "lint",
67 "fastlane", "firebase",
68 "composer", "craft",
69 "swift",
70 "dotnet",
71 "curl",
72 "docker", "podman", "kubectl", "orbctl", "orb", "qemu-img",
73 "ollama", "llm", "hf", "claude", "aider", "codex", "opencode", "vibe",
74 "ddev", "dcli",
75 "brew", "mise", "asdf", "crontab", "defaults", "pmset", "sysctl", "cmake", "psql", "pg_isready",
76 "terraform", "heroku", "vercel", "flyctl",
77 "overmind", "tailscale", "tmux", "wg",
78 "networksetup", "launchctl", "diskutil", "security", "csrutil", "log",
79 "xcodebuild", "plutil", "xcode-select", "xcrun", "pkgutil", "lipo", "codesign", "spctl",
80 "xcodegen", "tuist", "pod", "swiftlint", "swiftformat", "periphery", "xcbeautify", "agvtool", "simctl",
81 "perl",
82 "R", "Rscript",
83 "grep", "egrep", "fgrep", "rg", "ag", "ack", "zgrep", "zegrep", "zfgrep", "locate", "mlocate", "plocate",
84 "cat", "gzcat", "head", "tail", "wc", "cut", "tr", "uniq", "less", "more", "zcat",
85 "diff", "comm", "paste", "tac", "rev", "nl",
86 "expand", "unexpand", "fold", "fmt", "col", "column", "iconv", "nroff",
87 "echo", "printf", "seq", "test", "[", "expr", "bc", "factor", "bat",
88 "arch", "command", "hostname",
89 "find", "sed", "shuf", "sort", "yq", "xmllint", "awk", "gawk", "mawk", "nawk",
90 "magick",
91 "fd", "eza", "exa", "ls", "delta", "colordiff",
92 "dirname", "basename", "realpath", "readlink",
93 "file", "stat", "du", "df", "tree", "cmp", "zipinfo", "tar", "unzip", "gzip",
94 "true", "false",
95 "alias", "export", "printenv", "read", "type", "wait", "whereis", "which", "whoami", "date", "pwd", "cd", "unset",
96 "uname", "nproc", "uptime", "id", "groups", "tty", "locale", "cal", "sleep",
97 "who", "w", "last", "lastlog",
98 "ps", "top", "htop", "iotop", "procs", "dust", "lsof", "pgrep", "lsblk", "free",
99 "jq", "jaq", "gojq", "fx", "jless", "htmlq", "xq", "tomlq", "mlr", "dasel",
100 "base64", "xxd", "getconf", "uuidgen",
101 "md5sum", "md5", "sha256sum", "shasum", "sha1sum", "sha512sum",
102 "cksum", "b2sum", "sum", "strings", "hexdump", "od", "size", "sips",
103 "sw_vers", "mdls", "otool", "nm", "system_profiler", "ioreg", "vm_stat", "mdfind", "man",
104 "dig", "nslookup", "host", "whois", "netstat", "ss", "ifconfig", "route", "ping",
105 "xv",
106 "fzf", "fzy", "peco", "pick", "selecta", "sk", "zf",
107 "identify", "shellcheck", "cloc", "tokei", "cucumber", "branchdiff", "workon", "safe-chains",
108];
109
110pub fn handler_docs() -> Vec<crate::docs::CommandDoc> {
111 let mut docs = Vec::new();
112 docs.extend(vcs::command_docs());
113 docs.extend(forges::command_docs());
114 docs.extend(node::command_docs());
115 docs.extend(ruby::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(xcode::command_docs());
121 docs.extend(perl::command_docs());
122 docs.extend(coreutils::command_docs());
123 docs.extend(fuzzy::command_docs());
124 docs.extend(shell::command_docs());
125 docs.extend(wrappers::command_docs());
126 docs.extend(crate::registry::toml_command_docs());
127 docs
128}
129
130#[cfg(test)]
131#[derive(Debug)]
132pub(crate) enum CommandEntry {
133 Positional { cmd: &'static str },
134 Custom { cmd: &'static str, valid_prefix: Option<&'static str> },
135 Subcommand { cmd: &'static str, subs: &'static [SubEntry], bare_ok: bool },
136 Delegation { cmd: &'static str },
137}
138
139#[cfg(test)]
140#[derive(Debug)]
141pub(crate) enum SubEntry {
142 Policy { name: &'static str },
143 Nested { name: &'static str, subs: &'static [SubEntry] },
144 Custom { name: &'static str, valid_suffix: Option<&'static str> },
145 Positional,
146 Guarded { name: &'static str, valid_suffix: &'static str },
147}
148
149use crate::command::CommandDef;
150
151const COMMAND_DEFS: &[&CommandDef] = &[
152 &node::BUN,
153 &ruby::BUNDLE,
154 &vcs::GIT,
155];
156
157pub fn all_opencode_patterns() -> Vec<String> {
158 let mut patterns = Vec::new();
159 for def in COMMAND_DEFS {
160 patterns.extend(def.opencode_patterns());
161 }
162 for def in coreutils::all_flat_defs() {
163 patterns.extend(def.opencode_patterns());
164 }
165 for def in jvm::jvm_flat_defs() {
166 patterns.extend(def.opencode_patterns());
167 }
168 for def in android::android_flat_defs() {
169 patterns.extend(def.opencode_patterns());
170 }
171 for def in xcode::xcbeautify_flat_defs() {
172 patterns.extend(def.opencode_patterns());
173 }
174 for def in fuzzy::fuzzy_flat_defs() {
175 patterns.extend(def.opencode_patterns());
176 }
177 patterns.sort();
178 patterns.dedup();
179 patterns
180}
181
182#[cfg(test)]
183fn full_registry() -> Vec<&'static CommandEntry> {
184 let mut entries = Vec::new();
185 entries.extend(shell::REGISTRY);
186 entries.extend(wrappers::REGISTRY);
187 entries.extend(vcs::full_registry());
188 entries.extend(forges::full_registry());
189 entries.extend(node::full_registry());
190 entries.extend(jvm::full_registry());
191 entries.extend(android::full_registry());
192 entries.extend(network::REGISTRY);
193 entries.extend(system::full_registry());
194 entries.extend(xcode::full_registry());
195 entries.extend(perl::REGISTRY);
196 entries.extend(coreutils::full_registry());
197 entries.extend(fuzzy::full_registry());
198 entries
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204 use std::collections::HashSet;
205
206 const UNKNOWN_FLAG: &str = "--xyzzy-unknown-42";
207 const UNKNOWN_SUB: &str = "xyzzy-unknown-42";
208
209 fn check_entry(entry: &CommandEntry, failures: &mut Vec<String>) {
210 match entry {
211 CommandEntry::Positional { .. } | CommandEntry::Delegation { .. } => {}
212 CommandEntry::Custom { cmd, valid_prefix } => {
213 let base = valid_prefix.unwrap_or(cmd);
214 let test = format!("{base} {UNKNOWN_FLAG}");
215 if crate::is_safe_command(&test) {
216 failures.push(format!("{cmd}: accepted unknown flag: {test}"));
217 }
218 }
219 CommandEntry::Subcommand { cmd, subs, bare_ok } => {
220 if !bare_ok && crate::is_safe_command(cmd) {
221 failures.push(format!("{cmd}: accepted bare invocation"));
222 }
223 let test = format!("{cmd} {UNKNOWN_SUB}");
224 if crate::is_safe_command(&test) {
225 failures.push(format!("{cmd}: accepted unknown subcommand: {test}"));
226 }
227 for sub in *subs {
228 check_sub(cmd, sub, failures);
229 }
230 }
231 }
232 }
233
234 fn check_sub(prefix: &str, entry: &SubEntry, failures: &mut Vec<String>) {
235 match entry {
236 SubEntry::Policy { name } => {
237 let test = format!("{prefix} {name} {UNKNOWN_FLAG}");
238 if crate::is_safe_command(&test) {
239 failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
240 }
241 }
242 SubEntry::Nested { name, subs } => {
243 let path = format!("{prefix} {name}");
244 let test = format!("{path} {UNKNOWN_SUB}");
245 if crate::is_safe_command(&test) {
246 failures.push(format!("{path}: accepted unknown subcommand: {test}"));
247 }
248 for sub in *subs {
249 check_sub(&path, sub, failures);
250 }
251 }
252 SubEntry::Custom { name, valid_suffix } => {
253 let base = match valid_suffix {
254 Some(s) => format!("{prefix} {name} {s}"),
255 None => format!("{prefix} {name}"),
256 };
257 let test = format!("{base} {UNKNOWN_FLAG}");
258 if crate::is_safe_command(&test) {
259 failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
260 }
261 }
262 SubEntry::Positional => {}
263 SubEntry::Guarded { name, valid_suffix } => {
264 let test = format!("{prefix} {name} {valid_suffix} {UNKNOWN_FLAG}");
265 if crate::is_safe_command(&test) {
266 failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
267 }
268 }
269 }
270 }
271
272 #[test]
273 fn all_commands_reject_unknown() {
274 let registry = full_registry();
275 let mut failures = Vec::new();
276 for entry in ®istry {
277 check_entry(entry, &mut failures);
278 }
279 assert!(
280 failures.is_empty(),
281 "unknown flags/subcommands accepted:\n{}",
282 failures.join("\n")
283 );
284 }
285
286 #[test]
287 fn command_defs_reject_unknown() {
288 for def in COMMAND_DEFS {
289 def.auto_test_reject_unknown();
290 }
291 }
292
293 #[test]
294 fn flat_defs_reject_unknown() {
295 for def in coreutils::all_flat_defs() {
296 def.auto_test_reject_unknown();
297 }
298 for def in xcode::xcbeautify_flat_defs() {
299 def.auto_test_reject_unknown();
300 }
301 for def in jvm::jvm_flat_defs().into_iter().chain(android::android_flat_defs()).chain(fuzzy::fuzzy_flat_defs()) {
302 def.auto_test_reject_unknown();
303 }
304 }
305
306
307 #[test]
308 fn bare_false_rejects_bare_invocation() {
309 let check_def = |def: &crate::command::FlatDef| {
310 if !def.policy.bare {
311 assert!(
312 !crate::is_safe_command(def.name),
313 "{}: bare=false but bare invocation accepted",
314 def.name,
315 );
316 }
317 };
318 for def in coreutils::all_flat_defs()
319 .into_iter()
320 .chain(xcode::xcbeautify_flat_defs())
321 {
322 check_def(def);
323 }
324 for def in jvm::jvm_flat_defs().into_iter().chain(android::android_flat_defs()).chain(fuzzy::fuzzy_flat_defs()) {
325 check_def(def);
326 }
327 }
328
329 fn visit_subs(prefix: &str, subs: &[crate::command::SubDef], visitor: &mut dyn FnMut(&str, &crate::command::SubDef)) {
330 for sub in subs {
331 visitor(prefix, sub);
332 if let crate::command::SubDef::Nested { name, subs: inner } = sub {
333 visit_subs(&format!("{prefix} {name}"), inner, visitor);
334 }
335 }
336 }
337
338 #[test]
339 fn guarded_subs_require_guard() {
340 let mut failures = Vec::new();
341 for def in COMMAND_DEFS {
342 visit_subs(def.name, def.subs, &mut |prefix, sub| {
343 if let crate::command::SubDef::Guarded { name, guard_long, .. } = sub {
344 let without = format!("{prefix} {name}");
345 if crate::is_safe_command(&without) {
346 failures.push(format!("{without}: accepted without guard {guard_long}"));
347 }
348 let with = format!("{prefix} {name} {guard_long}");
349 if !crate::is_safe_command(&with) {
350 failures.push(format!("{with}: rejected with guard {guard_long}"));
351 }
352 }
353 });
354 }
355 assert!(failures.is_empty(), "guarded sub issues:\n{}", failures.join("\n"));
356 }
357
358 #[test]
359 fn guarded_subs_accept_guard_short() {
360 let mut failures = Vec::new();
361 for def in COMMAND_DEFS {
362 visit_subs(def.name, def.subs, &mut |prefix, sub| {
363 if let crate::command::SubDef::Guarded { name, guard_short: Some(short), .. } = sub {
364 let with_short = format!("{prefix} {name} {short}");
365 if !crate::is_safe_command(&with_short) {
366 failures.push(format!("{with_short}: rejected with guard_short"));
367 }
368 }
369 });
370 }
371 assert!(failures.is_empty(), "guard_short issues:\n{}", failures.join("\n"));
372 }
373
374 #[test]
375 fn nested_subs_reject_bare() {
376 let mut failures = Vec::new();
377 for def in COMMAND_DEFS {
378 visit_subs(def.name, def.subs, &mut |prefix, sub| {
379 if let crate::command::SubDef::Nested { name, .. } = sub {
380 let bare = format!("{prefix} {name}");
381 if crate::is_safe_command(&bare) {
382 failures.push(format!("{bare}: nested sub accepted bare invocation"));
383 }
384 }
385 });
386 }
387 assert!(failures.is_empty(), "nested bare issues:\n{}", failures.join("\n"));
388 }
389
390 #[test]
391 fn process_substitution_blocked() {
392 let cmds = ["echo <(cat /etc/passwd)", "echo >(rm -rf /)", "grep pattern <(ls)"];
393 for cmd in &cmds {
394 assert!(
395 !crate::is_safe_command(cmd),
396 "process substitution not blocked: {cmd}",
397 );
398 }
399 }
400
401 #[test]
402 fn positional_style_accepts_unknown_args() {
403 use crate::policy::FlagStyle;
404 for def in coreutils::all_flat_defs() {
405 if def.policy.flag_style == FlagStyle::Positional {
406 let test = format!("{} --unknown-xyz", def.name);
407 assert!(
408 crate::is_safe_command(&test),
409 "{}: FlagStyle::Positional but rejected unknown arg",
410 def.name,
411 );
412 }
413 }
414 }
415
416 fn visit_policies(prefix: &str, subs: &[crate::command::SubDef], visitor: &mut dyn FnMut(&str, &crate::policy::FlagPolicy)) {
417 for sub in subs {
418 match sub {
419 crate::command::SubDef::Policy { name, policy, .. } => {
420 visitor(&format!("{prefix} {name}"), policy);
421 }
422 crate::command::SubDef::Guarded { name, guard_long, policy, .. } => {
423 visitor(&format!("{prefix} {name} {guard_long}"), policy);
424 }
425 crate::command::SubDef::Nested { name, subs: inner } => {
426 visit_policies(&format!("{prefix} {name}"), inner, visitor);
427 }
428 _ => {}
429 }
430 }
431 }
432
433 #[test]
434 fn valued_flags_accept_eq_syntax() {
435 let mut failures = Vec::new();
436
437 let check_flat = |def: &crate::command::FlatDef, failures: &mut Vec<String>| {
438 for flag in def.policy.valued.iter() {
439 let cmd = format!("{} {flag}=test_value", def.name);
440 if !crate::is_safe_command(&cmd) {
441 failures.push(format!("{cmd}: valued flag rejected with = syntax"));
442 }
443 }
444 };
445 for def in coreutils::all_flat_defs()
446 .into_iter()
447 .chain(xcode::xcbeautify_flat_defs())
448 {
449 check_flat(def, &mut failures);
450 }
451 for def in jvm::jvm_flat_defs().into_iter().chain(android::android_flat_defs()).chain(fuzzy::fuzzy_flat_defs()) {
452 check_flat(def, &mut failures);
453 }
454
455 for def in COMMAND_DEFS {
456 visit_policies(def.name, def.subs, &mut |prefix, policy| {
457 for flag in policy.valued.iter() {
458 let cmd = format!("{prefix} {flag}=test_value");
459 if !crate::is_safe_command(&cmd) {
460 failures.push(format!("{cmd}: valued flag rejected with = syntax"));
461 }
462 }
463 });
464 }
465
466 assert!(failures.is_empty(), "valued = syntax issues:\n{}", failures.join("\n"));
467 }
468
469 #[test]
470 fn max_positional_enforced() {
471 let mut failures = Vec::new();
472
473 let check_flat = |def: &crate::command::FlatDef, failures: &mut Vec<String>| {
474 if let Some(max) = def.policy.max_positional {
475 let args: Vec<&str> = (0..=max).map(|_| "testarg").collect();
476 let cmd = format!("{} {}", def.name, args.join(" "));
477 if crate::is_safe_command(&cmd) {
478 failures.push(format!(
479 "{}: max_positional={max} but accepted {} positional args",
480 def.name,
481 max + 1,
482 ));
483 }
484 }
485 };
486 for def in coreutils::all_flat_defs()
487 .into_iter()
488 .chain(xcode::xcbeautify_flat_defs())
489 {
490 check_flat(def, &mut failures);
491 }
492 for def in jvm::jvm_flat_defs().into_iter().chain(android::android_flat_defs()).chain(fuzzy::fuzzy_flat_defs()) {
493 check_flat(def, &mut failures);
494 }
495
496 for def in COMMAND_DEFS {
497 visit_policies(def.name, def.subs, &mut |prefix, policy| {
498 if let Some(max) = policy.max_positional {
499 let args: Vec<&str> = (0..=max).map(|_| "testarg").collect();
500 let cmd = format!("{prefix} {}", args.join(" "));
501 if crate::is_safe_command(&cmd) {
502 failures.push(format!(
503 "{prefix}: max_positional={max} but accepted {} positional args",
504 max + 1,
505 ));
506 }
507 }
508 });
509 }
510
511 assert!(failures.is_empty(), "max_positional issues:\n{}", failures.join("\n"));
512 }
513
514 #[test]
515 fn doc_generation_non_empty() {
516 let mut failures = Vec::new();
517
518 for def in COMMAND_DEFS {
519 let doc = def.to_doc();
520 if doc.description.trim().is_empty() {
521 failures.push(format!("{}: CommandDef produced empty doc", def.name));
522 }
523 if doc.url.is_empty() {
524 failures.push(format!("{}: CommandDef has empty URL", def.name));
525 }
526 }
527
528 let check_flat = |def: &crate::command::FlatDef, failures: &mut Vec<String>| {
529 let doc = def.to_doc();
530 if doc.description.trim().is_empty() && !def.policy.bare {
531 failures.push(format!("{}: FlatDef produced empty doc", def.name));
532 }
533 if doc.url.is_empty() {
534 failures.push(format!("{}: FlatDef has empty URL", def.name));
535 }
536 };
537 for def in coreutils::all_flat_defs()
538 .into_iter()
539 .chain(xcode::xcbeautify_flat_defs())
540 {
541 check_flat(def, &mut failures);
542 }
543 for def in jvm::jvm_flat_defs().into_iter().chain(android::android_flat_defs()).chain(fuzzy::fuzzy_flat_defs()) {
544 check_flat(def, &mut failures);
545 }
546
547 assert!(failures.is_empty(), "doc generation issues:\n{}", failures.join("\n"));
548 }
549
550 #[test]
551 fn registry_covers_handled_commands() {
552 let registry = full_registry();
553 let mut all_cmds: HashSet<&str> = registry
554 .iter()
555 .map(|e| match e {
556 CommandEntry::Positional { cmd }
557 | CommandEntry::Custom { cmd, .. }
558 | CommandEntry::Subcommand { cmd, .. }
559 | CommandEntry::Delegation { cmd } => *cmd,
560 })
561 .collect();
562 for def in COMMAND_DEFS {
563 all_cmds.insert(def.name);
564 }
565 for def in coreutils::all_flat_defs() {
566 all_cmds.insert(def.name);
567 }
568 for def in xcode::xcbeautify_flat_defs() {
569 all_cmds.insert(def.name);
570 }
571 for def in jvm::jvm_flat_defs().into_iter().chain(android::android_flat_defs()).chain(fuzzy::fuzzy_flat_defs()) {
572 all_cmds.insert(def.name);
573 }
574 for name in crate::registry::toml_command_names() {
575 all_cmds.insert(name);
576 }
577 let handled: HashSet<&str> = HANDLED_CMDS.iter().copied().collect();
578
579 let missing: Vec<_> = handled.difference(&all_cmds).collect();
580 assert!(missing.is_empty(), "not in registry or COMMAND_DEFS: {missing:?}");
581
582 let extra: Vec<_> = all_cmds.difference(&handled).collect();
583 assert!(extra.is_empty(), "not in HANDLED_CMDS: {extra:?}");
584 }
585
586}