use crate::safety::{assess_safety, Effect, Mode, SafetyReport};
const PRIVILEGED: &[&str] = &[
"sudo",
"su",
"doas",
"pkexec",
"mount",
"umount",
"chown",
"chroot",
"useradd",
"userdel",
"usermod",
"groupadd",
"groupdel",
"passwd",
"chpasswd",
"systemctl",
"service",
"modprobe",
"insmod",
"rmmod",
"sysctl",
"iptables",
"ip6tables",
"nft",
"ufw",
"firewall-cmd",
"fdisk",
"parted",
"mkfs",
"mkswap",
"swapon",
"swapoff",
"shutdown",
"reboot",
"halt",
"poweroff",
"init",
"telinit",
"apt",
"apt-get",
"aptitude",
"yum",
"dnf",
"zypper",
"pacman",
"dpkg",
"rpm",
"snap",
"visudo",
"setcap",
"nsenter",
];
const EXEC: &[&str] = &[
"bash", "sh", "zsh", "fish", "ksh", "dash", "csh", "tcsh", "eval", "exec", "source", ".",
"xargs", "nohup", "timeout", "watch", "make", "ninja", "docker", "podman", "nerdctl", "npm",
"npx", "yarn", "pnpm", "pip", "pip3", "pipx", "gem", "bundle", "cargo", "go", "node", "deno",
"bun", "python", "python2", "python3", "ruby", "perl", "php", "lua", "java", "parallel", "at",
"batch", "brew",
];
const DESTRUCTIVE: &[&str] = &[
"rm", "rmdir", "shred", "unlink", "srm", "wipe", "dd", "truncate",
];
const PROCESS: &[&str] = &[
"kill", "pkill", "killall", "renice", "nice", "fuser", "skill",
];
const NETWORK: &[&str] = &[
"curl",
"wget",
"ssh",
"scp",
"sftp",
"rsync",
"nc",
"ncat",
"netcat",
"telnet",
"ftp",
"tftp",
"ping",
"ping6",
"traceroute",
"tracepath",
"mtr",
"dig",
"nslookup",
"host",
"whois",
"git",
"svn",
"hg",
"kubectl",
"helm",
"aws",
"gcloud",
"az",
"gsutil",
"s3cmd",
"rclone",
"http",
"httpie",
"wscat",
];
const WRITE_LOCAL: &[&str] = &[
"touch", "mkdir", "cp", "mv", "ln", "tee", "install", "tar", "unzip", "zip", "gzip", "gunzip",
"bzip2", "bunzip2", "xz", "unxz", "zstd", "chmod", "chgrp", "patch", "mktemp", "mkfifo",
"crontab", "gcc", "g++", "clang", "clang++", "cc", "javac", "rustc",
];
const READ_LOCAL: &[&str] = &[
"ls",
"dir",
"vdir",
"cat",
"bat",
"head",
"tail",
"grep",
"egrep",
"fgrep",
"rg",
"ag",
"find",
"fd",
"stat",
"file",
"wc",
"sort",
"uniq",
"cut",
"awk",
"gawk",
"sed",
"less",
"more",
"diff",
"cmp",
"comm",
"join",
"paste",
"nl",
"tac",
"column",
"jq",
"yq",
"xxd",
"od",
"hexdump",
"strings",
"md5sum",
"sha1sum",
"sha256sum",
"cksum",
"du",
"df",
"tree",
"realpath",
"readlink",
"pwd",
"env",
"printenv",
"whoami",
"id",
"groups",
"hostname",
"uname",
"date",
"ps",
"top",
"htop",
"pgrep",
"pidof",
"which",
"type",
"command",
"whereis",
"locate",
"getconf",
"lsblk",
"lscpu",
"lsusb",
"lspci",
"free",
"uptime",
"who",
"w",
"last",
"lsof",
"ss",
"netstat",
"ifconfig",
"route",
"ip",
"getent",
"man",
"info",
"history",
"journalctl",
];
const PURE: &[&str] = &[
"true", "false", ":", "echo", "printf", "test", "[", "expr", "seq", "yes", "sleep", "basename",
"dirname", "rev", "cal", "tr", "fold", "expand", "unexpand",
];
pub fn classify_command(name: &str) -> Option<Effect> {
if PRIVILEGED.contains(&name) {
Some(Effect::Privileged)
} else if EXEC.contains(&name) {
Some(Effect::Exec)
} else if DESTRUCTIVE.contains(&name) {
Some(Effect::Destructive)
} else if PROCESS.contains(&name) {
Some(Effect::Process)
} else if NETWORK.contains(&name) {
Some(Effect::Network)
} else if WRITE_LOCAL.contains(&name) {
Some(Effect::WriteLocal)
} else if READ_LOCAL.contains(&name) {
Some(Effect::ReadLocal)
} else if PURE.contains(&name) {
Some(Effect::Pure)
} else {
None
}
}
pub fn commands_for(effect: Effect) -> &'static [&'static str] {
match effect {
Effect::Privileged => PRIVILEGED,
Effect::Exec => EXEC,
Effect::Destructive => DESTRUCTIVE,
Effect::Process => PROCESS,
Effect::Network => NETWORK,
Effect::WriteLocal => WRITE_LOCAL,
Effect::ReadLocal => READ_LOCAL,
Effect::Pure => PURE,
}
}
pub fn known_command_count() -> usize {
Effect::all().iter().map(|&e| commands_for(e).len()).sum()
}
fn is_env_assignment(tok: &str) -> bool {
match tok.split_once('=') {
Some((k, _)) => {
!k.is_empty()
&& !k.starts_with(|c: char| c.is_ascii_digit())
&& k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}
None => false,
}
}
fn basename(cmd: &str) -> &str {
cmd.rsplit(['/', '\\']).next().unwrap_or(cmd)
}
pub fn classify_invocation(line: &str) -> Option<Effect> {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
return None;
}
let prog = trimmed
.split_whitespace()
.find(|tok| !is_env_assignment(tok))?;
let base = basename(prog);
Some(classify_command(base).unwrap_or(Effect::Exec))
}
pub fn classify_script(script: &str) -> Vec<Effect> {
let normalized = script.replace("&&", "\n").replace("||", "\n");
normalized
.split(['\n', ';', '|', '&'])
.filter_map(classify_invocation)
.collect()
}
pub fn assess_safety_script(script: &str, mode: Mode) -> SafetyReport {
assess_safety(&classify_script(script), mode)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classifies_a_wide_variety_of_commands() {
use Effect::*;
let cases = [
("ls", ReadLocal),
("cat", ReadLocal),
("grep", ReadLocal),
("jq", ReadLocal),
("ps", ReadLocal),
("rm", Destructive),
("dd", Destructive),
("shred", Destructive),
("truncate", Destructive),
("curl", Network),
("wget", Network),
("ssh", Network),
("git", Network),
("kubectl", Network),
("kill", Process),
("pkill", Process),
("renice", Process),
("bash", Exec),
("python3", Exec),
("docker", Exec),
("npm", Exec),
("make", Exec),
("xargs", Exec),
("sudo", Privileged),
("mount", Privileged),
("systemctl", Privileged),
("apt-get", Privileged),
("mkfs", Privileged),
("mkdir", WriteLocal),
("cp", WriteLocal),
("tee", WriteLocal),
("gcc", WriteLocal),
("tar", WriteLocal),
("echo", Pure),
("true", Pure),
("sleep", Pure),
];
for (name, want) in cases {
assert_eq!(classify_command(name), Some(want), "classify {name}");
}
assert_eq!(classify_command("some_unknown_tool_xyz"), None);
}
#[test]
fn invocation_strips_env_and_path_and_falls_back_to_exec() {
assert_eq!(
classify_invocation("FOO=bar rm -rf /tmp/x"),
Some(Effect::Destructive)
);
assert_eq!(
classify_invocation("/usr/bin/curl https://x"),
Some(Effect::Network)
);
assert_eq!(classify_invocation("./build.sh"), Some(Effect::Exec));
assert_eq!(classify_invocation("myprog --flag"), Some(Effect::Exec));
assert_eq!(
classify_invocation("sudo apt-get install x"),
Some(Effect::Privileged)
);
assert_eq!(classify_invocation(" "), None);
assert_eq!(classify_invocation("# a comment"), None);
assert_eq!(classify_invocation("#!/bin/bash"), None);
}
#[test]
fn classifies_a_whole_script_and_assesses_safety() {
let script = "set -e\n\
mkdir -p build\n\
curl -s https://example.com/d.json | jq .name\n\
cp d.json build/\n\
rm -rf build && echo done";
let effects = classify_script(script);
assert!(effects.contains(&Effect::Network), "{effects:?}");
assert!(effects.contains(&Effect::WriteLocal), "{effects:?}");
assert!(effects.contains(&Effect::Destructive), "{effects:?}");
assert!(effects.contains(&Effect::ReadLocal), "{effects:?}"); assert!(effects.contains(&Effect::Exec), "set → exec: {effects:?}");
let agent = assess_safety_script(script, Mode::Agent);
assert!(agent.bounded, "{agent}");
let human = assess_safety_script(script, Mode::Human);
assert!(!human.bounded, "{human}");
}
#[test]
fn pipelines_and_connectors_split_into_each_command() {
let effects = classify_script("cat f | grep x | wc -l; rm f && true");
assert_eq!(
effects,
vec![
Effect::ReadLocal, Effect::ReadLocal, Effect::ReadLocal, Effect::Destructive, Effect::Pure, ]
);
}
#[test]
fn empty_or_comment_only_script_has_no_effects() {
let r = classify_script("# just a comment\n\n \n#!/bin/sh");
assert!(r.is_empty());
assert_eq!(assess_safety_script("# nothing", Mode::Agent).grade, 'A');
}
}