Skip to content

Commit 83ae46f

Browse files
jkyberneeesclaude
andcommitted
harden(danger): fail closed — deny unrecognised commands by default
Previously the classifier fell through to Safe (allow) for any command it didn't recognise, so the whole gate was fail-open: a novel or obfuscated verb that dodged every known-dangerous check ran unprompted. Flip it to fail-closed: - New Unknown risk class, default action Deny (same as Destructive), ranked just below Destructive so a single unknown stage dominates benign siblings in a pipeline/compound command. - classifyCommand now returns Safe only for a recognised command used benignly, and Unknown otherwise. - safeCommands: an explicit read-only/no-op allowlist (ls, cat, grep, sort, jq, stat, ps, git via existing prefixes, cd/export and other benign builtins, …) so ordinary inspection still classifies Safe. Mutating/code-executing tools are deliberately excluded — they must be allowlisted, not slip through unclassified. A safe command given a write redirect or system/sensitive path is still escalated first. - Leading VAR=val assignments are unwrapped (FOO=bar rm -rf / → rm), and an assignment-only command is a no-op (Safe). Side benefit: the variable-indirection limitation now denies rather than silently allows — `$X -rf /` reads as an Unknown verb. Configurable like any class: set "unknown": "prompt" for a softer profile, or use the allowlist for specific trusted tools. Godmode (action: allow) still allows it, exactly like Destructive. Docs and tests updated (rank/ActionFor expectations; new fail-closed, assignment-unwrap, and safe-allowlist coverage). Full suite green; vet clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 82208dc commit 83ae46f

3 files changed

Lines changed: 185 additions & 16 deletions

File tree

internal/danger/classifier.go

Lines changed: 115 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,16 @@
33
//
44
// Classification is token-based (not regex) — it respects quotes, pipes,
55
// 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
77
// configure which actions (allow/prompt/deny) apply to each class.
88
//
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+
//
916
// # Threat model
1017
//
1118
// The classifier is an adversarial filter, not a parser for well-behaved
@@ -55,13 +62,16 @@
5562
// shell interpreter. It does not, and cannot, catch everything:
5663
//
5764
// - 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.
5967
// - Fully dynamic construction from runtime data, command output, or
6068
// environment the classifier cannot evaluate.
6169
// - Arbitrary value transformations beyond the enumerated encodings
6270
// (e.g. a secret piped through gzip/openssl before exfiltration).
6371
// - 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.
6575
//
6676
// Because these gaps exist, the classifier is paired with other controls:
6777
// non-interactive denial, output redaction (internal/redact), and — for
@@ -96,6 +106,13 @@ const (
96106
CodeExecution RiskClass = "code_execution"
97107
Install RiskClass = "install"
98108
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"
99116
)
100117

