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
154pub fn all_opencode_patterns() -> Vec<String> {
155    let mut patterns = Vec::new();
156    patterns.sort();
157    patterns.dedup();
158    patterns
159}
160
161#[cfg(test)]
162fn full_registry() -> Vec<&'static CommandEntry> {
163    let mut entries = Vec::new();
164    entries.extend(shell::REGISTRY);
165    entries.extend(wrappers::REGISTRY);
166    entries.extend(vcs::full_registry());
167    entries.extend(forges::full_registry());
168    entries.extend(node::full_registry());
169    entries.extend(jvm::full_registry());
170    entries.extend(android::full_registry());
171    entries.extend(network::REGISTRY);
172    entries.extend(system::full_registry());
173    entries.extend(xcode::full_registry());
174    entries.extend(perl::REGISTRY);
175    entries.extend(coreutils::full_registry());
176    entries.extend(fuzzy::full_registry());
177    entries
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use std::collections::HashSet;
184
185    const UNKNOWN_FLAG: &str = "--xyzzy-unknown-42";
186    const UNKNOWN_SUB: &str = "xyzzy-unknown-42";
187
188    fn check_entry(entry: &CommandEntry, failures: &mut Vec<String>) {
189        match entry {
190            CommandEntry::Positional { .. } | CommandEntry::Delegation { .. } => {}
191            CommandEntry::Custom { cmd, valid_prefix } => {
192                let base = valid_prefix.unwrap_or(cmd);
193                let test = format!("{base} {UNKNOWN_FLAG}");
194                if crate::is_safe_command(&test) {
195                    failures.push(format!("{cmd}: accepted unknown flag: {test}"));
196                }
197            }
198            CommandEntry::Subcommand { cmd, subs, bare_ok } => {
199                if !bare_ok && crate::is_safe_command(cmd) {
200                    failures.push(format!("{cmd}: accepted bare invocation"));
201                }
202                let test = format!("{cmd} {UNKNOWN_SUB}");
203                if crate::is_safe_command(&test) {
204                    failures.push(format!("{cmd}: accepted unknown subcommand: {test}"));
205                }
206                for sub in *subs {
207                    check_sub(cmd, sub, failures);
208                }
209            }
210        }
211    }
212
213    fn check_sub(prefix: &str, entry: &SubEntry, failures: &mut Vec<String>) {
214        match entry {
215            SubEntry::Policy { name } => {
216                let test = format!("{prefix} {name} {UNKNOWN_FLAG}");
217                if crate::is_safe_command(&test) {
218                    failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
219                }
220            }
221            SubEntry::Nested { name, subs } => {
222                let path = format!("{prefix} {name}");
223                let test = format!("{path} {UNKNOWN_SUB}");
224                if crate::is_safe_command(&test) {
225                    failures.push(format!("{path}: accepted unknown subcommand: {test}"));
226                }
227                for sub in *subs {
228                    check_sub(&path, sub, failures);
229                }
230            }
231            SubEntry::Custom { name, valid_suffix } => {
232                let base = match valid_suffix {
233                    Some(s) => format!("{prefix} {name} {s}"),
234                    None => format!("{prefix} {name}"),
235                };
236                let test = format!("{base} {UNKNOWN_FLAG}");
237                if crate::is_safe_command(&test) {
238                    failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
239                }
240            }
241            SubEntry::Positional => {}
242            SubEntry::Guarded { name, valid_suffix } => {
243                let test = format!("{prefix} {name} {valid_suffix} {UNKNOWN_FLAG}");
244                if crate::is_safe_command(&test) {
245                    failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
246                }
247            }
248        }
249    }
250
251    #[test]
252    fn all_commands_reject_unknown() {
253        let registry = full_registry();
254        let mut failures = Vec::new();
255        for entry in &registry {
256            check_entry(entry, &mut failures);
257        }
258        assert!(
259            failures.is_empty(),
260            "unknown flags/subcommands accepted:\n{}",
261            failures.join("\n")
262        );
263    }
264
265    #[test]
266    fn process_substitution_blocked() {
267        let cmds = ["echo <(cat /etc/passwd)", "echo >(rm -rf /)", "grep pattern <(ls)"];
268        for cmd in &cmds {
269            assert!(
270                !crate::is_safe_command(cmd),
271                "process substitution not blocked: {cmd}",
272            );
273        }
274    }
275
276    #[test]
277    fn registry_covers_handled_commands() {
278        let registry = full_registry();
279        let mut all_cmds: HashSet<&str> = registry
280            .iter()
281            .map(|e| match e {
282                CommandEntry::Positional { cmd }
283                | CommandEntry::Custom { cmd, .. }
284                | CommandEntry::Subcommand { cmd, .. }
285                | CommandEntry::Delegation { cmd } => *cmd,
286            })
287            .collect();
288        for name in crate::registry::toml_command_names() {
289            all_cmds.insert(name);
290        }
291        let handled: HashSet<&str> = HANDLED_CMDS.iter().copied().collect();
292
293        let missing: Vec<_> = handled.difference(&all_cmds).collect();
294        assert!(missing.is_empty(), "not in registry: {missing:?}");
295
296        let extra: Vec<_> = all_cmds.difference(&handled).collect();
297        assert!(extra.is_empty(), "not in HANDLED_CMDS: {extra:?}");
298    }
299
300}