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