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