From 44c9c9ffa667fc6bd04a761d7555d373033db264 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sat, 18 Jul 2026 15:00:09 +0200 Subject: [PATCH] M3: classify odek self-invocations as system_write --- AGENTS.md | 1 + docs/SECURITY.md | 6 ++++++ internal/danger/classifier.go | 8 ++++++++ internal/danger/classifier_test.go | 21 +++++++++++++++++++++ 4 files changed, 36 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index c884dd5..555bf68 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -174,6 +174,7 @@ Layered prompt-injection / approval-fatigue defenses. Full reference: [docs/SECU - **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. - **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. - **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-` prefix, `ResumeSession` rejects cross-chat IDs, and plans live under `~/.odek/plans/chat/` so one chat cannot list, read, delete, or resume another chat's sessions/plans. +- **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. - **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/`, `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. - **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 `` 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. - **`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 ` still classifies `` normally. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 95118d4..05149fb 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -555,6 +555,12 @@ These commands are now scoped to the requesting chat: This removes the cross-chat session/plan disclosure path while keeping the CLI and admin flows functional. +### 39d. odek self-invocations classified as `system_write` + +Human-gated trust mutations such as `odek memory promote `, `odek memory extended confirm `, and `odek skill promote --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. + +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. + ### 40. `/api/resources` result limit cap 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. diff --git a/internal/danger/classifier.go b/internal/danger/classifier.go index 2c025b3..cfc08d5 100644 --- a/internal/danger/classifier.go +++ b/internal/danger/classifier.go @@ -1766,6 +1766,14 @@ func classifyCommand(tokens []string) RiskClass { return SystemWrite } + // odek self-invocations can reach human-gated trust mutations (`odek memory + // promote`, `odek memory extended confirm`, `odek skill promote --force`). + // Treating the whole binary as system_write prevents a prompt-injected agent + // from using the shell tool to flip its own taint gates. + if first == "odek" { + return SystemWrite + } + // Blocked if isBlocked(tokens) { return Blocked diff --git a/internal/danger/classifier_test.go b/internal/danger/classifier_test.go index 6e34953..6c2dea9 100644 --- a/internal/danger/classifier_test.go +++ b/internal/danger/classifier_test.go @@ -806,6 +806,27 @@ func TestClassify_ShellRCTargets(t *testing.T) { } } +func TestClassify_OdekSelfInvocation(t *testing.T) { + tests := []struct { + cmd string + want RiskClass + }{ + {"odek memory promote abc123", SystemWrite}, + {"odek memory extended confirm xyz", SystemWrite}, + {"odek skill promote evil-skill --force", SystemWrite}, + {"./odek memory list", SystemWrite}, + {"/usr/local/bin/odek --version", SystemWrite}, + } + for _, tt := range tests { + t.Run(tt.cmd, func(t *testing.T) { + got := Classify(tt.cmd) + if got != tt.want { + t.Errorf("Classify(%q) = %s, want %s", tt.cmd, got, tt.want) + } + }) + } +} + func TestClassify_PythonDashC(t *testing.T) { got := Classify("python -c 'print(1)'") if got != CodeExecution {