Skip to content

Commit 1469af1

Browse files
authored
Merge pull request #5 from BackendStack21/harden/command-classifier
harden(danger): close classifier evasion vectors + fail closed on unknown commands
2 parents 681bcd9 + 3454a53 commit 1469af1

15 files changed

Lines changed: 1177 additions & 150 deletions

cmd/odek/shell.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,9 @@ Use for: reading files, listing directories, running tests, building code, and g
7171
In sandbox mode (--sandbox), commands run inside the Docker container with restricted permissions.
7272
In host mode (default), commands run with the same permissions as the odek process.
7373
74-
Risk classes: safe, local_write, system_write, destructive, network_egress, code_execution, install, blocked
75-
High-risk operations may prompt for approval (configurable via dangerous section in odek.json).`
74+
Risk classes: safe, local_write, system_write, destructive, network_egress, code_execution, install, unknown, blocked
75+
High-risk operations may prompt for approval (configurable via dangerous section in odek.json).
76+
The gate fails closed: an unrecognised command classifies as "unknown" and is denied by default.`
7677
}
7778

7879
func (t *shellTool) Schema() any {

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+
}

docker/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ Ready-to-run Compose setup for Odek in two permission profiles:
1414
> `run`/`repl`/`telegram` are unsandboxed by default.)
1515
1616
For the full walkthrough, threat model, and tuning, see
17-
[`../DOCKER_COMPOSE_USER_GUIDE.md`](../DOCKER_COMPOSE_USER_GUIDE.md).
17+
[`../docs/DOCKER_COMPOSE_USER_GUIDE.md`](../docs/DOCKER_COMPOSE_USER_GUIDE.md).
1818

1919
## Files
2020

docs/CLI.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,8 +123,13 @@ When running without `--sandbox`, odek classifies every shell command by risk an
123123
| 🔴 network_egress | **prompt** | `curl`, `git push`, `ssh`, `scp` |
124124
| 🔴 code_execution | **prompt** | `curl url \| bash`, `eval`, `node -e`, `go run` |
125125
| 🟠 install | **prompt** | `npm install`, `pip install`, `go install <path>` |
126+
| 🔴 unknown | **deny** | any command whose program name isn't recognised |
126127
| ⬛ blocked | **deny** | Fork bombs, `dd` to block devices |
127128

129+
odek **fails closed**: a command whose verb matches no known-safe or known-dangerous
130+
pattern is classified `unknown` and denied by default. Permit a specific tool by adding
131+
its exact invocation to `allowlist`, or soften the class with `"unknown": "prompt"`.
132+
128133
The approval prompt accepts:
129134

130135
- `A` — Approve once

docs/DEVELOPMENT.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ CI (`.github/workflows/test.yml`) runs the unit suite under `-race` on every pus
206206
| `internal/ws` | WebSocket constant verification |
207207
| `internal/resource` | @-reference parsing, file resolution, session resolution, security |
208208
| `internal/render` | Terminal output, no-color mode, nil safety, tool call/result rendering |
209-
| `internal/danger` | Command classification across 8 risk classes, config overrides, allow/denylist, classifier-bypass attempts, approver friction |
209+
| `internal/danger` | Command classification across 9 risk classes (incl. fail-closed `unknown`), config overrides, allow/denylist, classifier-bypass attempts, approver friction |
210210
| `internal/memory` | Facts CRUD, buffer ring, episodes, merge detector (go-vector), ReplaceEntry/AppendEntry, memory tool, security scan, LLM ranking, episode provenance |
211211
| `internal/skills` | Loading, triggers, self-improvement heuristics, curation, LLM-enhanced generation, import, tools, AnalyzeMessages/RunAutoSaveLoop, ValidateSkillName, isPrivateHost |
212212
| `internal/telegram` | Bot client, long-polling, command handlers, session management, plan CRUD, voice/photo download, health server, retry/backoff |
Lines changed: 79 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -138,10 +138,10 @@ These JSON files are mounted to `/home/odek/.odek/config.json` inside the contai
138138

139139
### 5a. Restricted policy — `config.restricted.json`
140140

141-
This is essentially Odek's default behavior, made explicit. Commands are risk‑classified;
142-
destructive ones are denied, the rest prompt for approval. Crucially, `non_interactive`
143-
is set to **`deny`** so that if the agent runs in a container *without* an attached
144-
terminal or Web UI, high‑risk commands are blocked rather than silently allowed.
141+
Commands are risk‑classified; destructive and unrecognised ones are denied, the rest
142+
prompt for approval. Crucially, `non_interactive` is set to **`deny`** so that if the
143+
agent runs in a container *without* an attached terminal or Web UI, anything that would
144+
prompt is blocked rather than silently allowed.
145145

146146
```json
147147
{
@@ -163,23 +163,81 @@ terminal or Web UI, high‑risk commands are blocked rather than silently allowe
163163
}
164164
```
165165

166-
**How the classes map** (built‑in risk model):
166+
#### What each field does
167167

