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