Skip to content

Commit 5f3774a

Browse files
jkyberneeesclaude
andcommitted
docs(danger): document the dangerous config and the fail-closed unknown class
Extend the user-facing guide for setting up the "dangerous" approval policy, and bring the security/CLI/dev docs in line with the new 9-class model (adds the fail-closed `unknown` class). DOCKER_COMPOSE_USER_GUIDE.md §5a now has a full field-by-field setup guide for the restricted profile: - what each key does (sandbox/action/non_interactive/classes/allow/deny) - the 9-class table with built-in vs profile actions - the action-resolution precedence (allowlist → denylist → classes → blocked → action → built-in default → non_interactive) - a gotcha: a global "action" overrides EVERY unlisted class, so the shipped restricted profile prompts even on `safe` (`ls`) unless you add "safe": "allow" or omit "action" — both shown - customisation recipes (tighten / loosen / allowlist one tool / lockdown) CLI.md, SECURITY.md, DEVELOPMENT.md: add the `unknown` class, state the fail-closed default, refresh the evasion list and regression-suite pointers. No code changes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 83ae46f commit 5f3774a

4 files changed

Lines changed: 89 additions & 25 deletions

File tree

DOCKER_COMPOSE_USER_GUIDE.md

Lines changed: 73 additions & 15 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

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 |

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)