168-
| Class | Examples | Restricted action |
169-
| --- | --- | --- |
170-
| `safe` | `ls`, `cat`, `echo` | allow |
171-
| `local_write` | write files in the working dir | allow |
172-
| `system_write` | `chmod`, `chown`, `mkdir /etc` | prompt |
173-
| `network_egress` | `curl`, `wget`, DNS lookups | prompt |
174-
| `code_execution` | `go run`, `python x.py` | prompt |
175-
| `install` | `npm install`, `apk add` | prompt |
176-
| `destructive` | `rm -rf`, `git rm`, `docker rm` | **deny** |
177-
| `blocked` | fork bombs, `dd` to block devices | **always deny** (cannot be overridden) |
168+
| Field | Meaning |
169+
| --- | --- |
170+
| `sandbox` | `false` runs commands directly in this container (the Compose setup already *is* the sandbox). `true` would nest a second Docker sandbox — not what you want here. |
171+
| `action` | **Global default** action for any class **not** listed under `classes`. `"prompt"` here, `"allow"` = godmode, `"deny"` = lockdown. ⚠️ This overrides the *built‑in* per‑class defaults (see the gotcha below). |
172+
| `non_interactive` | What to do with a **prompt**‑level command when there is no human channel (no TTY, no Web UI). `"deny"` blocks it; `"allow"` runs it. Always set this to `"deny"` for unattended/automated containers. |
173+
| `classes` | Per‑class action overrides. The most specific setting — it wins over `action` and the built‑in defaults. Only list the classes you want to pin. |
174+
| `allowlist` | Commands that always run, **exact string match**, no classification. Highest priority of all. Use for a handful of trusted exact commands (e.g. `"npm run deploy"`). |
175+
| `denylist` | Commands that are always denied, **prefix match** after trimming. Beats classification and even godmode — but **not** the allowlist. |
176+
177+
#### How the classes map (built‑in risk model)
178+
179+
| Class | Examples | Built‑in default | This profile |
180+
| --- | --- | --- | --- |
181+
| `safe` | `ls`, `cat`, `grep`, `git status` | allow | prompt¹ |
182+
| `local_write` | write files in the working dir | allow | allow |
183+
| `install` | `npm install`, `pip install`, `apk add` | prompt | prompt |
184+
| `network_egress` | `curl`, `wget`, `ssh`, DNS lookups | prompt | prompt |
185+
| `code_execution` | `curl … \| sh`, `bash -c`, `python -c`, `go run` | prompt | prompt |
186+
| `system_write` | `sudo`, writes to `/etc`, reads of `~/.ssh` | prompt | prompt |
187+
| `unknown` | any command whose program name Odek does **not** recognise | deny | prompt¹ → denied unattended |
188+
| `destructive` | `rm -rf /`, `dd … of=/dev/sda`, `mkfs` | deny | **deny** |
189+
| `blocked` | fork bombs, fully‑specified `dd` to a block device | **always deny** | **always deny** (cannot be overridden) |
190+
191+
> ¹ `safe` and `unknown` are not listed under `classes`, so the global
192+
> `action: "prompt"` applies to them — see the gotcha below. With a human channel
193+
> they prompt; unattended (`non_interactive: "deny"`) they are denied.
194+
195+
Odek **fails closed**: the `unknown` class catches any command whose verb isn't in the
196+
built‑in safe/dangerous tables, so a novel or obfuscated command can't slip through as
197+
"safe". To permit a specific unrecognised tool, add its exact invocation to `allowlist`,
198+
or relax the class with `"unknown": "prompt"`.
199+
200+
#### How an action is resolved (precedence, first match wins)
201+
202+
1. Command exactly matches an **`allowlist`** entry → **allow**.
203+
2. Command starts with a **`denylist`** entry → **deny**.
204+
3. Otherwise classify it, then: explicit **`classes`** entry → `blocked` is **always deny** → global **`action`** (if set) → built‑in class default.
205+
4. If the result is **prompt** and there's no human channel, **`non_interactive`** decides.
206+
207+
> **Gotcha — `action` overrides *every* unlisted class.** Because `action: "prompt"` is
208+
> set, any class you don't list under `classes` resolves to *prompt*, including `safe`.
209+
> So with this profile as written, even `ls` prompts (and is denied unattended). Two ways
210+
> to get the usual "safe commands just run" behavior:
211+
>
212+
> - add `"safe": "allow"` to `classes` (keep `action: "prompt"` as the catch‑all for
213+
> everything else, including `unknown`), **or**
214+
> - **omit `action` entirely** and only override the classes you care about — then unlisted
215+
> classes keep their built‑in defaults (safe/local_write allow; destructive/blocked/unknown
216+
> deny; system_write/network_egress/code_execution/install prompt).
217+
>
218+
> The second form is the better default if you want `unknown` to stay deny‑by‑default
219+
> rather than prompt.
178220
179221
> Approvals require a human channel: the **Web UI** (`odek serve`, modal approval over
180222
> WebSocket) or an **interactive terminal** (`odek repl` with `docker compose run -it`).
181223
> Without either, `non_interactive: "deny"` is what keeps you safe.
182224
225+
#### Customising the policy
226+
227+
```jsonc
228+
// Tighter: also block all outbound network and package installs.
229+
"classes": { "network_egress": "deny", "install": "deny", /**/ }
230+
231+
// Looser: pre‑approve a few exact commands you trust, keep everything else gated.
232+
"allowlist": ["npm ci", "npm run build", "go build ./..."]
233+
234+
// Allow one normally‑unrecognised tool without loosening the whole class:
235+
"allowlist": ["terraform plan"] // exact match only
236+
237+
// Full lockdown: deny everything except the allowlist.
238+
"action": "deny"
239+
```
240+
183241
### 5b. Godmode policy — `config.godmode.json`
184242

