Skip to content

Commit 87e9e2f

Browse files
committed
feat: YOLO mode — 'action':'allow' one-liner overrides all risk classes
Code change: ActionFor now checks DefaultAction before built-in defaults, so 'action':'allow' truly means everything is allowed. Blocked class (fork bombs, dd to block device) always returns Deny regardless of config. Per-class overrides in Classes map still win. Tests: 6 E2E tests validating OneLiner (all classes→Allow), BlockedStillDenied, PerClassOverride, Denied (lockdown), ShellTool integration, and real command classification. Docs: YOLO mode section in SECURITY.md with one-liner JSON, exceptions (Blocked + per-class overrides), use cases (CI, sandboxed sessions, power users), and lockdown mode ('deny').
1 parent d28efe5 commit 87e9e2f

4 files changed

Lines changed: 168 additions & 11 deletions

File tree

cmd/odek/yolo_e2e_test.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package main
2+
3+
import (
4+
"testing"
5+
6+
"github.com/BackendStack21/kode/internal/danger"
7+
)
8+
9+
// ── Tests ─────────────────────────────────────────────────────────────
10+
11+
func TestE2E_YOLO_OneLiner(t *testing.T) {
12+
skipIfNoE2E(t)
13+
14+
allow := "allow"
15+
dc := &danger.DangerousConfig{DefaultAction: &allow}
16+
17+
for _, cls := range []danger.RiskClass{
18+
danger.Safe, danger.LocalWrite, danger.SystemWrite,
19+
danger.Destructive, danger.NetworkEgress,
20+
danger.CodeExecution, danger.Install,
21+
} {
22+
t.Run(string(cls), func(t *testing.T) {
23+
if got := dc.ActionFor(cls); got != danger.Allow {
24+
t.Errorf("ActionFor(%q) = %q, want %q", cls, got, danger.Allow)
25+
}
26+
})
27+
}
28+
}
29+
30+
func TestE2E_YOLO_BlockedStillDenied(t *testing.T) {
31+
skipIfNoE2E(t)
32+
33+
allow := "allow"
34+
dc := &danger.DangerousConfig{DefaultAction: &allow}
35+
36+
got := dc.ActionForCommand(":(){ :|:& };:")
37+
if got != danger.Deny {
38+
t.Errorf("fork bomb = %q, want %q (blocked even in YOLO)", got, danger.Deny)
39+
}
40+
if got := dc.ActionFor(danger.Blocked); got != danger.Deny {
41+
t.Errorf("ActionFor(blocked) = %q, want %q", got, danger.Deny)
42+
}
43+
}
44+
45+
func TestE2E_YOLO_PerClassOverride(t *testing.T) {
46+
skipIfNoE2E(t)
47+
48+
allow := "allow"
49+
dc := &danger.DangerousConfig{
50+
DefaultAction: &allow,
51+
Classes: map[danger.RiskClass]danger.Action{danger.Destructive: danger.Deny},
52+
}
53+
54+
if got := dc.ActionFor(danger.Destructive); got != danger.Deny {
55+
t.Errorf("ActionFor(destructive) = %q, want %q (per-class wins)", got, danger.Deny)
56+
}
57+
if got := dc.ActionFor(danger.SystemWrite); got != danger.Allow {
58+
t.Errorf("ActionFor(system_write) = %q, want %q", got, danger.Allow)
59+
}
60+
}
61+
62+
func TestE2E_YOLO_Denied(t *testing.T) {
63+
skipIfNoE2E(t)
64+
65+
deny := "deny"
66+
dc := &danger.DangerousConfig{DefaultAction: &deny}
67+
68+
if got := dc.ActionFor(danger.Safe); got != danger.Deny {
69+
t.Errorf("ActionFor(safe) with deny config = %q, want %q", got, danger.Deny)
70+
}
71+
}
72+
73+
func TestE2E_YOLO_ShellTool(t *testing.T) {
74+
skipIfNoE2E(t)
75+
76+
allow := "allow"
77+
st := &shellTool{
78+
dangerousConfig: danger.DangerousConfig{DefaultAction: &allow},
79+
ttyPath: "/nonexistent/tty",
80+
}
81+
82+
_, err := st.Call(`{"command": "whoami", "description": "yolo"}`)
83+
if err != nil {
84+
t.Errorf("YOLO shell tool should allow, got: %v", err)
85+
}
86+
}
87+
88+
func TestE2E_YOLO_Commands(t *testing.T) {
89+
skipIfNoE2E(t)
90+
91+
allow := "allow"
92+
dc := danger.DangerousConfig{DefaultAction: &allow}
93+
94+
cases := []struct {
95+
name, cmd string
96+
}{
97+
{"system_write", "sudo systemctl restart nginx"},
98+
{"network_egress", "wget http://example.com/file"},
99+
{"code_execution", "node server.js"},
100+
{"install", "pip install flask"},
101+
{"destructive", "rm -rf /var/log/old"},
102+
{"safe", "ls -la"},
103+
{"local_write", "mkdir -p /tmp/x"},
104+
}
105+
for _, tc := range cases {
106+
t.Run(tc.name, func(t *testing.T) {
107+
if got := dc.ActionForCommand(tc.cmd); got != danger.Allow {
108+
cls := danger.Classify(tc.cmd)
109+
t.Errorf("ActionForCommand(%q) = %q (class: %s), want %q",
110+
tc.cmd, got, cls, danger.Allow)
111+
}
112+
})
113+
}
114+
}

