Skip to main content

safe_chains/handlers/
mod.rs

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 &registry {
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}