|
3 | 3 | // |
4 | 4 | // Classification is token-based (not regex) — it respects quotes, pipes, |
5 | 5 | // redirects, compound commands (&&, ||, ;), and multi-line input. Each |
6 | | -// command is classified into one of 8 risk classes, and the user can |
| 6 | +// command is classified into one of 9 risk classes, and the user can |
7 | 7 | // configure which actions (allow/prompt/deny) apply to each class. |
8 | 8 | // |
| 9 | +// The gate fails CLOSED. A command whose program name is recognised but |
| 10 | +// used benignly classifies as Safe (allow); a command whose verb is NOT |
| 11 | +// recognised classifies as Unknown and is denied by default. The set of |
| 12 | +// recognised-safe commands (safeCommands) is therefore an explicit |
| 13 | +// read-only allowlist — extend it, or the per-profile allowlist, to permit |
| 14 | +// a tool rather than relying on it slipping through unclassified. |
| 15 | +// |
9 | 16 | // # Threat model |
10 | 17 | // |
11 | 18 | // The classifier is an adversarial filter, not a parser for well-behaved |
|
55 | 62 | // shell interpreter. It does not, and cannot, catch everything: |
56 | 63 | // |
57 | 64 | // - Variable indirection: `X=rm; $X -rf /` — the value of $X is not |
58 | | -// tracked, so the second command reads as an unknown verb. |
| 65 | +// tracked. Note the fail-closed default turns this from a silent bypass |
| 66 | +// into a denial: the unrecognised `$X` verb classifies as Unknown. |
59 | 67 | // - Fully dynamic construction from runtime data, command output, or |
60 | 68 | // environment the classifier cannot evaluate. |
61 | 69 | // - Arbitrary value transformations beyond the enumerated encodings |
62 | 70 | // (e.g. a secret piped through gzip/openssl before exfiltration). |
63 | 71 | // - Interpreter escape hatches we do not special-case (awk 'BEGIN{system()}', |
64 | | -// editor `!` shells, language-specific eval paths). |
| 72 | +// editor `!` shells, language-specific eval paths). These read as a known |
| 73 | +// command (awk/vim/…) used benignly, so they classify Safe — the known |
| 74 | +// verb is the gap, not an unknown one. |
65 | 75 | // |
66 | 76 | // Because these gaps exist, the classifier is paired with other controls: |
67 | 77 | // non-interactive denial, output redaction (internal/redact), and — for |
@@ -96,6 +106,13 @@ const ( |
96 | 106 | CodeExecution RiskClass = "code_execution" |
97 | 107 | Install RiskClass = "install" |
98 | 108 | Blocked RiskClass = "blocked" |
| 109 | + |
| 110 | + // Unknown is the fall-through class for a command whose program name the |
| 111 | + // classifier does not recognise. It defaults to Deny (same as |
| 112 | + // Destructive): the gate fails CLOSED rather than open, so a novel or |
| 113 | + // obfuscated verb that dodged every known-dangerous check cannot run |
| 114 | + // unprompted. Recognised-but-benign usage classifies as Safe instead. |
| 115 | + Unknown RiskClass = "unknown" |
99 | 116 | ) |
100 | 117 |
|
101 | 118 | // Action represents what to do when a command of a given risk class is detected. |
@@ -280,7 +297,13 @@ func parseBrowserIP(host string) net.IP { |
280 | 297 | // |
281 | 298 | // safe → allow, local_write → allow, system_write → prompt, |
282 | 299 | // destructive → deny, network_egress → prompt, |
283 | | -// code_execution → prompt, install → prompt, blocked → deny |
| 300 | +// code_execution → prompt, install → prompt, blocked → deny, |
| 301 | +// unknown → deny |
| 302 | +// |
| 303 | +// The classifier fails closed: a command whose program name is not |
| 304 | +// recognised classifies as Unknown and is denied by default. Set |
| 305 | +// "unknown": "prompt" (or add trusted tools to the allowlist) to soften |
| 306 | +// this for a given profile. |
284 | 307 | type DangerousConfig struct { |
285 | 308 | // Classes maps risk classes to their configured action. |
286 | 309 | // Only overrides for non-default values need to be set. |
@@ -323,6 +346,10 @@ var defaultActions = map[RiskClass]Action{ |
323 | 346 | CodeExecution: Prompt, |
324 | 347 | Install: Prompt, |
325 | 348 | Blocked: Deny, |
| 349 | + // Unrecognised commands fail closed — denied by default, like |
| 350 | + // Destructive. Override per-profile (e.g. "unknown": "prompt") or via |
| 351 | + // the allowlist for tools you trust. |
| 352 | + Unknown: Deny, |
326 | 353 | } |
327 | 354 |
|
328 | 355 | // ActionFor returns the configured action for the given risk class. |
@@ -590,6 +617,59 @@ var installPrefixes = map[string]bool{ |
590 | 617 | "pnpm": true, "yarn": true, "bun": true, "apk": true, |
591 | 618 | } |
592 | 619 |
|
| 620 | +// safeCommands are read-only / no-op programs that inspect state or |
| 621 | +// transform stdin→stdout without touching the filesystem, network, or |
| 622 | +// privileges. They classify as Safe (allow) so ordinary inspection keeps |
| 623 | +// working under the fail-closed default. A command here that is given a |
| 624 | +// write redirect or a system/sensitive path is still escalated by the |
| 625 | +// LocalWrite / SystemWrite / resource-scan checks before this set is |
| 626 | +// consulted — so adding a tool here cannot make `cmd > /etc/x` allowed. |
| 627 | +// |
| 628 | +// Only genuinely non-mutating tools belong here: anything that writes |
| 629 | +// files, mutates system state, opens the network, or executes arbitrary |
| 630 | +// code must NOT be added (it would become silently allowed). |
| 631 | +var safeCommands = map[string]bool{ |
| 632 | + // listing / reading files |
| 633 | + "ls": true, "ll": true, "dir": true, "vdir": true, "cat": true, "tac": true, |
| 634 | + "head": true, "tail": true, "less": true, "more": true, "bat": true, |
| 635 | + "nl": true, "wc": true, "file": true, "stat": true, "readlink": true, |
| 636 | + "realpath": true, "basename": true, "dirname": true, "tree": true, |
| 637 | + "du": true, "df": true, "find": true, "locate": true, "mdfind": true, |
| 638 | + // text transforms (stdin→stdout; a > redirect escalates to LocalWrite) |
| 639 | + "grep": true, "egrep": true, "fgrep": true, "rg": true, "ag": true, "ack": true, |
| 640 | + "sort": true, "uniq": true, "cut": true, "paste": true, "column": true, |
| 641 | + "fold": true, "comm": true, "join": true, "look": true, "tr": true, |
| 642 | + "expand": true, "unexpand": true, "fmt": true, "pr": true, "rev": true, |
| 643 | + "diff": true, "cmp": true, "sdiff": true, "colordiff": true, "diffstat": true, |
| 644 | + "jq": true, "yq": true, "xmllint": true, "csvlook": true, |
| 645 | + // hashing / encoding (read-only inspection) |
| 646 | + "strings": true, "od": true, "hexdump": true, "xxd": true, |
| 647 | + "base32": true, "md5sum": true, "sha1sum": true, "sha256sum": true, |
| 648 | + "sha512sum": true, "cksum": true, "b2sum": true, "sum": true, "shasum": true, |
| 649 | + // system / process inspection |
| 650 | + "pwd": true, "printf": true, "date": true, "cal": true, "uptime": true, |
| 651 | + "uname": true, "arch": true, "hostname": true, "nproc": true, "free": true, |
| 652 | + "vmstat": true, "iostat": true, "mpstat": true, "lscpu": true, "lsblk": true, |
| 653 | + "lsmem": true, "lsusb": true, "lspci": true, "lsof": true, "dmesg": true, |
| 654 | + "id": true, "whoami": true, "groups": true, "users": true, "who": true, |
| 655 | + "w": true, "last": true, "getent": true, "ps": true, "pgrep": true, |
| 656 | + "pidof": true, "netstat": true, "ss": true, "printenv": true, "locale": true, |
| 657 | + "getconf": true, "which": true, "whereis": true, "type": true, "hash": true, |
| 658 | + // control / no-op builtins |
| 659 | + "true": true, "false": true, ":": true, "test": true, "[": true, |
| 660 | + "sleep": true, "seq": true, "yes": true, "expr": true, "echo": true, |
| 661 | + "man": true, "info": true, "tldr": true, "help": true, "clear": true, |
| 662 | + // benign shell builtins (navigation, var/job control; no FS/net/priv). |
| 663 | + // NOTE: eval/source/. are deliberately absent — they execute code and |
| 664 | + // are handled as code_execution. |
| 665 | + "cd": true, "pushd": true, "popd": true, "dirs": true, "export": true, |
| 666 | + "unset": true, "set": true, "read": true, "wait": true, "shift": true, |
| 667 | + "return": true, "exit": true, "trap": true, "umask": true, "getopts": true, |
| 668 | + "local": true, "declare": true, "typeset": true, "readonly": true, |
| 669 | + "alias": true, "unalias": true, "jobs": true, "bg": true, "fg": true, |
| 670 | + "disown": true, "let": true, "ulimit": true, "times": true, |
| 671 | +} |
| 672 | + |
593 | 673 | // ── Classifier ───────────────────────────────────────────────────────── |
594 | 674 |
|
595 | 675 | // Classify determines the risk class of a shell command using token-level |
@@ -1029,7 +1109,11 @@ func isKnownCommandName(name string) bool { |
1029 | 1109 | networkPrefixes[name] || |
1030 | 1110 | codeEvalPrefixes[name] || |
1031 | 1111 | installPrefixes[name] || |
1032 | | - pipedShells[name] |
| 1112 | + pipedShells[name] || |
| 1113 | + safeCommands[name] || |
| 1114 | + remoteRunPrefixes[name] || |
| 1115 | + execWrappers[name] || |
| 1116 | + privilegedWrappers[name] |
1033 | 1117 | } |
1034 | 1118 |
|
1035 | 1119 | // isRawBlocked checks the raw command string for patterns that are |
@@ -1106,13 +1190,21 @@ var execWrappers = map[string]bool{ |
1106 | 1190 | "command": true, "exec": true, "builtin": true, "watch": true, |
1107 | 1191 | } |
1108 | 1192 |
|
1109 | | -// unwrapWrappers strips leading execution wrappers and returns the inner |
1110 | | -// command tokens plus a risk floor (system_write if a privileged wrapper was |
1111 | | -// present). It conservatively skips wrapper option flags, `env` VAR=VALUE |
1112 | | -// assignments, and the numeric/duration argument that timeout/nice take. |
| 1193 | +// unwrapWrappers strips leading shell assignments and execution wrappers and |
| 1194 | +// returns the inner command tokens plus a risk floor (system_write if a |
| 1195 | +// privileged wrapper was present). It conservatively skips wrapper option |
| 1196 | +// flags, `env` VAR=VALUE assignments, and the numeric/duration argument that |
| 1197 | +// timeout/nice take. Leading bare assignments (FOO=bar cmd …) are skipped so |
| 1198 | +// the real command is the one classified; an assignment-only command (no |
| 1199 | +// verb) is left empty and treated as Safe. |
1113 | 1200 | func unwrapWrappers(tokens []string) ([]string, RiskClass) { |
1114 | 1201 | floor := Safe |
1115 | 1202 | i := 0 |
| 1203 | + for i < len(tokens) && isAssignment(tokens[i]) { |
| 1204 | + i++ // leading VAR=value assignment prefix |
| 1205 | + } |
| 1206 | + tokens = tokens[i:] |
| 1207 | + i = 0 |
1116 | 1208 | for i < len(tokens) { |
1117 | 1209 | name := commandName(tokens[i]) |
1118 | 1210 | priv := privilegedWrappers[name] |
@@ -1311,7 +1403,13 @@ func classifyCommand(tokens []string) RiskClass { |
1311 | 1403 | return SystemWrite |
1312 | 1404 | } |
1313 | 1405 |
|
1314 | | - return Safe |
| 1406 | + // Fail closed: a recognised command used benignly is Safe; an |
| 1407 | + // unrecognised verb is Unknown (deny-by-default). An empty token slice |
| 1408 | + // (e.g. an assignment-only command after unwrapping) is Safe. |
| 1409 | + if len(tokens) == 0 || isKnownCommandName(first) { |
| 1410 | + return Safe |
| 1411 | + } |
| 1412 | + return Unknown |
1315 | 1413 | } |
1316 | 1414 |
|
1317 | 1415 | // ── Detection helpers ────────────────────────────────────────────────── |
@@ -1660,8 +1758,14 @@ func isSystemPath(path string) bool { |
1660 | 1758 | func rank(cls RiskClass) int { |
1661 | 1759 | switch cls { |
1662 | 1760 | case Blocked: |
1663 | | - return 8 |
| 1761 | + return 9 |
1664 | 1762 | case Destructive: |
| 1763 | + return 8 |
| 1764 | + case Unknown: |
| 1765 | + // Ranked above the prompt-level classes so a single unknown stage in |
| 1766 | + // a pipeline/compound command dominates benign siblings (e.g. |
| 1767 | + // `pip install x && weirdverb` stays deny-by-default), but below |
| 1768 | + // Destructive/Blocked so those keep their more informative label. |
1665 | 1769 | return 7 |
1666 | 1770 | case SystemWrite: |
1667 | 1771 | return 6 |
|
0 commit comments