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;
14
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(|| forges::dispatch(cmd, tokens))
44        .or_else(|| node::dispatch(cmd, tokens))
45        .or_else(|| jvm::dispatch(cmd, tokens))
46        .or_else(|| android::dispatch(cmd, tokens))
47        .or_else(|| network::dispatch(cmd, tokens))
48        .or_else(|| system::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", "helm", "skopeo", "crane", "cosign",
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    "pg_dump", "bazel", "meson", "ninja",
79    "terraform", "heroku", "vercel", "flyctl", "pulumi", "netlify", "railway", "wrangler", "cf", "newrelic",
80    "aws", "gcloud", "az",
81    "doctl", "hcloud", "vultr-cli", "exo", "scw", "linode-cli",
82    "ansible-playbook", "ansible-inventory", "ansible-doc", "ansible-config", "ansible-galaxy",
83    "overmind", "tailscale", "tmux", "wg", "systemctl", "journalctl",
84    "networksetup", "launchctl", "diskutil", "security", "csrutil", "log",
85    "xcodebuild", "plutil", "xcode-select", "xcrun", "pkgutil", "lipo", "codesign", "spctl",
86    "xcodegen", "tuist", "pod", "swiftlint", "swiftformat", "periphery", "xcbeautify", "agvtool", "simctl",
87    "perl",
88    "R", "Rscript",
89    "grep", "egrep", "fgrep", "rg", "ag", "ack", "zgrep", "zegrep", "zfgrep", "locate", "mlocate", "plocate",
90    "cat", "gzcat", "head", "tail", "wc", "cut", "tr", "uniq", "less", "more", "zcat",
91    "diff", "comm", "paste", "tac", "rev", "nl",
92    "expand", "unexpand", "fold", "fmt", "col", "column", "iconv", "nroff",
93    "echo", "printf", "seq", "test", "[", "expr", "bc", "factor", "bat",
94    "arch", "command", "hostname",
95    "find", "sed", "shuf", "sort", "yq", "xmllint", "awk", "gawk", "mawk", "nawk",
96    "magick",
97    "fd", "eza", "exa", "ls", "delta", "colordiff",
98    "dirname", "basename", "realpath", "readlink",
99    "file", "stat", "du", "df", "tree", "cmp", "zipinfo", "tar", "unzip", "gzip",
100    "true", "false",
101    "alias", "export", "printenv", "read", "type", "wait", "whereis", "which", "whoami", "date", "pwd", "cd", "unset",
102    "uname", "nproc", "uptime", "id", "groups", "tty", "locale", "cal", "sleep",
103    "who", "w", "last", "lastlog",
104    "ps", "top", "htop", "iotop", "procs", "dust", "lsof", "pgrep", "lsblk", "free",
105    "jq", "jaq", "gojq", "fx", "jless", "htmlq", "xq", "tomlq", "mlr", "dasel",
106    "base64", "xxd", "getconf", "uuidgen",
107    "md5sum", "md5", "sha256sum", "shasum", "sha1sum", "sha512sum",
108    "cksum", "b2sum", "sum", "strings", "hexdump", "od", "size", "sips",
109    "sw_vers", "mdls", "otool", "nm", "system_profiler", "ioreg", "vm_stat", "mdfind", "man",
110    "dig", "nslookup", "host", "whois", "netstat", "ss", "ifconfig", "route", "ping",
111    "traceroute", "traceroute6", "mtr",
112    "xv",
113    "fzf", "fzy", "peco", "pick", "selecta", "sk", "zf",
114    "identify", "shellcheck", "cloc", "tokei", "cucumber", "branchdiff", "workon", "safe-chains",
115    "tldr", "ldd", "objdump", "readelf", "just",
116    "prettier", "black", "ruff", "mypy", "pyright", "pylint", "flake8", "isort",
117    "rubocop", "eslint", "biome", "stylelint", "zoxide",
118    "direnv", "make", "packer", "vagrant",
119    "node", "python3", "python", "rustc", "java", "php",
120    "gcc", "g++", "cc", "c++", "clang", "clang++",
121    "elixir", "erl", "mix", "zig", "lua", "tsc",
122    "jc", "gron", "difft", "difftastic", "duf", "xsv", "qsv",
123    "git-lfs",
124];
125
126pub fn handler_docs() -> Vec<crate::docs::CommandDoc> {
127    let mut docs = Vec::new();
128    docs.extend(forges::command_docs());
129    docs.extend(node::command_docs());
130    docs.extend(jvm::command_docs());
131    docs.extend(android::command_docs());
132    docs.extend(network::command_docs());
133    docs.extend(system::command_docs());
134    docs.extend(perl::command_docs());
135    docs.extend(coreutils::command_docs());
136    docs.extend(fuzzy::command_docs());
137    docs.extend(shell::command_docs());
138    docs.extend(wrappers::command_docs());
139    docs.extend(crate::registry::toml_command_docs());
140    docs
141}
142
143#[cfg(test)]
144#[derive(Debug)]
145pub(crate) enum CommandEntry {
146    Positional { cmd: &'static str },
147    Custom { cmd: &'static str, valid_prefix: Option<&'static str> },
148    Subcommand { cmd: &'static str, subs: &'static [SubEntry], bare_ok: bool },
149    Delegation { cmd: &'static str },
150}
151
152#[cfg(test)]
153#[derive(Debug)]
154pub(crate) enum SubEntry {
155    Policy { name: &'static str },
156    Nested { name: &'static str, subs: &'static [SubEntry] },
157    Custom { name: &'static str, valid_suffix: Option<&'static str> },
158    Positional,
159    Guarded { name: &'static str, valid_suffix: &'static str },
160}
161
162pub fn all_opencode_patterns() -> Vec<String> {
163    let mut patterns = Vec::new();
164    patterns.sort();
165    patterns.dedup();
166    patterns
167}
168
169#[cfg(test)]
170fn full_registry() -> Vec<&'static CommandEntry> {
171    let mut entries = Vec::new();
172    entries.extend(shell::REGISTRY);
173    entries.extend(wrappers::REGISTRY);
174    entries.extend(forges::full_registry());
175    entries.extend(node::full_registry());
176    entries.extend(jvm::full_registry());
177    entries.extend(android::full_registry());
178    entries.extend(network::REGISTRY);
179    entries.extend(system::full_registry());
180    entries.extend(perl::REGISTRY);
181    entries.extend(coreutils::full_registry());
182    entries.extend(fuzzy::full_registry());
183    entries
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use std::collections::HashSet;
190
191    const UNKNOWN_FLAG: &str = "--xyzzy-unknown-42";
192    const UNKNOWN_SUB: &str = "xyzzy-unknown-42";
193
194    fn check_entry(entry: &CommandEntry, failures: &mut Vec<String>) {
195        match entry {
196            CommandEntry::Positional { .. } | CommandEntry::Delegation { .. } => {}
197            CommandEntry::Custom { cmd, valid_prefix } => {
198                let base = valid_prefix.unwrap_or(cmd);
199                let test = format!("{base} {UNKNOWN_FLAG}");
200                if crate::is_safe_command(&test) {
201                    failures.push(format!("{cmd}: accepted unknown flag: {test}"));
202                }
203            }
204            CommandEntry::Subcommand { cmd, subs, bare_ok } => {
205                if !bare_ok && crate::is_safe_command(cmd) {
206                    failures.push(format!("{cmd}: accepted bare invocation"));
207                }
208                let test = format!("{cmd} {UNKNOWN_SUB}");
209                if crate::is_safe_command(&test) {
210                    failures.push(format!("{cmd}: accepted unknown subcommand: {test}"));
211                }
212                for sub in *subs {
213                    check_sub(cmd, sub, failures);
214                }
215            }
216        }
217    }
218
219    fn check_sub(prefix: &str, entry: &SubEntry, failures: &mut Vec<String>) {
220        match entry {
221            SubEntry::Policy { name } => {
222                let test = format!("{prefix} {name} {UNKNOWN_FLAG}");
223                if crate::is_safe_command(&test) {
224                    failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
225                }
226            }
227            SubEntry::Nested { name, subs } => {
228                let path = format!("{prefix} {name}");
229                let test = format!("{path} {UNKNOWN_SUB}");
230                if crate::is_safe_command(&test) {
231                    failures.push(format!("{path}: accepted unknown subcommand: {test}"));
232                }
233                for sub in *subs {
234                    check_sub(&path, sub, failures);
235                }
236            }
237            SubEntry::Custom { name, valid_suffix } => {
238                let base = match valid_suffix {
239                    Some(s) => format!("{prefix} {name} {s}"),
240                    None => format!("{prefix} {name}"),
241                };
242                let test = format!("{base} {UNKNOWN_FLAG}");
243                if crate::is_safe_command(&test) {
244                    failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
245                }
246            }
247            SubEntry::Positional => {}
248            SubEntry::Guarded { name, valid_suffix } => {
249                let test = format!("{prefix} {name} {valid_suffix} {UNKNOWN_FLAG}");
250                if crate::is_safe_command(&test) {
251                    failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
252                }
253            }
254        }
255    }
256
257    #[test]
258    fn all_commands_reject_unknown() {
259        let registry = full_registry();
260        let mut failures = Vec::new();
261        for entry in &registry {
262            check_entry(entry, &mut failures);
263        }
264        assert!(
265            failures.is_empty(),
266            "unknown flags/subcommands accepted:\n{}",
267            failures.join("\n")
268        );
269    }
270
271    #[test]
272    fn process_substitution_blocked() {
273        let cmds = ["echo <(cat /etc/passwd)", "echo >(rm -rf /)", "grep pattern <(ls)"];
274        for cmd in &cmds {
275            assert!(
276                !crate::is_safe_command(cmd),
277                "process substitution not blocked: {cmd}",
278            );
279        }
280    }
281
282    #[test]
283    fn registry_covers_handled_commands() {
284        let registry = full_registry();
285        let mut all_cmds: HashSet<&str> = registry
286            .iter()
287            .map(|e| match e {
288                CommandEntry::Positional { cmd }
289                | CommandEntry::Custom { cmd, .. }
290                | CommandEntry::Subcommand { cmd, .. }
291                | CommandEntry::Delegation { cmd } => *cmd,
292            })
293            .collect();
294        for name in crate::registry::toml_command_names() {
295            all_cmds.insert(name);
296        }
297        let handled: HashSet<&str> = HANDLED_CMDS.iter().copied().collect();
298
299        let missing: Vec<_> = handled.difference(&all_cmds).collect();
300        assert!(missing.is_empty(), "not in registry: {missing:?}");
301
302        let extra: Vec<_> = all_cmds.difference(&handled).collect();
303        assert!(extra.is_empty(), "not in HANDLED_CMDS: {extra:?}");
304    }
305
306}