101118
// 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 {
280297
//
281298
// safe → allow, local_write → allow, system_write → prompt,
282299
// 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.
284307
type DangerousConfig struct {
285308
// Classes maps risk classes to their configured action.
286309
// Only overrides for non-default values need to be set.
@@ -323,6 +346,10 @@ var defaultActions = map[RiskClass]Action{
323346
CodeExecution: Prompt,
324347
Install: Prompt,
325348
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,
326353
}
327354

328355
// ActionFor returns the configured action for the given risk class.
@@ -590,6 +617,59 @@ var installPrefixes = map[string]bool{
590617
"pnpm": true, "yarn": true, "bun": true, "apk": true,
591618
}
592619

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+
593673
// ── Classifier ─────────────────────────────────────────────────────────
594674

595675
// Classify determines the risk class of a shell command using token-level
@@ -1029,7 +1109,11 @@ func isKnownCommandName(name string) bool {
10291109
networkPrefixes[name] ||
10301110
codeEvalPrefixes[name] ||
10311111
installPrefixes[name] ||
1032-
pipedShells[name]
1112+
pipedShells[name] ||
1113+
safeCommands[name] ||
1114+
remoteRunPrefixes[name] ||
1115+
execWrappers[name] ||
1116+
privilegedWrappers[name]
10331117
}
10341118

10351119
// isRawBlocked checks the raw command string for patterns that are
@@ -1106,13 +1190,21 @@ var execWrappers = map[string]bool{
11061190
"command": true, "exec": true, "builtin": true, "watch": true,
11071191
}
11081192

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.
11131200
func unwrapWrappers(tokens []string) ([]string, RiskClass) {
11141201
floor := Safe
11151202
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
11161208
for i < len(tokens) {
11171209
name := commandName(tokens[i])
11181210
priv := privilegedWrappers[name]
@@ -1311,7 +1403,13 @@ func classifyCommand(tokens []string) RiskClass {
13111403
return SystemWrite
13121404
}
13131405

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
13151413
}
13161414

13171415
// ── Detection helpers ──────────────────────────────────────────────────
@@ -1660,8 +1758,14 @@ func isSystemPath(path string) bool {
16601758
func rank(cls RiskClass) int {
16611759
switch cls {
16621760
case Blocked:
1663-
return 8
1761+
return 9
16641762
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.
16651769
return 7
16661770
case SystemWrite:
16671771
return 6

internal/danger/classifier_test.go

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -377,8 +377,13 @@ func TestClassify_ConfigDefaults(t *testing.T) {
377377
if got := cfg.ActionFor(Blocked); got != Deny {
378378
t.Errorf("ActionFor(blocked) = %s, want deny", got)
379379
}
380-
if got := cfg.ActionFor(RiskClass("unknown")); got != Prompt {
381-
t.Errorf("ActionFor(unknown) = %s, want prompt (default)", got)
380+
// Unknown fails closed: unrecognised commands are denied by default.
381+
if got := cfg.ActionFor(Unknown); got != Deny {
382+
t.Errorf("ActionFor(unknown) = %s, want deny", got)
383+
}
384+
// A class string that isn't in the table at all falls back to prompt.
385+
if got := cfg.ActionFor(RiskClass("bogus")); got != Prompt {
386+
t.Errorf("ActionFor(bogus) = %s, want prompt (fallback)", got)
382387
}
383388
}
384389

@@ -819,9 +824,10 @@ func TestRank(t *testing.T) {
819824
{"network_egress", NetworkEgress, 4},
820825
{"code_execution", CodeExecution, 5},
821826
{"system_write", SystemWrite, 6},
822-
{"destructive", Destructive, 7},
823-
{"blocked", Blocked, 8},
824-
{"unknown_class", RiskClass("unknown"), 0},
827+
{"unknown", Unknown, 7},
828+
{"destructive", Destructive, 8},
829+
{"blocked", Blocked, 9},
830+
{"unrecognized_class", RiskClass("bogus"), 0},
825831
}
826832
for _, tt := range tests {
827833
t.Run(tt.name, func(t *testing.T) {

internal/danger/hardening_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,65 @@ func TestHardening_NewNetworkAndExec(t *testing.T) {
180180
}
181181
}
182182

183+
func TestHardening_UnknownFailsClosed(t *testing.T) {
184+
// Unrecognised verbs classify as Unknown (deny-by-default), and an
185+
// unknown stage dominates benign siblings in a compound command.
186+
unknown := []string{
187+
"frobnicate --do-stuff",
188+
"mytool subcmd arg",
189+
"make",
190+
"cat file && mytool",
191+
"ls | weirdfilter",
192+
"X=rm $Y -rf /", // variable indirection: $Y is an unknown verb
193+
}
194+
for _, cmd := range unknown {
195+
if got := Classify(cmd); got != Unknown {
196+
t.Errorf("Classify(%q) = %s, want unknown", cmd, got)
197+
}
198+
}
199+
// And Unknown denies by default, matching destructive.
200+
cfg := DangerousConfig{}
201+
if got := cfg.ActionFor(Unknown); got != Deny {
202+
t.Errorf("ActionFor(unknown) = %s, want deny", got)
203+
}
204+
}
205+
206+
func TestHardening_LeadingAssignmentUnwrapped(t *testing.T) {
207+
cases := []struct {
208+
cmd string
209+
cls RiskClass
210+
}{
211+
{"FOO=bar rm -rf /", Destructive}, // assignment skipped → rm
212+
{"A=1 B=2 curl http://x", NetworkEgress},
213+
{"FOO=bar", Safe}, // assignment-only is a no-op
214+
{"LANG=C ls -la", Safe}, // benign command after assignment
215+
}
216+
for _, tc := range cases {
217+
if got := Classify(tc.cmd); got != tc.cls {
218+
t.Errorf("Classify(%q) = %s, want %s", tc.cmd, got, tc.cls)
219+
}
220+
}
221+
}
222+
223+
func TestHardening_SafeAllowlistStillSafe(t *testing.T) {
224+
// A representative sweep of the read-only allowlist must remain Safe so
225+
// fail-closed does not break ordinary inspection.
226+
safe := []string{
227+
"ls -la", "cat main.go", "head -n5 f", "tail -f log", "wc -l f",
228+
"grep -r foo .", "rg pattern", "sort f", "uniq f", "cut -f1 f",
229+
"diff a b", "jq '.x' f", "stat f", "file f", "du -sh .", "df -h",
230+
"tree", "realpath .", "basename /a/b", "dirname /a/b", "pwd",
231+
"printf '%s' x", "date", "uname -a", "id", "whoami", "ps aux",
232+
"which go", "type ls", "sha256sum f", "xxd f", "cd /etc", "export FOO=1",
233+
"true", "test -f x", "seq 1 5", "sleep 1",
234+
}
235+
for _, cmd := range safe {
236+
if got := Classify(cmd); got != Safe {
237+
t.Errorf("Classify(%q) = %s, want safe", cmd, got)
238+
}
239+
}
240+
}
241+
183242
// TestHardening_NoRegressionOnBenign guards against over-classification of
184243
// ordinary developer commands that must remain low-risk.
185244
func TestHardening_NoRegressionOnBenign(t *testing.T) {

0 commit comments

Comments
 (0)