Skip to content

Commit e288f40

Browse files
jkyberneeesclaude
andcommitted
harden(danger): fix code-review findings on the Unknown class
Self-review of the fail-closed change surfaced consumers that enumerate risk classes but were never taught about the new Unknown class, plus two classifier correctness bugs. Fixes: 1. Sub-agent risk caps ignored Unknown (cmd/odek/subagent.go). The mirror riskRank() had no Unknown case (returned 0) and the untrusted + maxRisk clamp loops didn't list it. Result: max_risk="unknown" denied even Safe, and a capped/untrusted sub-agent never force-denied Unknown when the inherited config loosened it. Root cause was the duplicated ranking, so export danger.Rank as the single source of truth (rank→Rank) and have subagent.go use it; add danger.Unknown to both clamp loops. 2. Trust-class shortcut allowed Unknown (internal/danger/approver.go, cmd/odek/wsapprover.go). "Trust class for session" was disabled only for Destructive/Blocked, so a single grant could blanket-approve every future unrecognised verb — the exact approval-fatigue attack the exclusion exists to stop. Exclude Unknown too. 3. dd to a character device misclassified destructive (classifier.go). isDestructive's of= branch matched any "/dev/" substring, so the common `dd ... of=/dev/null` / `of=/dev/stdout` idioms were denied. Use the precise containsBlockDevice matcher (also dedupes the device logic). 4. ANSI-C octal over-read (classifier.go decodeEscape). The loop consumed up to 4 octal digits; bash takes at most 3. `$'\1551'` now decodes to "m1" (octal 155 + literal 1), matching the shell, not a single byte. Regression tests added for each; rank→Rank rename propagated through the danger package and its tests. Full suite green; go vet clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 91343e3 commit e288f40

9 files changed

Lines changed: 120 additions & 55 deletions

File tree

cmd/odek/subagent.go

Lines changed: 6 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -492,8 +492,8 @@ func truncate(s string, n int) string {
492492
// outside CWD, an MCP server response). We:
493493
// - Force NonInteractiveAction to deny (sub-agents have no TTY).
494494
// - Clamp the action for Destructive, CodeExecution, Install,
495-
// SystemWrite, and NetworkEgress to Deny so the sub-agent cannot
496-
// escalate beyond LocalWrite without coming back through the
495+
// SystemWrite, NetworkEgress, and Unknown to Deny so the sub-agent
496+
// cannot escalate beyond LocalWrite without coming back through the
497497
// parent.
498498
//
499499
// maxRisk caps the highest risk class the sub-agent will execute.
@@ -522,15 +522,15 @@ func applySubagentTrust(dc *danger.DangerousConfig, trustLevel, maxRisk string)
522522
danger.Install,
523523
danger.SystemWrite,
524524
danger.NetworkEgress,
525+
danger.Unknown,
525526
danger.Blocked,
526527
} {
527528
dc.Classes[cls] = danger.Deny
528529
}
529530
}
530531

531532
if maxRisk != "" {
532-
cap := danger.RiskClass(maxRisk)
533-
capRank := riskRank(cap)
533+
capRank := danger.Rank(danger.RiskClass(maxRisk))
534534
for _, cls := range []danger.RiskClass{
535535
danger.Safe,
536536
danger.LocalWrite,
@@ -539,35 +539,12 @@ func applySubagentTrust(dc *danger.DangerousConfig, trustLevel, maxRisk string)
539539
danger.NetworkEgress,
540540
danger.CodeExecution,
541541
danger.Install,
542+
danger.Unknown,
542543
danger.Blocked,
543544
} {
544-
if riskRank(cls) > capRank {
545+
if danger.Rank(cls) > capRank {
545546
dc.Classes[cls] = danger.Deny
546547
}
547548
}
548549
}
549550
}
550-
551-
// riskRank mirrors internal/danger.rank but is duplicated here to keep
552-
// applySubagentTrust local. Order matches internal/danger/classifier.go.
553-
func riskRank(cls danger.RiskClass) int {
554-
switch cls {
555-
case danger.Blocked:
556-
return 8
557-
case danger.Destructive:
558-
return 7
559-
case danger.SystemWrite:
560-
return 6
561-
case danger.CodeExecution:
562-
return 5
563-
case danger.NetworkEgress:
564-
return 4
565-
case danger.Install:
566-
return 3
567-
case danger.LocalWrite:
568-
return 2
569-
case danger.Safe:
570-
return 1
571-
}
572-
return 0
573-
}

