Skip to content

Commit 685ff37

Browse files
authored
M3: classify odek self-invocations as system_write (#60)
1 parent 465a433 commit 685ff37

4 files changed

Lines changed: 36 additions & 0 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ Layered prompt-injection / approval-fatigue defenses. Full reference: [docs/SECU
174174
- **Telegram plan file size cap** (`internal/telegram/plan.go`) — plan files larger than 1 MiB are rejected by `ReadPlan` and `MostRecentPlan`, and `ListPlans` only reads the first 8 KiB for preview. This prevents a prompt-injected agent from causing an OOM via a multi-hundred-megabyte plan file.
175175
- **Telegram log file permissions** (`internal/telegram/log.go`) — Telegram log files are created with `0600`, and `os.Chmod` hardens existing files. Chat IDs and task snippets are no longer world-readable by default.
176176
- **Telegram chat-scoped sessions and plans** (`internal/telegram/session.go`, `internal/telegram/plan.go`, `cmd/odek/telegram.go`) — `/sessions`, `/resume`, `/prune`, `/plans`, `/plan_view`, `/plan_delete`, and `/plan_resume` are scoped to the requesting chat. Sessions are filtered by the `tg-<chatID>` prefix, `ResumeSession` rejects cross-chat IDs, and plans live under `~/.odek/plans/chat<chatID>/` so one chat cannot list, read, delete, or resume another chat's sessions/plans.
177+
- **odek self-invocation gate** (`internal/danger/classifier.go`) — any shell stage whose program basename is `odek` is classified as `system_write`. This prevents a prompt-injected agent from reaching the human-gated trust mutations (`odek memory promote`, `odek memory extended confirm`, `odek skill promote --force`) through the shell tool and flipping its own taint gates.
177178
- **Session ID entropy + session-scoped auth tokens** (`internal/session/session.go`, `cmd/odek/serve.go`) — session IDs now carry 128 bits of randomness (16 bytes / 32 hex chars); each session stores a 256-bit `AuthToken` required by `GET/DELETE/POST /api/sessions/<id>`, `POST /api/cancel`, and WebSocket session-resume messages via `X-Session-Token` header, `session_token` cookie, or `auth_token` WS field. Per-IP rate limiting (60/min) on session lookups adds a brute-force backstop.
178179
- **Skill/episode untrusted wrapper** (`internal/loop/loop.go` + `odek.go`) — skill context and retrieved session-episode context are passed through the caller-provided untrusted wrapper (the same nonce'd `<untrusted_content_*>` boundary used for tool output) before being injected into the model's system context. This prevents a compromised or tainted skill/episode from being treated as trusted system instructions.
179180
- **`env` / `printenv` environment-dump gate** (`internal/danger/classifier.go`) — bare `env` and `printenv` invocations are classified as `system_write` because they can leak process-environment secrets that the redaction scanner does not recognise. `env VAR=value <cmd>` still classifies `<cmd>` normally.

docs/SECURITY.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,12 @@ These commands are now scoped to the requesting chat:
555555

556556
This removes the cross-chat session/plan disclosure path while keeping the CLI and admin flows functional.
557557

558+
### 39d. odek self-invocations classified as `system_write`
559+
560+
Human-gated trust mutations such as `odek memory promote <session>`, `odek memory extended confirm <id>`, and `odek skill promote <name> --force` are intentionally exposed only through the CLI, not as agent tools. Because the shell tool would otherwise resolve a bare `odek` command through `commandName` and let it fall through to an interactive prompt (or auto-allow depending on policy), a prompt-injected agent could invoke these commands itself and flip its own taint gates.
561+
562+
The danger classifier now treats any shell stage whose program basename is `odek` as `system_write`, so every self-invocation requires explicit operator approval and cannot be used to bypass the memory/skill provenance gates from inside an agent session.
563+
558564
### 40. `/api/resources` result limit cap
559565

560566
The `/api/resources?q=...&limit=N` autocomplete endpoint previously accepted any positive `limit` value. It is now capped to 100 results both in the HTTP handler and in `Registry.Search`. This prevents a prompt-injected or attacker-forged request from forcing an unbounded directory walk and returning a multi-megabyte JSON response.

internal/danger/classifier.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1766,6 +1766,14 @@ func classifyCommand(tokens []string) RiskClass {
17661766
return SystemWrite
17671767
}
17681768

1769+
// odek self-invocations can reach human-gated trust mutations (`odek memory
1770+
// promote`, `odek memory extended confirm`, `odek skill promote --force`).
1771+
// Treating the whole binary as system_write prevents a prompt-injected agent
1772+
// from using the shell tool to flip its own taint gates.
1773+
if first == "odek" {
1774+
return SystemWrite
1775+
}
1776+
17691777
// Blocked
17701778
if isBlocked(tokens) {
17711779
return Blocked

internal/danger/classifier_test.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -806,6 +806,27 @@ func TestClassify_ShellRCTargets(t *testing.T) {
806806
}
807807
}
808808

809+
func TestClassify_OdekSelfInvocation(t *testing.T) {
810+
tests := []struct {
811+
cmd string
812+
want RiskClass
813+
}{
814+
{"odek memory promote abc123", SystemWrite},
815+
{"odek memory extended confirm xyz", SystemWrite},
816+
{"odek skill promote evil-skill --force", SystemWrite},
817+
{"./odek memory list", SystemWrite},
818+
{"/usr/local/bin/odek --version", SystemWrite},
819+
}
820+
for _, tt := range tests {
821+
t.Run(tt.cmd, func(t *testing.T) {
822+
got := Classify(tt.cmd)
823+
if got != tt.want {
824+
t.Errorf("Classify(%q) = %s, want %s", tt.cmd, got, tt.want)
825+
}
826+
})
827+
}
828+
}
829+
809830
func TestClassify_PythonDashC(t *testing.T) {
810831
got := Classify("python -c 'print(1)'")
811832
if got != CodeExecution {

0 commit comments

Comments
 (0)