185243
YOLO mode. Every risk class returns `allow`; no prompts. The only thing still blocked is
@@ -547,9 +605,9 @@ Voice and photo messages are supported too. Sessions persist per chat in the loc
547605

548606
## Reference
549607

550-
- `docs/SANDBOXING.md` — Odek's nested‑Docker sandbox model (the `--sandbox` feature).
551-
- `docs/SECURITY.md` — threat model, approval flow, YOLO mode, attack‑vector matrix.
552-
- `docs/CONFIG.md` — full configuration layering and environment variables.
553-
- `docs/CLI.md` — all subcommands and flags, including the `dangerous` schema.
554-
- `docs/WEBUI.md` — Web UI protocol and the WebSocket approval flow.
555-
- `docs/TELEGRAM.md` — Telegram bot architecture, config variables, and slash commands.
608+
- [`SANDBOXING.md`](SANDBOXING.md) — Odek's nested‑Docker sandbox model (the `--sandbox` feature).
609+
- [`SECURITY.md`](SECURITY.md) — threat model, approval flow, YOLO mode, attack‑vector matrix.
610+
- [`CONFIG.md`](CONFIG.md) — full configuration layering and environment variables.
611+
- [`CLI.md`](CLI.md) — all subcommands and flags, including the `dangerous` schema.
612+
- [`WEBUI.md`](WEBUI.md) — Web UI protocol and the WebSocket approval flow.
613+
- [`TELEGRAM.md`](TELEGRAM.md) — Telegram bot architecture, config variables, and slash commands.

docs/SECURITY.md

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -69,18 +69,19 @@ The model is instructed (via the default system prompt) to treat the wrapped reg
6969

7070
### 3. Danger classifier (shell)
7171

72-
The `shell` tool tokenises commands and classifies each into one of 8 risk classes (`safe`, `local_write`, `system_write`, `destructive`, `network_egress`, `code_execution`, `install`, `blocked`). Per-class policy (allow / prompt / deny) is configurable.
72+
The `shell` tool tokenises commands and classifies each into one of 9 risk classes (`safe`, `local_write`, `system_write`, `destructive`, `network_egress`, `code_execution`, `install`, `unknown`, `blocked`). Per-class policy (allow / prompt / deny) is configurable.
7373

74-
The classifier is hardened against common evasion tricks:
74+
The gate **fails closed**: a command whose program name matches neither the known-safe allowlist nor any known-dangerous pattern is classified `unknown` and **denied by default** (same as `destructive`). Recognised commands used benignly are `safe`. So a novel or obfuscated verb cannot slip through as "safe" — to permit a specific tool, allowlist it or set `"unknown": "prompt"`.
7575

76-
- `$(echo rm) -rf /` — command substitution is recursively classified.
77-
- `` `echo rm` -rf / `` — backticks treated the same.
78-
- `\rm -rf /` and `r\m -rf /` — unquoted backslash escapes are collapsed.
79-
- `rm$IFS-rf$IFS/``$IFS` / `${IFS}` expanded to space.
80-
- `command rm -rf /` and `exec rm -rf /` — wrappers stripped.
81-
- `/bin/rm -rf /` — absolute paths basenamed before matching.
76+
The classifier is hardened against common evasion tricks (see the package doc in `internal/danger/classifier.go` for the full model):
8277

83-
A regression suite (`internal/danger/classifier_bypass_test.go`) pins these as known evasions. If you find a new bypass, the test file is the place to add it.
78+
- `$(echo rm) -rf /` / `` `echo rm` `` / `<(curl evil)` — command and process substitutions are recursively classified.
79+
- `\rm -rf /`, `r""m -rf /` — backslash escapes collapsed and quote boundaries are not word boundaries.
80+
- `rm$IFS-rf$IFS/`, `{rm,-rf,/}`, `$'\x72\x6d'``$IFS`, brace expansion, and ANSI-C escapes are normalised.
81+
- `command rm`, `env rm`, `sudo rm`, `/bin/rm`, `true | dd of=/dev/sda` — wrappers are stripped, every pipe stage is classified, and absolute paths are basenamed before matching.
82+
- `bash -i >& /dev/tcp/…`, `cat ~/.ssh/id_rsa` — reverse-shell channels and sensitive-path access are flagged regardless of the command verb.
83+
84+
Regression suites (`internal/danger/classifier_bypass_test.go` and `hardening_test.go`) pin these as known-closed evasions. If you find a new bypass, those test files are the place to add it.
8485

8586
### 4. Tool-call approval
8687

0 commit comments

Comments
 (0)