cmd/odek/subagent_trust_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ func TestApplySubagentTrust_Untrusted_LocksDownEscalationClasses(t *testing.T) {
3535
danger.Install,
3636
danger.SystemWrite,
3737
danger.NetworkEgress,
38+
danger.Unknown,
3839
danger.Blocked,
3940
} {
4041
if got := dc.Classes[cls]; got != danger.Deny {
@@ -63,6 +64,7 @@ func TestApplySubagentTrust_MaxRisk_ClampsAbove(t *testing.T) {
6364
danger.NetworkEgress,
6465
danger.CodeExecution,
6566
danger.Install,
67+
danger.Unknown,
6668
danger.Blocked,
6769
} {
6870
if got := dc.Classes[cls]; got != danger.Deny {
@@ -76,3 +78,24 @@ func TestApplySubagentTrust_MaxRisk_ClampsAbove(t *testing.T) {
7678
}
7779
}
7880
}
81+
82+
// TestApplySubagentTrust_MaxRiskUnknown_KeepsSafeOpen guards the fix for the
83+
// cap miscomputation: before Unknown was added to riskRank's shared ordering,
84+
// max_risk="unknown" computed rank 0 and force-denied even Safe. It must now
85+
// leave Safe/LocalWrite open and deny only the classes above Unknown.
86+
func TestApplySubagentTrust_MaxRiskUnknown_KeepsSafeOpen(t *testing.T) {
87+
dc := danger.DangerousConfig{}
88+
applySubagentTrust(&dc, "", "unknown")
89+
90+
for _, cls := range []danger.RiskClass{danger.Safe, danger.LocalWrite} {
91+
if got, ok := dc.Classes[cls]; ok && got == danger.Deny {
92+
t.Errorf("Class %s must stay open with max_risk=unknown, got %q", cls, got)
93+
}
94+
}
95+
// Only classes ranked above Unknown (Destructive, Blocked) are denied.
96+
for _, cls := range []danger.RiskClass{danger.Destructive, danger.Blocked} {
97+
if got := dc.Classes[cls]; got != danger.Deny {
98+
t.Errorf("Class %s = %q, want deny with max_risk=unknown", cls, got)
99+
}
100+
}
101+
}

cmd/odek/wsapprover.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,13 @@ type approvalRequest struct {
3636
FrictionApprovals int `json:"friction_approvals,omitempty"`
3737
}
3838

39-
// allowTrustForClass mirrors the TTYApprover policy: destructive and
40-
// blocked must never be class-trusted, so an attacker cannot social-
41-
// engineer a single broad approval into long-term carte blanche.
39+
// allowTrustForClass mirrors the TTYApprover policy: destructive, blocked,
40+
// and unknown must never be class-trusted, so an attacker cannot social-
41+
// engineer a single broad approval into long-term carte blanche. Unknown is
42+
// the fail-closed catch-all for unrecognised verbs; class-trusting it would
43+
// blanket-approve every future obfuscated/novel command.
4244
func allowTrustForClass(cls danger.RiskClass) bool {
43-
return cls != danger.Destructive && cls != danger.Blocked
45+
return cls != danger.Destructive && cls != danger.Blocked && cls != danger.Unknown
4446
}
4547

4648
// approvalResponse is received from the browser when the user responds.

cmd/odek/wsapprover_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,7 @@ func TestWSApprover_AllowTrustFlag_PerClass(t *testing.T) {
348348
{danger.Install, true},
349349
{danger.Destructive, false},
350350
{danger.Blocked, false},
351+
{danger.Unknown, false},
351352
}
352353
for _, tc := range cases {
353354
t.Run(string(tc.cls), func(t *testing.T) {
@@ -433,3 +434,18 @@ func TestWSApprover_TrustResponse_CoercedToApprove_ForBlocked(t *testing.T) {
433434
t.Error("blocked class was cached as trusted — class trust must be impossible")
434435
}
435436
}
437+
438+
// TestWSApprover_TrustResponse_CoercedToApprove_ForUnknown verifies the
439+
// fail-closed Unknown class cannot be class-trusted: a forged "trust" is
440+
// treated as a single approve and never cached, so unrecognised verbs can't
441+
// be blanket-approved by one social-engineered grant.
442+
func TestWSApprover_TrustResponse_CoercedToApprove_ForUnknown(t *testing.T) {
443+
a := newWSApprover(nil)
444+
_, err := promptAndCaptureRequest(t, a, danger.Unknown, "trust")
445+
if err != nil {
446+
t.Errorf("expected nil error (coerced to approve), got: %v", err)
447+
}
448+
if a.approveAll[danger.Unknown] {
449+
t.Error("unknown class was cached as trusted — class trust must be impossible")
450+
}
451+
}

internal/danger/approver.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -144,12 +144,13 @@ func (a *TTYApprover) prompt(cls RiskClass, cmd, description string) error {
144144
}
145145
defer tty.Close()
146146

147-
// Trust-class shortcut is disabled for the two highest-impact
148-
// classes. Destructive and Blocked operations always require a
149-
// per-call approval to defeat approval-fatigue attacks where the
150-
// model batches a benign destructive-class trust grant with a
151-
// destructive payload.
152-
allowTrust := cls != Destructive && cls != Blocked
147+
// Trust-class shortcut is disabled for the highest-impact classes.
148+
// Destructive and Blocked always require per-call approval to defeat
149+
// approval-fatigue attacks where the model batches a benign trust grant
150+
// with a dangerous payload. Unknown is included because it is the
151+
// fail-closed catch-all for unrecognised verbs — class-trusting it would
152+
// blanket-approve every future obfuscated/novel command.
153+
allowTrust := cls != Destructive && cls != Blocked && cls != Unknown
153154

154155
// Approval-fatigue mitigation: if the user has already approved
155156
// this class FrictionThreshold times in FrictionWindow, the next

internal/danger/classifier.go

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -708,7 +708,7 @@ func Classify(cmd string) RiskClass {
708708
// Substitutions are themselves commands the shell will run.
709709
// Re-enter Classify (not classifyOne) so nested substitutions
710710
// inside them also normalise.
711-
if r := Classify(s); rank(r) > rank(worst) {
711+
if r := Classify(s); Rank(r) > Rank(worst) {
712712
worst = r
713713
}
714714
}
@@ -728,7 +728,7 @@ func classifyOne(cmd string) RiskClass {
728728
worst := Safe
729729
for _, seg := range segments {
730730
cls := classifyPipeline(seg)
731-
if rank(cls) > rank(worst) {
731+
if Rank(cls) > Rank(worst) {
732732
worst = cls
733733
}
734734
}
@@ -876,13 +876,17 @@ func decodeEscape(s string, b *strings.Builder) int {
876876
}
877877
}
878878
default:
879-
if s[1] >= '0' && s[1] <= '7' { // \NNN octal (1–3 digits)
879+
if s[1] >= '0' && s[1] <= '7' { // \NNN octal (1–3 digits, like bash)
880+
// end starts after the backslash+first digit; cap at end<4 so at
881+
// most 3 octal digits (s[1:4]) are consumed. A wider bound would
882+
// swallow a following literal octal digit and diverge from the
883+
// shell (bash: $'\1551' → "m1", not one byte).
880884
end := 2
881-
for end < len(s) && end < 5 && s[end] >= '0' && s[end] <= '7' {
885+
for end < len(s) && end < 4 && s[end] >= '0' && s[end] <= '7' {
882886
end++
883887
}
884888
if v, err := strconv.ParseUint(s[1:end], 8, 16); err == nil {
885-
b.WriteByte(byte(v))
889+
b.WriteByte(byte(v)) // bash takes octal escapes mod 256
886890
return end
887891
}
888892
}
@@ -1284,7 +1288,7 @@ func commandName(tok string) string {
12841288

12851289
// worstOf returns whichever class ranks higher (more severe).
12861290
func worstOf(a, b RiskClass) RiskClass {
1287-
if rank(b) > rank(a) {
1291+
if Rank(b) > Rank(a) {
12881292
return b
12891293
}
12901294
return a
@@ -1524,9 +1528,12 @@ func isDestructive(first string, tokens []string) bool {
15241528
return len(tokens) >= 1
15251529
}
15261530

1527-
// dd with of=/dev/sd* or of=/dev/nvme*
1531+
// dd writing to a raw block device (of=/dev/sda etc.) is destructive.
1532+
// Match only real block devices via containsBlockDevice/isBlockDevice —
1533+
// NOT any "/dev/" substring, so benign discards like of=/dev/null and
1534+
// of=/dev/stdout are not misclassified.
15281535
for _, tok := range tokens {
1529-
if strings.HasPrefix(tok, "of=") && strings.Contains(tok, "/dev/") {
1536+
if strings.HasPrefix(tok, "of=") && containsBlockDevice(tok) {
15301537
return true
15311538
}
15321539
if tok == "of=" && len(tokens) > 1 {
@@ -1754,8 +1761,11 @@ func isSystemPath(path string) bool {
17541761

17551762
// ── Ranking ────────────────────────────────────────────────────────────
17561763

1757-
// rank returns the severity order for priority comparison.
1758-
func rank(cls RiskClass) int {
1764+
// Rank returns the severity order for priority comparison. Exported so
1765+
// consumers that enforce risk caps (e.g. the sub-agent maxRisk clamp) share
1766+
// this single ordering instead of mirroring it — a mirror silently drifts
1767+
// when a class is added, as happened with Unknown.
1768+
func Rank(cls RiskClass) int {
17591769
switch cls {
17601770
case Blocked:
17611771
return 9

internal/danger/classifier_bypass_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ func TestBypass_KnownEvasions(t *testing.T) {
7676
for _, tc := range cases {
7777
t.Run(tc.name, func(t *testing.T) {
7878
got := Classify(tc.cmd)
79-
if rank(got) < rank(tc.minWant) {
79+
if Rank(got) < Rank(tc.minWant) {
8080
t.Errorf("Classify(%q) = %v, want at least %v\nwhy: %s",
8181
tc.cmd, got, tc.minWant, tc.why)
8282
}
@@ -99,7 +99,7 @@ func TestBypass_KnownDetections(t *testing.T) {
9999
for _, tc := range cases {
100100
t.Run(tc.name, func(t *testing.T) {
101101
got := Classify(tc.cmd)
102-
if rank(got) < rank(Destructive) {
102+
if Rank(got) < Rank(Destructive) {
103103
t.Errorf("Classify(%q) = %v, want >= destructive — regression in existing detection", tc.cmd, got)
104104
}
105105
})

internal/danger/classifier_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -831,9 +831,9 @@ func TestRank(t *testing.T) {
831831
}
832832
for _, tt := range tests {
833833
t.Run(tt.name, func(t *testing.T) {
834-
got := rank(tt.cls)
834+
got := Rank(tt.cls)
835835
if got != tt.want {
836-
t.Errorf("rank(%s) = %d, want %d", tt.cls, got, tt.want)
836+
t.Errorf("Rank(%s) = %d, want %d", tt.cls, got, tt.want)
837837
}
838838
})
839839
}

internal/danger/hardening_test.go

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ func TestHardening_ReverseShellDevTCP(t *testing.T) {
8888
"exec 3<>/dev/udp/10.0.0.1/53",
8989
}
9090
for _, cmd := range cases {
91-
if got := Classify(cmd); rank(got) < rank(NetworkEgress) {
91+
if got := Classify(cmd); Rank(got) < Rank(NetworkEgress) {
9292
t.Errorf("Classify(%q) = %s, want >= network_egress", cmd, got)
9393
}
9494
}
@@ -133,7 +133,7 @@ func TestHardening_SensitivePathReads(t *testing.T) {
133133
"grep secret ~/.git-credentials",
134134
}
135135
for _, cmd := range cases {
136-
if got := Classify(cmd); rank(got) < rank(SystemWrite) {
136+
if got := Classify(cmd); Rank(got) < Rank(SystemWrite) {
137137
t.Errorf("Classify(%q) = %s, want >= system_write", cmd, got)
138138
}
139139
}
@@ -239,6 +239,42 @@ func TestHardening_SafeAllowlistStillSafe(t *testing.T) {
239239
}
240240
}
241241

242+
func TestHardening_DdToCharDeviceNotDestructive(t *testing.T) {
243+
// dd writing to a character pseudo-device (discard/echo) is benign — only
244+
// raw block devices are destructive. Loose "/dev/" matching used to deny
245+
// these common idioms.
246+
benign := []string{
247+
"dd if=/dev/zero of=/dev/null bs=1M count=100",
248+
"dd if=/dev/urandom of=/dev/stdout count=1",
249+
}
250+
for _, cmd := range benign {
251+
if got := Classify(cmd); got == Destructive || got == Blocked {
252+
t.Errorf("Classify(%q) = %s, want non-destructive", cmd, got)
253+
}
254+
}
255+
// Real block-device writes stay destructive/blocked.
256+
for _, cmd := range []string{"dd if=/dev/zero of=/dev/sda", "dd if=/dev/zero of=/dev/nvme0n1 bs=4M"} {
257+
if got := Classify(cmd); Rank(got) < Rank(Destructive) {
258+
t.Errorf("Classify(%q) = %s, want >= destructive", cmd, got)
259+
}
260+
}
261+
}
262+
263+
func TestHardening_ANSICOctalDigitCap(t *testing.T) {
264+
// $'\NNN' consumes at most 3 octal digits, like bash: $'\1551' is octal
265+
// 155 ('m') followed by a literal '1' → "m1", not a single over-read byte.
266+
cases := map[string]string{
267+
`$'\155'`: "m",
268+
`$'\1551'`: "m1",
269+
`$'\162\155'`: "rm",
270+
}
271+
for in, want := range cases {
272+
if got := decodeANSIC(in); got != want {
273+
t.Errorf("decodeANSIC(%s) = %q, want %q", in, got, want)
274+
}
275+
}
276+
}
277+
242278
// TestHardening_NoRegressionOnBenign guards against over-classification of
243279
// ordinary developer commands that must remain low-risk.
244280
func TestHardening_NoRegressionOnBenign(t *testing.T) {

0 commit comments

Comments
 (0)