You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix: validate session/episode vector index IDs and reject symlinks
Finding #14 — Session vector index rebuild:
- Validate each session filename-derived ID with ValidateSessionID before
embedding it.
- Skip entries whose DirEntry.Type() is a symlink and double-check with
os.Lstat.
- Add internal/session/vector_index_test.go regression tests.
Finding #15 — Episode vector index session_id trust:
- Treat index.json as untrusted input.
- Call session.ValidateSessionID(m.SessionID) before building the .md path.
- Add defense-in-depth checks for .. and path separators.
- Log warnings for rejected entries.
- Add regression tests in internal/memory/episode_index_test.go.
Update docs/SECURITY.md and AGENTS.md for both defenses.
-**glob tool hardening** (`cmd/odek/file_tool.go`) — `glob` caps results at 1,000 matches and wraps returned paths as untrusted content.
114
114
-**Sub-agent task-file cap** (`cmd/odek/subagent.go`) — `odek subagent --task <file>` rejects task files larger than 10 MiB before loading them into memory.
115
115
-**session_search hardening** (`cmd/odek/session_search_tool.go`) — the `get` action returns at most the 100 most recent messages and wraps each message content, task, and buffer entry as untrusted; `list`/`search`/`find` also wrap session tasks.
116
+
-**Session vector index hardening** (`internal/session/vector_index.go`) — `rebuildLocked` validates every session filename with `ValidateSessionID` and skips symlinks via `DirEntry.Type()` and `os.Lstat`, preventing a planted symlink from embedding arbitrary files into the semantic search corpus.
116
117
-**@-resource / --ctx prompt wrapping** (`cmd/odek/refs.go`, `cmd/odek/serve.go`) — content resolved from `@file` references and `--ctx` files is wrapped as untrusted before being inserted into the prompt.
117
118
-**Config file size cap** (`internal/config/loader.go`) — `~/.odek/config.json` and `./odek.json` are rejected if larger than 5 MiB to prevent OOM from a malicious or broken config at startup.
118
119
-**Resource resolver size cap** (`internal/resource/resource.go`) — `@-resource` file loads are capped at 1 MiB to prevent OOM from `@hugefile` references.
-**Telegram outbound media path allowlist** (`internal/telegram/media_path.go` + `internal/telegram/handler.go` + `internal/tool/send_message.go` + `cmd/odek/telegram.go`) — paths supplied to `MEDIA:...` prefixes or `send_message(file=...)` are resolved to an absolute path and verified against an allowlist (cwd, `~/.odek/media/`, system temp dir). `os.Lstat` rejects symlink final components and `filepath.EvalSymlinks` ensures the resolved path does not escape the allowlist, preventing prompt-injection-driven exfiltration of arbitrary files.
142
143
-**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>` 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.
143
144
-**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.
145
+
-**Episode index session ID validation** (`internal/memory/episode_index.go` + `internal/session/session.go`) — `readAllSummaries` treats `index.json` as untrusted input and validates every `session_id` with `session.ValidateSessionID` before building the `filepath.Join(dir, sessionID+".md")` path. Invalid / traversal / separator-containing IDs are skipped with a warning, preventing a tampered episode index from pulling arbitrary files (e.g. `~/.odek/config.json`, `IDENTITY.md`) into the embedding space.
144
146
-**Secret redaction** (`internal/redact/redact.go`) — 20+ patterns: OpenAI, Anthropic, GitHub PAT, AWS, PEM, JWT, Vault, Google OAuth, SendGrid, Discord, DB URLs, etc.
Copy file name to clipboardExpand all lines: docs/SECURITY.md
+14Lines changed: 14 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -350,6 +350,19 @@ Legacy sessions created before this defense have no `AuthToken`; the first acces
350
350
351
351
Skill content and retrieved session episodes are externally-sourced data that cross the trust boundary. Before injecting them as `system` messages, the loop passes them through the same nonce'd `<untrusted_content_*>` wrapper used for tool output. The skill manager already gates `NeedsReview`/tainted skills, and the memory manager filters tainted episodes from search, but the wrapper provides defense-in-depth so a compromised skill or episode cannot pose as trusted system instructions.
352
352
353
+
### 26. Session vector index rebuild hardening
354
+
355
+
`internal/session/vector_index.go::rebuildLocked` scans the session directory to build the semantic search corpus. Before a file is read it must pass two checks:
356
+
357
+
1.**Session-ID validation** — the filename is stripped of its `.json` suffix and passed through `ValidateSessionID`. Names that are empty, contain path separators, or contain `..` are skipped.
358
+
2.**Symlink rejection** — the `os.DirEntry.Type()` is checked for `ModeSymlink`, and the full path is then `os.Lstat`ed to skip symlinks even on platforms/filesystems where `Type()` does not report the link.
359
+
360
+
This closes the path where an attacker plants a symlink named like a session file (e.g. `20260518-abc….json`) that points to a sensitive file outside the sessions directory, which would otherwise have its content embedded into the session search corpus.
361
+
362
+
### 27. Episode index session ID validation
363
+
364
+
The episode vector index is rebuilt from `index.json` plus one `.md` summary file per entry. Because `index.json` is persisted JSON that can be tampered with on disk, `internal/memory/episode_index.go::readAllSummaries` treats every `session_id` as untrusted input. It calls `session.ValidateSessionID` before constructing the path `filepath.Join(dir, sessionID+".md")` and skips (with a stderr warning) any entry that is empty, contains path separators, contains `..`, or is otherwise malformed. This prevents a tampered entry such as `"../../../.odek/config"` from causing the rebuild to read arbitrary files (e.g. `~/.odek/config.json` or `IDENTITY.md`) and include them in the embedding space.
365
+
353
366
### YOLO mode
354
367
355
368
```json
@@ -404,6 +417,7 @@ Defaults: `FrictionThreshold=3`, `FrictionWindow=60s`. To opt out (TTYApprover o
404
417
| Memory replays a previously-injected episode forever | Tainted episodes filtered from `Search`|
405
418
| User reflex-approves a destructive class after many benign ones | Friction mode requires typed `approve` + 1.5 s pause |
406
419
| Successful injection steers agent to attacker URL |`odek audit` flags `suspicious_divergence` on the turn |
420
+
| Symlink planted as session file exfiltrates arbitrary file into semantic search |`rebuildLocked` validates IDs and skips symlinks via `Lstat`|
0 commit comments