Skip to content

Commit db47d8f

Browse files
committed
fix: cap write_file content, harden file_info, bound WebSocket/session/skill sizes
1 parent 09e4372 commit db47d8f

9 files changed

Lines changed: 201 additions & 2 deletions

File tree

AGENTS.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ System prompt is loaded by priority: `--system` flag > `~/.odek/IDENTITY.md` > c
9191
### Security Architecture
9292
Layered prompt-injection / approval-fatigue defenses. Full reference: [docs/SECURITY.md](docs/SECURITY.md).
9393

94-
- **Untrusted-content wrapper** (`cmd/odek/untrusted.go`) — every tool whose output sources from outside the trust boundary (`browser`, `read_file`, `shell`, `search_files`, `multi_grep`, `transcribe`, `head_tail`, `diff`, `tr`, `sort`, `json_query`, `batch_patch`, `glob`, `session_search`, `@-resources`, `--ctx` files, any MCP tool) wraps results in `<untrusted_content_<nonce> source="...">…</untrusted_content_<nonce>>`. Per-call nonce defeats wrapper-escape via literal close tag.
94+
- **Untrusted-content wrapper** (`cmd/odek/untrusted.go`) — every tool whose output sources from outside the trust boundary (`browser`, `read_file`, `shell`, `search_files`, `multi_grep`, `transcribe`, `head_tail`, `diff`, `tr`, `sort`, `json_query`, `batch_patch`, `glob`, `file_info`, `session_search`, `@-resources`, `--ctx` files, any MCP tool) wraps results in `<untrusted_content_<nonce> source="...">…</untrusted_content_<nonce>>`. Per-call nonce defeats wrapper-escape via literal close tag.
9595
- **Audit log** (`cmd/odek/audit.go` + `internal/session/audit.go`) — every `wrapUntrusted` call records source + content-hash + turn into `<sessions>/audit/<id>.json`. After each turn a divergence heuristic flags `suspicious_divergence=true` when the agent ingested untrusted content AND its tool calls referenced resources the user did not mention. Inspect with `odek audit <session-id>` / `odek audit --list`.
9696
- **Memory taint** (`internal/memory/provenance.go`) — `EpisodeProvenance` tracks Untrusted/Sources/UserApproved. Tainted episodes are stored but `Search()` filters them out, so a one-shot injection cannot persist via the episode pipeline. User must explicitly promote.
9797
- **Skill provenance gate** (`internal/skills/loader.go` + `cache.go`) — `Skill.Provenance{Untrusted, Sources, NeedsReview}`. NeedsReview skills pin to Lazy regardless of `auto_load`. `odek skill promote <name>` clears the flag after user review.
@@ -116,6 +116,11 @@ Layered prompt-injection / approval-fatigue defenses. Full reference: [docs/SECU
116116
- **Resource resolver size cap** (`internal/resource/resource.go`) — `@-resource` file loads are capped at 1 MiB to prevent OOM from `@hugefile` references.
117117
- **Sub-agent summary cap** (`cmd/odek/subagent_tool.go`) — each sub-agent result included in the `delegate_tasks` summary is truncated to 100 KiB to prevent memory DoS.
118118
- **patch / batch_patch output expansion cap** (`cmd/odek/file_tool.go`, `cmd/odek/perf_tools.go`) — the post-replacement result is capped at 10 MiB so `ReplaceAll` cannot explode memory.
119+
- **write_file content cap** (`cmd/odek/file_tool.go`) — the `content` argument is capped at 1 MiB to prevent disk exhaustion and memory pressure from a single enormous tool call.
120+
- **file_info confinement + wrapping** (`cmd/odek/file_tool.go`) — `file_info` respects the same `restrictToCWD` path confinement as `write_file`/`patch`, and the returned path is wrapped as untrusted content.
121+
- **WebSocket message-size cap** (`cmd/odek/serve.go`) — `odek serve` sets `MaxPayloadBytes` on every WebSocket connection so a local client cannot OOM the server with a huge frame.
122+
- **Session file size cap** (`internal/session/session.go`) — session files larger than 32 MiB are rejected by `Load()` to prevent OOM from tampered or corrupted transcripts.
123+
- **Skill file size cap** (`internal/skills/loader.go`) — `SKILL.md` files larger than 1 MiB are skipped so a malicious project cannot OOM the process at startup or bloat the system prompt.
119124
- **Serve sandbox default-on**`odek serve` enables `--sandbox` automatically unless `--no-sandbox` is passed.
120125
- **Secret redaction** (`internal/redact/redact.go`) — 20+ patterns: OpenAI, Anthropic, GitHub PAT, AWS, PEM, JWT, Vault, Google OAuth, SendGrid, Discord, DB URLs, etc.
121126

cmd/odek/file_tool.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ const maxLines = 2000
2525
// memory exhaustion from huge files.
2626
const maxReadBytes = 1 << 20 // 1 MiB
2727

28+
// maxWriteFileContentBytes caps the content argument of write_file to prevent
29+
// disk exhaustion and memory pressure from a single enormous tool call.
30+
const maxWriteFileContentBytes = maxReadBytes // 1 MiB
31+
2832
// maxSearchLimit caps the number of matches returned by search_files to
2933
// prevent unbounded result JSON from exhausting memory.
3034
const maxSearchLimit = 500
@@ -215,6 +219,9 @@ func (t *writeFileTool) Call(argsJSON string) (string, error) {
215219
if args.Path == "" {
216220
return jsonError("path is required")
217221
}
222+
if len(args.Content) > maxWriteFileContentBytes {
223+
return jsonError(fmt.Sprintf("content too large (%d bytes, max %d)", len(args.Content), maxWriteFileContentBytes))
224+
}
218225

219226
// Path confinement: when restrictToCWD is enabled, reject paths that
220227
// escape the working directory via ".." traversal or absolute paths.
@@ -1314,6 +1321,7 @@ func (t *globTool) Call(argsJSON string) (result string, err error) {
13141321

13151322
type fileInfoTool struct {
13161323
dangerousConfig danger.DangerousConfig
1324+
restrictToCWD bool // when true, reject paths escaping the working directory
13171325
}
13181326

13191327
func (t *fileInfoTool) Name() string { return "file_info" }
@@ -1368,6 +1376,16 @@ func (t *fileInfoTool) Call(argsJSON string) (result string, err error) {
13681376
return jsonError("path is required")
13691377
}
13701378

1379+
// Path confinement: when restrictToCWD is enabled, reject paths that
1380+
// escape the working directory via ".." traversal or absolute paths.
1381+
if t.restrictToCWD {
1382+
resolved, err := confineToCWD(args.Path)
1383+
if err != nil {
1384+
return jsonError(err.Error())
1385+
}
1386+
args.Path = resolved
1387+
}
1388+
13711389
// Security: classify path
13721390
risk := danger.ClassifyPath(args.Path)
13731391
if err := t.dangerousConfig.CheckOperation(danger.ToolOperation{
@@ -1401,6 +1419,10 @@ func (t *fileInfoTool) Call(argsJSON string) (result string, err error) {
14011419
IsRegular: lInfo.Mode().IsRegular(),
14021420
}
14031421

1422+
// file_info output originates from the filesystem trust boundary, so
1423+
// mark the returned path as untrusted.
1424+
fi.Path = wrapUntrusted("file_info:"+args.Path, fi.Path)
1425+
14041426
return jsonResult(fi)
14051427
}
14061428

cmd/odek/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1140,7 +1140,7 @@ func builtinTools(dc danger.DangerousConfig, sm *skills.SkillManager, approver d
11401140
&patchTool{dangerousConfig: dc, restrictToCWD: true},
11411141
&batchReadTool{dangerousConfig: dc},
11421142
&globTool{dangerousConfig: dc},
1143-
&fileInfoTool{dangerousConfig: dc},
1143+
&fileInfoTool{dangerousConfig: dc, restrictToCWD: true},
11441144
&batchPatchTool{dangerousConfig: dc, restrictToCWD: true},
11451145
&parallelShellTool{dangerousConfig: dc, approver: approver},
11461146
newHTTPBatchTool(dc),

cmd/odek/next_security_vulnerabilities_test.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"github.com/BackendStack21/odek/internal/llm"
1717
"github.com/BackendStack21/odek/internal/resource"
1818
"github.com/BackendStack21/odek/internal/session"
19+
"github.com/BackendStack21/odek/internal/skills"
1920
)
2021

2122
// ── 1. Browser history must be capped to avoid memory DoS ────────────────
@@ -801,3 +802,101 @@ func TestBatchPatch_RejectsOutputExpansion(t *testing.T) {
801802
t.Fatalf("expected size error, got: %q", r.Results[0].Error)
802803
}
803804
}
805+
806+
// ── 21. write_file must cap content size to prevent DoS / disk exhaustion ─
807+
808+
func TestWriteFile_CapsContentSize(t *testing.T) {
809+
path := filepath.Join(t.TempDir(), "out.txt")
810+
huge := strings.Repeat("x", maxWriteFileContentBytes+1)
811+
812+
tool := &writeFileTool{}
813+
result := callJSON(t, tool, fmt.Sprintf(`{"path":%q,"content":%q}`, path, huge))
814+
var r struct {
815+
Success bool `json:"success"`
816+
Error string `json:"error,omitempty"`
817+
}
818+
mustUnmarshal(t, result, &r)
819+
if r.Success {
820+
t.Fatal("write_file should reject content above maxWriteFileContentBytes")
821+
}
822+
if !strings.Contains(r.Error, "too large") {
823+
t.Fatalf("expected size error, got: %q", r.Error)
824+
}
825+
}
826+
827+
// ── 22. file_info must respect restrictToCWD and wrap its output ──────────
828+
829+
func TestFileInfo_RestrictToCWD(t *testing.T) {
830+
tool := &fileInfoTool{restrictToCWD: true}
831+
result := callJSON(t, tool, `{"path":"/etc/passwd"}`)
832+
var r struct {
833+
Error string `json:"error,omitempty"`
834+
}
835+
mustUnmarshal(t, result, &r)
836+
if r.Error == "" {
837+
t.Fatal("file_info with restrictToCWD=true should reject paths outside CWD")
838+
}
839+
}
840+
841+
func TestFileInfo_WrapsPath(t *testing.T) {
842+
t.Chdir(t.TempDir())
843+
os.WriteFile("target.txt", []byte("hello"), 0644)
844+
845+
tool := &fileInfoTool{restrictToCWD: true}
846+
result := callJSON(t, tool, `{"path":"target.txt"}`)
847+
var r struct {
848+
Path string `json:"path"`
849+
Error string `json:"error,omitempty"`
850+
}
851+
mustUnmarshal(t, result, &r)
852+
if r.Error != "" {
853+
t.Fatalf("unexpected error: %s", r.Error)
854+
}
855+
if !strings.HasPrefix(r.Path, "<untrusted_content_") {
856+
t.Fatalf("file_info path should be wrapped in untrusted_content, got: %q", r.Path)
857+
}
858+
}
859+
860+
// ── 23. session store Load must reject huge session files ─────────────────
861+
862+
func TestSessionLoad_CapsFileSize(t *testing.T) {
863+
t.Setenv("HOME", t.TempDir())
864+
store, err := session.NewStore()
865+
if err != nil {
866+
t.Fatalf("NewStore: %v", err)
867+
}
868+
869+
sessID := "20260613-abc123"
870+
sessPath := store.Path(sessID)
871+
if err := os.MkdirAll(filepath.Dir(sessPath), 0755); err != nil {
872+
t.Fatal(err)
873+
}
874+
// Write a session file that exceeds the cap.
875+
os.WriteFile(sessPath, []byte(strings.Repeat("x", session.MaxSessionFileBytes+1)), 0600)
876+
877+
_, err = store.Load(sessID)
878+
if err == nil {
879+
t.Fatal("session Load should reject a huge session file")
880+
}
881+
if !strings.Contains(err.Error(), "too large") {
882+
t.Fatalf("expected size error, got: %v", err)
883+
}
884+
}
885+
886+
// ── 24. skill loader must reject huge SKILL.md files ──────────────────────
887+
888+
func TestSkillLoader_CapsFileSize(t *testing.T) {
889+
projectDir := filepath.Join(t.TempDir(), ".odek", "skills")
890+
skillDir := filepath.Join(projectDir, "bigskill")
891+
if err := os.MkdirAll(skillDir, 0755); err != nil {
892+
t.Fatal(err)
893+
}
894+
895+
// Write a SKILL.md larger than the cap (no valid frontmatter needed).
896+
os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(strings.Repeat("x", skills.MaxSkillFileBytes+1)), 0644)
897+
898+
result := skills.ScanDirs(projectDir, "", nil)
899+
if len(result.AutoLoad)+len(result.Lazy) != 0 {
900+
t.Fatalf("skill loader should reject a huge SKILL.md, got %d skills", len(result.AutoLoad)+len(result.Lazy))
901+
}
902+
}

cmd/odek/serve.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ import (
3232
//go:embed ui
3333
var uiFS embed.FS
3434

35+
// maxWSMessageBytes caps the size of an incoming WebSocket text message.
36+
// This prevents a local client from exhausting server memory by sending a
37+
// multi-gigabyte frame.
38+
const maxWSMessageBytes = 8 * 1024 * 1024 // 8 MiB
39+
3540
// currentPromptCancel holds the cancel function for the currently executing
3641
// prompt. Used by the POST /api/cancel endpoint to abort a running agent.
3742
var currentPromptCancel atomic.Value
@@ -457,6 +462,10 @@ func handleWS(store *session.Store, resources *resource.Registry, resolved confi
457462
}()
458463
defer conn.Close()
459464

465+
// Cap incoming message size to prevent a local client from exhausting
466+
// server memory with a single huge frame.
467+
conn.MaxPayloadBytes = maxWSMessageBytes
468+
460469
// Create ONE agent per WebSocket connection — provides buffer
461470
// continuity across turns within the same session.
462471
agent, sandboxCleanup, mcpCleanup, approver, err := newServeAgent(resolved, system, func(v any) error {

cmd/odek/serve_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ func (s *testServer) handleResourceSearch() http.HandlerFunc {
113113
}
114114

115115
func (s *testServer) handleWebSocket(conn *golangws.Conn) {
116+
conn.MaxPayloadBytes = maxWSMessageBytes
116117
defer conn.Close()
117118

118119
for {
@@ -1717,3 +1718,41 @@ func TestServe_E2E_CancelWithMockLLM(t *testing.T) {
17171718
t.Log("No terminal event (connection may have been closed by cancel)")
17181719
}
17191720
}
1721+
1722+
// TestServe_WebSocketMaxPayload verifies that the WebSocket endpoint rejects
1723+
// incoming messages larger than maxWSMessageBytes to prevent memory DoS.
1724+
func TestServe_WebSocketMaxPayload(t *testing.T) {
1725+
s := startTestServer(t)
1726+
defer s.Close()
1727+
1728+
conn, err := golangws.Dial(s.wsURL+"/ws", "", "http://localhost")
1729+
if err != nil {
1730+
t.Fatalf("Dial(): %v", err)
1731+
}
1732+
defer conn.Close()
1733+
1734+
// First confirm a normal message works.
1735+
if err := golangws.Message.Send(conn, `{"type":"prompt","content":"hi"}`); err != nil {
1736+
t.Fatalf("Send small message: %v", err)
1737+
}
1738+
var small map[string]any
1739+
if err := readJSON(conn, &small); err != nil {
1740+
t.Fatalf("Receive small message response: %v", err)
1741+
}
1742+
if small["type"] != "session" {
1743+
t.Fatalf("expected session event, got %v", small["type"])
1744+
}
1745+
1746+
// Send a message that exceeds the payload cap.
1747+
huge := `{"type":"prompt","content":"` + strings.Repeat("x", int(maxWSMessageBytes)+1024) + `"}`
1748+
if err := golangws.Message.Send(conn, huge); err != nil {
1749+
// Some transports close the connection on send; that also satisfies the test.
1750+
return
1751+
}
1752+
1753+
// The next receive must fail because the server closed the connection.
1754+
var data []byte
1755+
if err := golangws.Message.Receive(conn, &data); err == nil {
1756+
t.Fatalf("expected connection to be closed after oversized message, but received: %s", string(data))
1757+
}
1758+
}

docs/SECURITY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ Tools that wrap:
6161
| `vision` | `vision:<file path>` (full description) |
6262
| `web_search` | `web_search:<query>` (results + answers from SearXNG) |
6363
| `session_search` | `session_search` (whole result — past sessions may be tainted) |
64+
| `file_info` | `file_info:<path>` (metadata about an external file) |
6465
| any MCP tool | `mcp:<server>:<tool>` |
6566

6667
`session_search` is wrapped because it can surface content from arbitrary past sessions — including sessions that ingested untrusted content. Wrapping its whole output keeps that content from re-entering as trusted instructions and records the retrieval in the audit log, closing a path that otherwise bypassed the memory taint gate (defense 5).

internal/session/session.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ import (
3232
"github.com/BackendStack21/odek/internal/redact"
3333
)
3434

35+
// MaxSessionFileBytes caps the on-disk size of a session file that Load will
36+
// read into memory. This prevents a tampered or corrupted multi-gigabyte
37+
// session file from causing an OOM when any caller loads it.
38+
const MaxSessionFileBytes = 32 * 1024 * 1024 // 32 MiB
39+
3540
// ── Types ──────────────────────────────────────────────────────────────
3641

3742
// Session represents a single multi-turn conversation with the agent.
@@ -319,6 +324,13 @@ func (s *Store) Load(id string) (*Session, error) {
319324
if err := ValidateSessionID(id); err != nil {
320325
return nil, err
321326
}
327+
info, err := os.Stat(s.path(id))
328+
if err != nil {
329+
return nil, fmt.Errorf("session: load %q: %w", id, err)
330+
}
331+
if info.Size() > MaxSessionFileBytes {
332+
return nil, fmt.Errorf("session: load %q: file too large (%d bytes, max %d)", id, info.Size(), MaxSessionFileBytes)
333+
}
322334
data, err := os.ReadFile(s.path(id))
323335
if err != nil {
324336
return nil, fmt.Errorf("session: load %q: %w", id, err)

internal/skills/loader.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ import (
88
"strings"
99
)
1010

11+
// MaxSkillFileBytes caps the size of a single SKILL.md file that the loader
12+
// will read into memory. A maliciously huge skill file could otherwise OOM
13+
// the process at startup or bloat the system prompt.
14+
const MaxSkillFileBytes = 1 * 1024 * 1024 // 1 MiB
15+
1116
// ── Frontmatter Parsing ───────────────────────────────────────────────
1217
//
1318
// Manual YAML frontmatter parser for the SKILL.md subset:
@@ -20,6 +25,13 @@ import (
2025
// parseSkillFile reads and parses a single SKILL.md file.
2126
// Returns nil if the file doesn't exist or can't be parsed.
2227
func parseSkillFile(path string) *Skill {
28+
info, err := os.Stat(path)
29+
if err != nil {
30+
return nil
31+
}
32+
if info.Size() > MaxSkillFileBytes {
33+
return nil
34+
}
2335
data, err := os.ReadFile(path)
2436
if err != nil {
2537
return nil

0 commit comments

Comments
 (0)