docs/SECURITY.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,40 @@ See `dangerous` section in [CLI.md](CLI.md#dangerous-operations) for the full co
117117
}
118118
```
119119

120+
### YOLO mode (`"action": "allow"`)
121+
122+
Set `"action": "allow"` to skip all prompts — everything runs without approval:
123+
124+
```json
125+
{"dangerous": { "action": "allow" }}
126+
```
127+
128+
This makes every risk class (`system_write`, `destructive`, `network_egress`, `code_execution`, `install`) return `allow`. The only exceptions are:
129+
130+
- **Blocked class** — fork bombs, `dd` to block devices — always denied regardless of config
131+
- **Per-class overrides** — explicit `classes` entries still win over the global default
132+
133+
```json
134+
{
135+
"dangerous": {
136+
"action": "allow",
137+
"classes": {
138+
"destructive": "deny" // still deny destructive commands
139+
}
140+
}
141+
}
142+
```
143+
144+
Use YOLO mode for:
145+
146+
- **Automated scripts / CI pipelines** — no TTY, no prompts expected
147+
- **Trusted sandboxed sessions**`odek run --sandbox --sandbox-network none` keeps risk contained
148+
- **Power users** who have reviewed the risk model and want full speed
149+
150+
Set `"action": "deny"` for the opposite — lockdown mode — where every operation is denied unless explicitly allowed via `allowlist` or per-class override.
151+
152+
> **Note:** These are the semantics of the `action` field (mapped to `DefaultAction` in code). It overrides all built-in defaults (system_write→prompt, destructive→deny, etc.) but not per-class entries in the `classes` map. Prior to v0.17 this field only applied to unknown risk classes — the v0.17 change makes `"action":"allow"` a true one-liner YOLO mode.
153+
120154
### Session trust
121155

122156
When you press `T`, the risk class is cached in memory for the lifetime of the odek process. Subsequent commands of the same class skip approval. Trust is **not persisted to disk** — every new `odek run` or `odek continue` starts fresh.

internal/danger/classifier.go

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -122,9 +122,11 @@ type DangerousConfig struct {
122122
// regardless of their risk classification. Exact match only.
123123
Denylist []string `json:"denylist,omitempty"`
124124

125-
// DefaultAction is used when no /dev/tty is available (non-interactive mode).
126-
// Values: "allow" (default), "deny", "prompt"
127-
// "prompt" in non-interactive mode falls back to "allow".
125+
// DefaultAction is the global default action applied to ALL risk classes
126+
// when set. Per-class overrides in Classes still win.
127+
// "allow" → YOLO mode (everything runs without prompt)
128+
// "deny" → lockdown (everything denied unless explicitly allowed)
129+
// Not set → uses built-in defaults per class
128130
DefaultAction *string `json:"action,omitempty"`
129131

130132
// NonInteractive specifies what to do when running without a TTY.
@@ -151,23 +153,30 @@ var defaultActions = map[RiskClass]Action{
151153
}
152154

153155
// ActionFor returns the configured action for the given risk class.
154-
// Falls back to defaults for unknown classes and unconfigured classes.
156+
// Per-class overrides in Classes win first, then the global default
157+
// action (the "action" field), then built-in defaults, then Prompt.
155158
func (c *DangerousConfig) ActionFor(cls RiskClass) Action {
156159
// If the user explicitly configured an action for this class, use it.
157-
// Falls back to built-in defaults for unconfigured classes.
158160
if c.Classes != nil {
159161
if a, ok := c.Classes[cls]; ok {
160162
return a
161163
}
162164
}
163-
// Fallback to built-in defaults
164-
if a, ok := defaultActions[cls]; ok {
165-
return a
165+
// Blocked is always denied regardless of global default action.
166+
// This covers commands like "rm -rf /" that are hardcoded as
167+
// unrecoverable even in YOLO mode.
168+
if cls == Blocked {
169+
return Deny
166170
}
167-
// Unknown class: use default action from config, or prompt
171+
// Global default action overrides all built-in defaults.
172+
// Set "action": "allow" for YOLO mode, "action": "deny" for lockdown.
168173
if c.DefaultAction != nil {
169174
return parseAction(*c.DefaultAction)
170175
}
176+
// Fallback to built-in defaults
177+
if a, ok := defaultActions[cls]; ok {
178+
return a
179+
}
171180
return Prompt
172181
}
173182

internal/danger/classifier_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -386,8 +386,8 @@ func TestClassify_Config_DefaultAction(t *testing.T) {
386386
}
387387

388388
cfg2 := DangerousConfig{DefaultAction: strPtr("deny")}
389-
if got := cfg2.ActionFor(Safe); got != Allow {
390-
t.Errorf("ActionFor(safe) with default=deny should still be allow (safe defaults always allow)")
389+
if got := cfg2.ActionFor(Safe); got != Deny {
390+
t.Errorf("ActionFor(safe) with default=deny = %s, want deny (global default overrides)", got)
391391
}
392392
}
393393

0 commit comments

Comments
 (0)