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    "terraform", "heroku", "vercel", "flyctl", "pulumi", "netlify", "railway", "wrangler", "cf", "newrelic",
79    "aws", "gcloud", "az",
80    "doctl", "hcloud", "vultr-cli", "exo", "scw", "linode-cli",
81    "ansible-playbook", "ansible-inventory", "ansible-doc", "ansible-config", "ansible-galaxy",
82    "overmind", "tailscale", "tmux", "wg", "systemctl", "journalctl",
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    "tldr", "ldd", "objdump", "readelf", "just",
114    "direnv", "make", "packer", "vagrant",
115];
116
117pub fn handler_docs() -> Vec<crate::docs::CommandDoc> {
118    let mut docs = Vec::new();
119    docs.extend(forges::command_docs());
120    docs.extend(node::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(perl::command_docs());
126    docs.extend(coreutils::command_docs());
127    docs.extend(fuzzy::command_docs());
128    docs.extend(shell::command_docs());
129    docs.extend(wrappers::command_docs());
130    docs.extend(crate::registry::toml_command_docs());
131    docs
132}
133
134#[cfg(test)]
135#[derive(Debug)]
136pub(crate) enum CommandEntry {
137    Positional { cmd: &'static str },
138    Custom { cmd: &'static str, valid_prefix: Option<&'static str> },
139    Subcommand { cmd: &'static str, subs: &'static [SubEntry], bare_ok: bool },
140    Delegation { cmd: &'static str },
141}
142
143#[cfg(test)]
144#[derive(Debug)]
145pub(crate) enum SubEntry {
146    Policy { name: &'static str },
147    Nested { name: &'static str, subs: &'static [SubEntry] },
148    Custom { name: &'static str, valid_suffix: Option<&'static str> },
149    Positional,
150    Guarded { name: &'static str, valid_suffix: &'static str },
151}
152
153pub fn all_opencode_patterns() -> Vec<String> {
154    let mut patterns = Vec::new();
155    patterns.sort();
156    patterns.dedup();
157    patterns
158}
159
160#[cfg(test)]
161fn full_registry() -> Vec<&'static CommandEntry> {
162    let mut entries = Vec::new();
163    entries.extend(shell::REGISTRY);
164    entries.extend(wrappers::REGISTRY);
165    entries.extend(forges::full_registry());
166    entries.extend(node::full_registry());
167    entries.extend(jvm::full_registry());
168    entries.extend(android::full_registry());
169    entries.extend(network::REGISTRY);
170    entries.extend(system::full_registry());
171    entries.extend(perl::REGISTRY);
172    entries.extend(coreutils::full_registry());
173    entries.extend(fuzzy::full_registry());
174    entries
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use std::collections::HashSet;
181
182    const UNKNOWN_FLAG: &str = "--xyzzy-unknown-42";
183    const UNKNOWN_SUB: &str = "xyzzy-unknown-42";
184
185    fn check_entry(entry: &CommandEntry, failures: &mut Vec<String>) {
186        match entry {
187            CommandEntry::Positional { .. } | CommandEntry::Delegation { .. } => {}
188            CommandEntry::Custom { cmd, valid_prefix } => {
189                let base = valid_prefix.unwrap_or(cmd);
190                let test = format!("{base} {UNKNOWN_FLAG}");
191                if crate::is_safe_command(&test) {
192                    failures.push(format!("{cmd}: accepted unknown flag: {test}"));
193                }
194            }
195            CommandEntry::Subcommand { cmd, subs, bare_ok } => {
196                if !bare_ok && crate::is_safe_command(cmd) {
197                    failures.push(format!("{cmd}: accepted bare invocation"));
198                }
199                let test = format!("{cmd} {UNKNOWN_SUB}");
200                if crate::is_safe_command(&test) {
201                    failures.push(format!("{cmd}: accepted unknown subcommand: {test}"));
202                }
203                for sub in *subs {
204                    check_sub(cmd, sub, failures);
205                }
206            }
207        }
208    }
209
210    fn check_sub(prefix: &str, entry: &SubEntry, failures: &mut Vec<String>) {
211        match entry {
212            SubEntry::Policy { name } => {
213                let test = format!("{prefix} {name} {UNKNOWN_FLAG}");
214                if crate::is_safe_command(&test) {
215                    failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
216                }
217            }
218            SubEntry::Nested { name, subs } => {
219                let path = format!("{prefix} {name}");
220                let test = format!("{path} {UNKNOWN_SUB}");
221                if crate::is_safe_command(&test) {
222                    failures.push(format!("{path}: accepted unknown subcommand: {test}"));
223                }
224                for sub in *subs {
225                    check_sub(&path, sub, failures);
226                }
227            }
228            SubEntry::Custom { name, valid_suffix } => {
229                let base = match valid_suffix {
230                    Some(s) => format!("{prefix} {name} {s}"),
231                    None => format!("{prefix} {name}"),
232                };
233                let test = format!("{base} {UNKNOWN_FLAG}");
234                if crate::is_safe_command(&test) {
235                    failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
236                }
237            }
238            SubEntry::Positional => {}
239            SubEntry::Guarded { name, valid_suffix } => {
240                let test = format!("{prefix} {name} {valid_suffix} {UNKNOWN_FLAG}");
241                if crate::is_safe_command(&test) {
242                    failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
243                }
244            }
245        }
246    }
247
248    #[test]
249    fn all_commands_reject_unknown() {
250        let registry = full_registry();
251        let mut failures = Vec::new();
252        for entry in &registry {
253            check_entry(entry, &mut failures);
254        }
255        assert!(
256            failures.is_empty(),
257            "unknown flags/subcommands accepted:\n{}",
258            failures.join("\n")
259        );
260    }
261
262    #[test]
263    fn process_substitution_blocked() {
264        let cmds = ["echo <(cat /etc/passwd)", "echo >(rm -rf /)", "grep pattern <(ls)"];
265        for cmd in &cmds {
266            assert!(
267                !crate::is_safe_command(cmd),
268                "process substitution not blocked: {cmd}",
269            );
270        }
271    }
272
273    #[test]
274    fn registry_covers_handled_commands() {
275        let registry = full_registry();
276        let mut all_cmds: HashSet<&str> = registry
277            .iter()
278            .map(|e| match e {
279                CommandEntry::Positional { cmd }
280                | CommandEntry::Custom { cmd, .. }
281                | CommandEntry::Subcommand { cmd, .. }
282                | CommandEntry::Delegation { cmd } => *cmd,
283            })
284            .collect();
285        for name in crate::registry::toml_command_names() {
286            all_cmds.insert(name);
287        }
288        let handled: HashSet<&str> = HANDLED_CMDS.iter().copied().collect();
289
290        let missing: Vec<_> = handled.difference(&all_cmds).collect();
291        assert!(missing.is_empty(), "not in registry: {missing:?}");
292
293        let extra: Vec<_> = all_cmds.difference(&handled).collect();
294        assert!(extra.is_empty(), "not in HANDLED_CMDS: {extra:?}");
295    }
296
297}