1use crate::safety::{assess_safety, Effect, Mode, SafetyReport};
33
34const PRIVILEGED: &[&str] = &[
39 "sudo",
40 "su",
41 "doas",
42 "pkexec",
43 "mount",
44 "umount",
45 "chown",
46 "chroot",
47 "useradd",
48 "userdel",
49 "usermod",
50 "groupadd",
51 "groupdel",
52 "passwd",
53 "chpasswd",
54 "systemctl",
55 "service",
56 "modprobe",
57 "insmod",
58 "rmmod",
59 "sysctl",
60 "iptables",
61 "ip6tables",
62 "nft",
63 "ufw",
64 "firewall-cmd",
65 "fdisk",
66 "parted",
67 "mkfs",
68 "mkswap",
69 "swapon",
70 "swapoff",
71 "shutdown",
72 "reboot",
73 "halt",
74 "poweroff",
75 "init",
76 "telinit",
77 "apt",
78 "apt-get",
79 "aptitude",
80 "yum",
81 "dnf",
82 "zypper",
83 "pacman",
84 "dpkg",
85 "rpm",
86 "snap",
87 "visudo",
88 "setcap",
89 "nsenter",
90];
91
92const EXEC: &[&str] = &[
93 "bash", "sh", "zsh", "fish", "ksh", "dash", "csh", "tcsh", "eval", "exec", "source", ".",
94 "xargs", "nohup", "timeout", "watch", "make", "ninja", "docker", "podman", "nerdctl", "npm",
95 "npx", "yarn", "pnpm", "pip", "pip3", "pipx", "gem", "bundle", "cargo", "go", "node", "deno",
96 "bun", "python", "python2", "python3", "ruby", "perl", "php", "lua", "java", "parallel", "at",
97 "batch", "brew",
98];
99
100const DESTRUCTIVE: &[&str] = &[
101 "rm", "rmdir", "shred", "unlink", "srm", "wipe", "dd", "truncate",
102];
103
104const PROCESS: &[&str] = &[
105 "kill", "pkill", "killall", "renice", "nice", "fuser", "skill",
106];
107
108const NETWORK: &[&str] = &[
109 "curl",
110 "wget",
111 "ssh",
112 "scp",
113 "sftp",
114 "rsync",
115 "nc",
116 "ncat",
117 "netcat",
118 "telnet",
119 "ftp",
120 "tftp",
121 "ping",
122 "ping6",
123 "traceroute",
124 "tracepath",
125 "mtr",
126 "dig",
127 "nslookup",
128 "host",
129 "whois",
130 "git",
131 "svn",
132 "hg",
133 "kubectl",
134 "helm",
135 "aws",
136 "gcloud",
137 "az",
138 "gsutil",
139 "s3cmd",
140 "rclone",
141 "http",
142 "httpie",
143 "wscat",
144];
145
146const WRITE_LOCAL: &[&str] = &[
147 "touch", "mkdir", "cp", "mv", "ln", "tee", "install", "tar", "unzip", "zip", "gzip", "gunzip",
148 "bzip2", "bunzip2", "xz", "unxz", "zstd", "chmod", "chgrp", "patch", "mktemp", "mkfifo",
149 "crontab", "gcc", "g++", "clang", "clang++", "cc", "javac", "rustc",
150];
151
152const READ_LOCAL: &[&str] = &[
153 "ls",
154 "dir",
155 "vdir",
156 "cat",
157 "bat",
158 "head",
159 "tail",
160 "grep",
161 "egrep",
162 "fgrep",
163 "rg",
164 "ag",
165 "find",
166 "fd",
167 "stat",
168 "file",
169 "wc",
170 "sort",
171 "uniq",
172 "cut",
173 "awk",
174 "gawk",
175 "sed",
176 "less",
177 "more",
178 "diff",
179 "cmp",
180 "comm",
181 "join",
182 "paste",
183 "nl",
184 "tac",
185 "column",
186 "jq",
187 "yq",
188 "xxd",
189 "od",
190 "hexdump",
191 "strings",
192 "md5sum",
193 "sha1sum",
194 "sha256sum",
195 "cksum",
196 "du",
197 "df",
198 "tree",
199 "realpath",
200 "readlink",
201 "pwd",
202 "env",
203 "printenv",
204 "whoami",
205 "id",
206 "groups",
207 "hostname",
208 "uname",
209 "date",
210 "ps",
211 "top",
212 "htop",
213 "pgrep",
214 "pidof",
215 "which",
216 "type",
217 "command",
218 "whereis",
219 "locate",
220 "getconf",
221 "lsblk",
222 "lscpu",
223 "lsusb",
224 "lspci",
225 "free",
226 "uptime",
227 "who",
228 "w",
229 "last",
230 "lsof",
231 "ss",
232 "netstat",
233 "ifconfig",
234 "route",
235 "ip",
236 "getent",
237 "man",
238 "info",
239 "history",
240 "journalctl",
241];
242
243const PURE: &[&str] = &[
244 "true", "false", ":", "echo", "printf", "test", "[", "expr", "seq", "yes", "sleep", "basename",
245 "dirname", "rev", "cal", "tr", "fold", "expand", "unexpand",
246];
247
248pub fn classify_command(name: &str) -> Option<Effect> {
257 if PRIVILEGED.contains(&name) {
259 Some(Effect::Privileged)
260 } else if EXEC.contains(&name) {
261 Some(Effect::Exec)
262 } else if DESTRUCTIVE.contains(&name) {
263 Some(Effect::Destructive)
264 } else if PROCESS.contains(&name) {
265 Some(Effect::Process)
266 } else if NETWORK.contains(&name) {
267 Some(Effect::Network)
268 } else if WRITE_LOCAL.contains(&name) {
269 Some(Effect::WriteLocal)
270 } else if READ_LOCAL.contains(&name) {
271 Some(Effect::ReadLocal)
272 } else if PURE.contains(&name) {
273 Some(Effect::Pure)
274 } else {
275 None
276 }
277}
278
279pub fn commands_for(effect: Effect) -> &'static [&'static str] {
283 match effect {
284 Effect::Privileged => PRIVILEGED,
285 Effect::Exec => EXEC,
286 Effect::Destructive => DESTRUCTIVE,
287 Effect::Process => PROCESS,
288 Effect::Network => NETWORK,
289 Effect::WriteLocal => WRITE_LOCAL,
290 Effect::ReadLocal => READ_LOCAL,
291 Effect::Pure => PURE,
292 }
293}
294
295pub fn known_command_count() -> usize {
298 Effect::all().iter().map(|&e| commands_for(e).len()).sum()
299}
300
301fn is_env_assignment(tok: &str) -> bool {
304 match tok.split_once('=') {
305 Some((k, _)) => {
306 !k.is_empty()
307 && !k.starts_with(|c: char| c.is_ascii_digit())
308 && k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
309 }
310 None => false,
311 }
312}
313
314fn basename(cmd: &str) -> &str {
316 cmd.rsplit(['/', '\\']).next().unwrap_or(cmd)
317}
318
319pub fn classify_invocation(line: &str) -> Option<Effect> {
327 let trimmed = line.trim();
328 if trimmed.is_empty() || trimmed.starts_with('#') {
329 return None;
330 }
331 let prog = trimmed
333 .split_whitespace()
334 .find(|tok| !is_env_assignment(tok))?;
335 let base = basename(prog);
336 Some(classify_command(base).unwrap_or(Effect::Exec))
338}
339
340pub fn classify_script(script: &str) -> Vec<Effect> {
346 let normalized = script.replace("&&", "\n").replace("||", "\n");
348 normalized
349 .split(['\n', ';', '|', '&'])
350 .filter_map(classify_invocation)
351 .collect()
352}
353
354pub fn assess_safety_script(script: &str, mode: Mode) -> SafetyReport {
358 assess_safety(&classify_script(script), mode)
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364
365 #[test]
366 fn classifies_a_wide_variety_of_commands() {
367 use Effect::*;
368 let cases = [
369 ("ls", ReadLocal),
370 ("cat", ReadLocal),
371 ("grep", ReadLocal),
372 ("jq", ReadLocal),
373 ("ps", ReadLocal),
374 ("rm", Destructive),
375 ("dd", Destructive),
376 ("shred", Destructive),
377 ("truncate", Destructive),
378 ("curl", Network),
379 ("wget", Network),
380 ("ssh", Network),
381 ("git", Network),
382 ("kubectl", Network),
383 ("kill", Process),
384 ("pkill", Process),
385 ("renice", Process),
386 ("bash", Exec),
387 ("python3", Exec),
388 ("docker", Exec),
389 ("npm", Exec),
390 ("make", Exec),
391 ("xargs", Exec),
392 ("sudo", Privileged),
393 ("mount", Privileged),
394 ("systemctl", Privileged),
395 ("apt-get", Privileged),
396 ("mkfs", Privileged),
397 ("mkdir", WriteLocal),
398 ("cp", WriteLocal),
399 ("tee", WriteLocal),
400 ("gcc", WriteLocal),
401 ("tar", WriteLocal),
402 ("echo", Pure),
403 ("true", Pure),
404 ("sleep", Pure),
405 ];
406 for (name, want) in cases {
407 assert_eq!(classify_command(name), Some(want), "classify {name}");
408 }
409 assert_eq!(classify_command("some_unknown_tool_xyz"), None);
410 }
411
412 #[test]
413 fn invocation_strips_env_and_path_and_falls_back_to_exec() {
414 assert_eq!(
415 classify_invocation("FOO=bar rm -rf /tmp/x"),
416 Some(Effect::Destructive)
417 );
418 assert_eq!(
419 classify_invocation("/usr/bin/curl https://x"),
420 Some(Effect::Network)
421 );
422 assert_eq!(classify_invocation("./build.sh"), Some(Effect::Exec));
424 assert_eq!(classify_invocation("myprog --flag"), Some(Effect::Exec));
425 assert_eq!(
427 classify_invocation("sudo apt-get install x"),
428 Some(Effect::Privileged)
429 );
430 assert_eq!(classify_invocation(" "), None);
432 assert_eq!(classify_invocation("# a comment"), None);
433 assert_eq!(classify_invocation("#!/bin/bash"), None);
434 }
435
436 #[test]
437 fn classifies_a_whole_script_and_assesses_safety() {
438 let script = "set -e\n\
439 mkdir -p build\n\
440 curl -s https://example.com/d.json | jq .name\n\
441 cp d.json build/\n\
442 rm -rf build && echo done";
443 let effects = classify_script(script);
444 assert!(effects.contains(&Effect::Network), "{effects:?}");
445 assert!(effects.contains(&Effect::WriteLocal), "{effects:?}");
446 assert!(effects.contains(&Effect::Destructive), "{effects:?}");
447 assert!(effects.contains(&Effect::ReadLocal), "{effects:?}"); assert!(effects.contains(&Effect::Exec), "set → exec: {effects:?}");
449
450 let agent = assess_safety_script(script, Mode::Agent);
452 assert!(agent.bounded, "{agent}");
453 let human = assess_safety_script(script, Mode::Human);
455 assert!(!human.bounded, "{human}");
456 }
457
458 #[test]
459 fn pipelines_and_connectors_split_into_each_command() {
460 let effects = classify_script("cat f | grep x | wc -l; rm f && true");
462 assert_eq!(
463 effects,
464 vec![
465 Effect::ReadLocal, Effect::ReadLocal, Effect::ReadLocal, Effect::Destructive, Effect::Pure, ]
471 );
472 }
473
474 #[test]
475 fn empty_or_comment_only_script_has_no_effects() {
476 let r = classify_script("# just a comment\n\n \n#!/bin/sh");
477 assert!(r.is_empty());
478 assert_eq!(assess_safety_script("# nothing", Mode::Agent).grade, 'A');
480 }
481}