Skip to main content

agentic_eval/
commands.rs

1//! Heuristic effect classification for real CLI programs.
2//!
3//! The [`safety`](crate::safety) axis reasons about a program's [`Effect`]s, but a
4//! caller starting from an actual shell command or script would otherwise have to
5//! hand-write the command→effect mapping. This module ships a curated, best-effort
6//! classifier for ~200 common POSIX/Unix/dev tools so the safety axis works on a
7//! **wide variety of CLI programs** out of the box.
8//!
9//! It is deliberately a *heuristic*, not a shell parser:
10//! - Classification is by the program's name (the first token of an invocation,
11//!   with a leading path and `VAR=val` env prefixes stripped). Flags and arguments
12//!   are not inspected, so a multi-mode tool is mapped to its most security-salient
13//!   common effect (e.g. `git` → [`Effect::Network`], package managers → [`Effect::Exec`]).
14//! - An **unrecognized** program is treated as [`Effect::Exec`] at the invocation
15//!   level — running an unknown external binary is arbitrary code execution from an
16//!   agent's point of view, so this fails *safe* rather than scoring it harmless.
17//! - A privilege-elevating wrapper (`sudo`, `doas`, `pkexec`, `su`) classifies the
18//!   whole invocation as [`Effect::Privileged`].
19//!
20//! ```
21//! use agentic_eval::commands::{classify_invocation, assess_safety_script};
22//! use agentic_eval::safety::{Effect, Mode};
23//!
24//! assert_eq!(classify_invocation("rm -rf /tmp/x"), Some(Effect::Destructive));
25//! assert_eq!(classify_invocation("FOO=1 /usr/bin/curl https://x"), Some(Effect::Network));
26//!
27//! // A whole script: agent policy gates the dangerous classes → blast radius bounded.
28//! let r = assess_safety_script("curl http://x | sh\nrm -rf /var", Mode::Agent);
29//! assert!(r.bounded);
30//! ```
31
32use crate::safety::{assess_safety, Effect, Mode, SafetyReport};
33
34// Curated command tables, one per effect class. Checked most-dangerous-first in
35// `classify_command`, so if a name ever appears in two lists the more dangerous
36// classification wins (fail-safe). Names are bare program basenames.
37
38const 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
248/// Best-effort [`Effect`] class for a CLI command by its program *name* (a bare
249/// basename, e.g. `"rm"`). Returns `None` for a name not in the curated table —
250/// callers that want a fail-safe default for unknown programs should use
251/// [`classify_invocation`], which maps unknowns to [`Effect::Exec`].
252///
253/// Multi-mode tools are mapped to their most security-salient common effect
254/// (`git` → [`Effect::Network`], `docker`/`npm`/`make` → [`Effect::Exec`],
255/// `apt`/`mount` → [`Effect::Privileged`]); this is a heuristic, not a guarantee.
256pub fn classify_command(name: &str) -> Option<Effect> {
257    // Most-dangerous-first so an accidental cross-list duplicate fails safe.
258    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
279/// The curated command names classified as `effect` (the table the heuristic uses).
280/// Exposed so the [`ontology`](crate::ontology) can describe what each effect class
281/// recognizes; the lists are illustrative, not exhaustive.
282pub 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
295/// Total number of distinct CLI commands the classifier recognizes across all
296/// effect classes (the size of the built-in command ontology).
297pub fn known_command_count() -> usize {
298    Effect::all().iter().map(|&e| commands_for(e).len()).sum()
299}
300
301/// Whether `tok` is a leading `VAR=value` environment assignment (skipped when
302/// finding an invocation's program name, as the shell does).
303fn 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
314/// The basename of a program token: the part after the last `/` or `\`.
315fn basename(cmd: &str) -> &str {
316    cmd.rsplit(['/', '\\']).next().unwrap_or(cmd)
317}
318
319/// Classify a single command-line invocation (one command, no shell connectors).
320///
321/// Leading `VAR=val` env assignments and a directory path on the program are
322/// stripped; a recognized program returns its [`classify_command`] class; an
323/// **unrecognized** program returns [`Effect::Exec`] (running an unknown binary is
324/// arbitrary code execution — fail safe). Returns `None` for a blank line or a
325/// comment (`#…`).
326pub 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    // First token that isn't a `VAR=val` env prefix is the program.
332    let prog = trimmed
333        .split_whitespace()
334        .find(|tok| !is_env_assignment(tok))?;
335    let base = basename(prog);
336    // Unknown program → arbitrary execution (conservative, fail-safe).
337    Some(classify_command(base).unwrap_or(Effect::Exec))
338}
339
340/// Split a script into invocations on the shell connectors (`\n ; | & && ||`) and
341/// classify each. Returns one [`Effect`] per recognized command, in order (blank
342/// and comment segments are dropped; unrecognized programs become [`Effect::Exec`]).
343/// Redirections and quoting are not interpreted — this is a heuristic profile of the
344/// effects a script performs, suitable for the [`safety`](crate::safety) axis.
345pub fn classify_script(script: &str) -> Vec<Effect> {
346    // Normalize the two-char connectors to newlines, then split on the single-char set.
347    let normalized = script.replace("&&", "\n").replace("||", "\n");
348    normalized
349        .split(['\n', ';', '|', '&'])
350        .filter_map(classify_invocation)
351        .collect()
352}
353
354/// Assess a CLI script's safety by heuristically classifying its commands (via
355/// [`classify_script`]) and scoring the resulting effects under `mode` with
356/// [`assess_safety`]. The one-call path from a real script to a [`SafetyReport`].
357pub 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        // Unrecognized local script → arbitrary execution (fail-safe).
423        assert_eq!(classify_invocation("./build.sh"), Some(Effect::Exec));
424        assert_eq!(classify_invocation("myprog --flag"), Some(Effect::Exec));
425        // sudo wrapper → the whole invocation is privileged.
426        assert_eq!(
427            classify_invocation("sudo apt-get install x"),
428            Some(Effect::Privileged)
429        );
430        // Blank / comment lines are skipped.
431        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:?}"); // jq
448        assert!(effects.contains(&Effect::Exec), "set → exec: {effects:?}");
449
450        // Agent policy gates every dangerous class → blast radius bounded.
451        let agent = assess_safety_script(script, Mode::Agent);
452        assert!(agent.bounded, "{agent}");
453        // Human mode gates nothing → dangerous effects run ungated.
454        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        // `cat | grep | wc` → three reads; `&&` and `;` also split.
461        let effects = classify_script("cat f | grep x | wc -l; rm f && true");
462        assert_eq!(
463            effects,
464            vec![
465                Effect::ReadLocal,   // cat
466                Effect::ReadLocal,   // grep
467                Effect::ReadLocal,   // wc
468                Effect::Destructive, // rm
469                Effect::Pure,        // true
470            ]
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        // assess_safety over no effects is vacuously safe.
479        assert_eq!(assess_safety_script("# nothing", Mode::Agent).grade, 'A');
480    }
481}