diff --git a/AGENTS.md b/AGENTS.md
index 331bb5f..dfccb21 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -91,7 +91,7 @@ System prompt is loaded by priority: `--system` flag > `~/.odek/IDENTITY.md` > c
### Security Architecture
Layered prompt-injection / approval-fatigue defenses. Full reference: [docs/SECURITY.md](docs/SECURITY.md).
-- **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`, any MCP tool) wraps results in ` source="...">…>`. Per-call nonce defeats wrapper-escape via literal close tag.
+- **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`, `tree`, `base64` file mode, `session_search`, `@-resources`, `--ctx` files, any MCP tool) wraps results in ` source="...">…>`. Browser page title and interactive-element text are wrapped in addition to the main content. Per-call nonce defeats wrapper-escape via literal close tag.
- **Audit log** (`cmd/odek/audit.go` + `internal/session/audit.go`) — every `wrapUntrusted` call records source + content-hash + turn into `/audit/.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 ` / `odek audit --list`.
- **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.
- **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 ` clears the flag after user review.
@@ -100,6 +100,35 @@ Layered prompt-injection / approval-fatigue defenses. Full reference: [docs/SECU
- **Approver friction** (`internal/danger/approver.go`, `cmd/odek/wsapprover.go`) — both TTYApprover and WSApprover engage friction mode after 3 approvals of the same class in 60s: require typing literal `approve`, 1.5s pause. Trust-class shortcut disabled for `destructive` + `blocked` regardless.
- **Danger classifier bypass resistance** (`internal/danger/classifier.go`) — `normalize()` pre-processes: expand `$IFS` / `${IFS}`, extract `$(...)` / `` `...` `` substitutions, strip `command` / `exec` / `builtin` wrappers, collapse unquoted backslashes, basename absolute paths. Regression suite in `classifier_bypass_test.go`.
- **WS Origin allowlist** (`cmd/odek/serve.go::checkLocalOrigin`) — rejects non-localhost upgrades. Closes CSRF-on-localhost.
+- **REST API CSRF protection** (`cmd/odek/serve.go::requireLocalOrigin`) — state-changing HTTP endpoints (POST/PUT/PATCH/DELETE) require a localhost origin or no Origin header, and static responses set `X-Frame-Options: DENY` + `Content-Security-Policy: frame-ancestors 'none'` to block clickjacking.
+- **Browser history cap** (`cmd/odek/browser_tool.go`) — navigation history is capped at 50 snapshots to prevent memory DoS from repeated `browser_navigate` calls.
+- **Browser element cap** (`cmd/odek/browser_tool.go`) — the number of interactive elements extracted per page is capped at 500 so a hostile page cannot OOM the agent with thousands of links or buttons.
+- **Search result bounds** (`cmd/odek/file_tool.go`, `cmd/odek/perf_tools.go`) — `search_files` and `multi_grep` enforce a max match limit (500) and a total returned-content cap (1 MiB) to avoid unbounded result JSON.
+- **Perf-tool file-size cap** (`cmd/odek/perf_tools.go`) — `diff`, `base64`, `tr`, `sort`, `json_query`, and `batch_patch` reject files larger than 10 MiB to avoid loading multi-gigabyte files into memory.
+- **Shell output cap** (`cmd/odek/shell.go`, `cmd/odek/perf_tools.go`) — `shell` and `parallel_shell` cap captured stdout/stderr at 1 MiB per stream to prevent memory DoS from commands that dump huge files.
+- **Browser request timeout** (`cmd/odek/browser_tool.go`) — the browser HTTP client enforces a 30-second request timeout so a slow/malicious server cannot hang the agent turn.
+- **Transcribe input/output guard** (`cmd/odek/transcribe_tool.go`) — rejects audio files larger than 10 MiB, caps whisper stdout at 10 MiB, and writes ffmpeg output to a temp file so it cannot clobber an existing `.wav` next to the source path.
+- **Tree width cap** (`cmd/odek/perf_tools.go`) — the `tree` tool limits each directory listing to 1,000 entries to avoid OOM from directories with millions of files.
+- **patch tool hardening** (`cmd/odek/file_tool.go`) — `patch` rejects files larger than 10 MiB and preserves the original file mode instead of resetting it to 0644.
+- **glob tool hardening** (`cmd/odek/file_tool.go`) — `glob` caps results at 1,000 matches and wraps returned paths as untrusted content.
+- **Sub-agent task-file cap** (`cmd/odek/subagent.go`) — `odek subagent --task ` rejects task files larger than 10 MiB before loading them into memory.
+- **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.
+- **@-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.
+- **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.
+- **Resource resolver size cap** (`internal/resource/resource.go`) — `@-resource` file loads are capped at 1 MiB to prevent OOM from `@hugefile` references.
+- **Resource resolver symlink hardening** (`internal/resource/resource.go`) — `FileResolver.Search` uses `os.Lstat` (not `os.Stat`) for search-result metadata, so symlinks cannot leak the size of arbitrary targets outside the workspace.
+- **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.
+- **Tree path wrapping** (`cmd/odek/perf_tools.go`) — the `tree` tool wraps every filesystem-derived path as untrusted content.
+- **head_tail output cap** (`cmd/odek/perf_tools.go`) — `head_tail` truncates returned lines so total content stays within 1 MiB, preventing multi-file/multi-line memory DoS.
+- **search_files symlink hardening** (`cmd/odek/file_tool.go`) — the `files` target uses `Lstat` (not `Stat`) and skips symlinks in the glob branch, closing metadata disclosure via symlinked paths.
+- **AGENTS.md size cap** (`odek.go`) — project-level `AGENTS.md` is ignored if larger than 256 KiB to prevent OOM/prompt stuffing from a malicious repo.
+- **IDENTITY.md size cap** (`cmd/odek/main.go`) — `~/.odek/IDENTITY.md` is ignored if larger than 256 KiB, falling back to the default identity.
+- **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.
+- **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.
+- **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.
+- **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.
+- **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.
+- **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.
- **Serve sandbox default-on** — `odek serve` enables `--sandbox` automatically unless `--no-sandbox` is passed.
- **Secret redaction** (`internal/redact/redact.go`) — 20+ patterns: OpenAI, Anthropic, GitHub PAT, AWS, PEM, JWT, Vault, Google OAuth, SendGrid, Discord, DB URLs, etc.
diff --git a/cmd/odek/browser_tool.go b/cmd/odek/browser_tool.go
index 98ae972..9c25f31 100644
--- a/cmd/odek/browser_tool.go
+++ b/cmd/odek/browser_tool.go
@@ -9,6 +9,7 @@ import (
"regexp"
"strings"
"sync"
+ "time"
"github.com/BackendStack21/odek/internal/danger"
)
@@ -44,6 +45,15 @@ type browserSnapshot struct {
Elements []clickableRef `json:"elements,omitempty"`
}
+// maxBrowserHistory caps the number of snapshots retained in browser state to
+// prevent memory DoS from repeated navigate actions.
+const maxBrowserHistory = 50
+
+// maxBrowserElements caps the number of interactive elements extracted from a
+// page to prevent a hostile page from OOMing the agent with thousands of links
+// or buttons.
+const maxBrowserElements = 500
+
// browserState holds the shared state for one browser session.
type browserState struct {
mu sync.Mutex
@@ -62,12 +72,17 @@ type browserTool struct {
trustedClasses map[danger.RiskClass]bool
}
+// browserRequestTimeout bounds each browser HTTP request. Tests may lower it to
+// verify timeout behavior.
+var browserRequestTimeout = 30 * time.Second
+
func newBrowserTool(dc danger.DangerousConfig) *browserTool {
t := &browserTool{
state: &browserState{nextRef: 1},
dangerousConfig: dc,
}
t.client = &http.Client{
+ Timeout: browserRequestTimeout,
CheckRedirect: t.checkRedirect,
Transport: ssrfGuardedTransport(),
}
@@ -167,6 +182,7 @@ func (t *browserTool) Call(argsJSON string) (string, error) {
}
if t.client == nil {
t.client = &http.Client{
+ Timeout: browserRequestTimeout,
CheckRedirect: t.checkRedirect,
Transport: ssrfGuardedTransport(),
}
@@ -226,10 +242,15 @@ func (t *browserTool) doNavigate(rawURL string) (string, error) {
html := string(body)
snap := parseHTML(html, rawURL, resp.StatusCode)
- // Store in state
+ // Store in state. Keep a persistent copy of the snapshot for current; the
+ // local variable's address would otherwise escape to the heap implicitly.
t.state.mu.Lock()
t.state.history = append(t.state.history, snap)
- t.state.current = &snap
+ if len(t.state.history) > maxBrowserHistory {
+ t.state.history = t.state.history[len(t.state.history)-maxBrowserHistory:]
+ }
+ snapCopy := snap
+ t.state.current = &snapCopy
t.state.nextRef = len(snap.Elements) + 1
t.state.mu.Unlock()
@@ -363,6 +384,9 @@ func parseHTML(html, pageURL string, status int) browserSnapshot {
// Extract links
for _, m := range reLink.FindAllStringSubmatch(html, -1) {
+ if len(elements) >= maxBrowserElements {
+ break
+ }
href := strings.TrimSpace(m[1])
text := strings.TrimSpace(m[2])
if href == "" || text == "" || href == "#" || strings.HasPrefix(href, "javascript:") {
@@ -388,6 +412,9 @@ func parseHTML(html, pageURL string, status int) browserSnapshot {
// Extract buttons and inputs
for _, m := range reButton.FindAllStringSubmatch(html, -1) {
+ if len(elements) >= maxBrowserElements {
+ break
+ }
text := strings.TrimSpace(m[1])
if text == "" {
text = "button"
@@ -403,6 +430,9 @@ func parseHTML(html, pageURL string, status int) browserSnapshot {
}
for _, m := range reInput.FindAllStringSubmatch(html, -1) {
+ if len(elements) >= maxBrowserElements {
+ break
+ }
tag := m[0]
text := ""
if vm := reInputVal.FindStringSubmatch(tag); len(vm) > 1 {
@@ -426,6 +456,12 @@ func parseHTML(html, pageURL string, status int) browserSnapshot {
snap.Content = strings.Join(contentParts, "\n")
snap.Elements = elements
+ // Title and element text come from the page — wrap them as untrusted content.
+ snap.Title = wrapUntrusted(pageURL, snap.Title)
+ for i := range snap.Elements {
+ snap.Elements[i].Text = wrapUntrusted(pageURL, snap.Elements[i].Text)
+ }
+
return snap
}
diff --git a/cmd/odek/browser_tool_test.go b/cmd/odek/browser_tool_test.go
index 8e47494..df3cba7 100644
--- a/cmd/odek/browser_tool_test.go
+++ b/cmd/odek/browser_tool_test.go
@@ -34,8 +34,8 @@ func TestBrowser_Navigate(t *testing.T) {
if r.Error != "" {
t.Fatalf("navigate error: %s", r.Error)
}
- if r.Title != "Test Page" {
- t.Errorf("title = %q, want %q", r.Title, "Test Page")
+ if unwrapUntrusted(r.Title) != "Test Page" {
+ t.Errorf("title = %q, want %q", unwrapUntrusted(r.Title), "Test Page")
}
if !strings.Contains(r.Content, "Hello World") {
t.Errorf("content missing 'Hello World': %q", r.Content)
diff --git a/cmd/odek/file_tool.go b/cmd/odek/file_tool.go
index 7b4568a..194d4f9 100644
--- a/cmd/odek/file_tool.go
+++ b/cmd/odek/file_tool.go
@@ -25,6 +25,22 @@ const maxLines = 2000
// memory exhaustion from huge files.
const maxReadBytes = 1 << 20 // 1 MiB
+// maxWriteFileContentBytes caps the content argument of write_file to prevent
+// disk exhaustion and memory pressure from a single enormous tool call.
+const maxWriteFileContentBytes = maxReadBytes // 1 MiB
+
+// maxSearchLimit caps the number of matches returned by search_files to
+// prevent unbounded result JSON from exhausting memory.
+const maxSearchLimit = 500
+
+// maxSearchResultBytes caps the total returned content bytes for a single
+// search_files / multi_grep content query.
+const maxSearchResultBytes = maxReadBytes
+
+// maxGlobMatches caps the number of paths returned by the glob tool to prevent
+// unbounded JSON responses from broad patterns.
+const maxGlobMatches = 1000
+
type readFileTool struct {
dangerousConfig danger.DangerousConfig
}
@@ -203,6 +219,9 @@ func (t *writeFileTool) Call(argsJSON string) (string, error) {
if args.Path == "" {
return jsonError("path is required")
}
+ if len(args.Content) > maxWriteFileContentBytes {
+ return jsonError(fmt.Sprintf("content too large (%d bytes, max %d)", len(args.Content), maxWriteFileContentBytes))
+ }
// Path confinement: when restrictToCWD is enabled, reject paths that
// escape the working directory via ".." traversal or absolute paths.
@@ -371,6 +390,9 @@ func (t *searchFilesTool) Call(argsJSON string) (string, error) {
if args.Limit <= 0 {
args.Limit = maxMatches
}
+ if args.Limit > maxSearchLimit {
+ args.Limit = maxSearchLimit
+ }
// Security: check search path
risk := danger.ClassifyPath(args.Path)
@@ -398,6 +420,7 @@ func (t *searchFilesTool) searchContent(args searchFilesArgs) (string, error) {
var matches []searchMatch
limit := args.Limit
+ resultBytes := 0
err = filepath.Walk(args.Path, func(path string, info os.FileInfo, err error) error {
if err != nil {
@@ -455,10 +478,16 @@ func (t *searchFilesTool) searchContent(args searchFilesArgs) (string, error) {
lineNum++
line := scanner.Text()
if re.MatchString(line) {
+ trimmed := strings.TrimSpace(line)
+ if resultBytes+len(trimmed) > maxSearchResultBytes {
+ limit = len(matches)
+ break
+ }
+ resultBytes += len(trimmed)
matches = append(matches, searchMatch{
Path: path,
Line: lineNum,
- Content: wrapUntrusted(fmt.Sprintf("%s:%d", path, lineNum), strings.TrimSpace(line)),
+ Content: wrapUntrusted(fmt.Sprintf("%s:%d", path, lineNum), trimmed),
})
if len(matches) >= limit {
break
@@ -493,12 +522,18 @@ func (t *searchFilesTool) searchFiles(args searchFilesArgs) (string, error) {
return jsonError(fmt.Sprintf("invalid glob %q: %v", pattern, err))
}
for _, p := range globMatches {
- info, err := os.Stat(p)
- if err == nil && !info.IsDir() {
- matches = append(matches, searchMatch{Path: p})
- if len(matches) >= limit {
- break
- }
+ // Lstat so symlinks are not followed to their targets for metadata.
+ info, err := os.Lstat(p)
+ if err != nil {
+ continue
+ }
+ // Skip directories and symlinks — same policy as the walk branch.
+ if info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
+ continue
+ }
+ matches = append(matches, searchMatch{Path: wrapUntrusted("search_files:"+p, p)})
+ if len(matches) >= limit {
+ break
}
}
} else {
@@ -522,7 +557,7 @@ func (t *searchFilesTool) searchFiles(args searchFilesArgs) (string, error) {
}
match, _ := filepath.Match(pattern, info.Name())
if match {
- matches = append(matches, searchMatch{Path: path})
+ matches = append(matches, searchMatch{Path: wrapUntrusted("search_files:"+path, path)})
if len(matches) >= limit {
return filepath.SkipAll
}
@@ -531,12 +566,13 @@ func (t *searchFilesTool) searchFiles(args searchFilesArgs) (string, error) {
})
}
- // Sort by modification time (newest first)
+ // Sort by modification time (newest first). Use Lstat so symlinks are not
+ // followed and their own metadata is used for sorting.
sort.Slice(matches, func(i, j int) bool {
- fi, _ := os.Stat(matches[i].Path)
- fj, _ := os.Stat(matches[j].Path)
+ fi, _ := os.Lstat(unwrapUntrusted(matches[i].Path))
+ fj, _ := os.Lstat(unwrapUntrusted(matches[j].Path))
if fi == nil || fj == nil {
- return matches[i].Path < matches[j].Path
+ return unwrapUntrusted(matches[i].Path) < unwrapUntrusted(matches[j].Path)
}
return fi.ModTime().After(fj.ModTime())
})
@@ -638,6 +674,16 @@ func (t *patchTool) Call(argsJSON string) (string, error) {
}
defer f.Close()
+ // Reject files that would exhaust memory during the read/edit/write cycle.
+ info, err := f.Stat()
+ if err != nil {
+ return jsonError(fmt.Sprintf("cannot stat %q: %v", args.Path, err))
+ }
+ if info.Size() > maxFileReadBytes {
+ return jsonError(fmt.Sprintf("file too large (%d bytes, max %d)", info.Size(), maxFileReadBytes))
+ }
+ origMode := info.Mode().Perm()
+
// Read content through the opened fd (not re-opening the path)
var sb strings.Builder
_, err = io.Copy(&sb, f)
@@ -657,6 +703,9 @@ func (t *patchTool) Call(argsJSON string) (string, error) {
} else {
modified = strings.Replace(original, args.OldString, args.NewString, 1)
}
+ if len(modified) > maxFileReadBytes {
+ return jsonError(fmt.Sprintf("patch result too large (%d bytes, max %d)", len(modified), maxFileReadBytes))
+ }
// Generate a simple diff
diff := fmt.Sprintf("--- a/%s\n+++ b/%s\n@@ -1 +1 @@\n-%s\n+%s\n",
@@ -681,7 +730,7 @@ func (t *patchTool) Call(argsJSON string) (string, error) {
os.Remove(tmpPath)
return jsonError(fmt.Sprintf("cannot write %q: %v", args.Path, err))
}
- if err := tmpFile.Chmod(0644); err != nil {
+ if err := tmpFile.Chmod(origMode); err != nil {
tmpFile.Close()
os.Remove(tmpPath)
return jsonError(fmt.Sprintf("cannot set permissions %q: %v", args.Path, err))
@@ -1137,6 +1186,9 @@ func (t *globTool) Call(argsJSON string) (result string, err error) {
if args.Limit <= 0 {
args.Limit = maxMatches
}
+ if args.Limit > maxGlobMatches {
+ args.Limit = maxGlobMatches
+ }
// Security: classify search root path
risk := danger.ClassifyPath(args.Path)
@@ -1260,6 +1312,10 @@ func (t *globTool) Call(argsJSON string) (result string, err error) {
return fi.ModTime().After(fj.ModTime())
})
+ for i := range matches {
+ matches[i].Path = wrapUntrusted("glob:"+args.Path, matches[i].Path)
+ }
+
return jsonResult(globResult{Matches: matches})
}
@@ -1272,6 +1328,7 @@ func (t *globTool) Call(argsJSON string) (result string, err error) {
type fileInfoTool struct {
dangerousConfig danger.DangerousConfig
+ restrictToCWD bool // when true, reject paths escaping the working directory
}
func (t *fileInfoTool) Name() string { return "file_info" }
@@ -1326,6 +1383,16 @@ func (t *fileInfoTool) Call(argsJSON string) (result string, err error) {
return jsonError("path is required")
}
+ // Path confinement: when restrictToCWD is enabled, reject paths that
+ // escape the working directory via ".." traversal or absolute paths.
+ if t.restrictToCWD {
+ resolved, err := confineToCWD(args.Path)
+ if err != nil {
+ return jsonError(err.Error())
+ }
+ args.Path = resolved
+ }
+
// Security: classify path
risk := danger.ClassifyPath(args.Path)
if err := t.dangerousConfig.CheckOperation(danger.ToolOperation{
@@ -1359,6 +1426,10 @@ func (t *fileInfoTool) Call(argsJSON string) (result string, err error) {
IsRegular: lInfo.Mode().IsRegular(),
}
+ // file_info output originates from the filesystem trust boundary, so
+ // mark the returned path as untrusted.
+ fi.Path = wrapUntrusted("file_info:"+args.Path, fi.Path)
+
return jsonResult(fi)
}
diff --git a/cmd/odek/file_tool_test.go b/cmd/odek/file_tool_test.go
index 691d4f5..fc99d62 100644
--- a/cmd/odek/file_tool_test.go
+++ b/cmd/odek/file_tool_test.go
@@ -402,7 +402,7 @@ func TestSearchFiles_FindByName(t *testing.T) {
}
for _, m := range r.Matches {
- name := filepath.Base(m.Path)
+ name := filepath.Base(unwrapUntrusted(m.Path))
if name != "main.go" && name != "main_test.go" {
t.Errorf("unexpected match: %s", name)
}
@@ -939,8 +939,9 @@ func TestSearchFiles_GlobWithPathSeparator(t *testing.T) {
if len(r.Matches) != 1 {
t.Fatalf("expected 1 match for 'subdir/*.txt', got %d", len(r.Matches))
}
- if !strings.HasSuffix(r.Matches[0].Path, "subdir/result.txt") && !strings.HasSuffix(r.Matches[0].Path, "subdir\\result.txt") {
- t.Errorf("unexpected match path: %s", r.Matches[0].Path)
+ p := unwrapUntrusted(r.Matches[0].Path)
+ if !strings.HasSuffix(p, "subdir/result.txt") && !strings.HasSuffix(p, "subdir\\result.txt") {
+ t.Errorf("unexpected match path: %s", p)
}
}
@@ -1491,8 +1492,8 @@ func TestSearchFiles_FilesTargetWithPathSeparator(t *testing.T) {
if len(r.Matches) != 1 {
t.Fatalf("expected 1 match for 'sub/*.txt', got %d", len(r.Matches))
}
- if !strings.Contains(r.Matches[0].Path, "nested.txt") {
- t.Errorf("expected nested.txt match, got: %s", r.Matches[0].Path)
+ if !strings.Contains(unwrapUntrusted(r.Matches[0].Path), "nested.txt") {
+ t.Errorf("expected nested.txt match, got: %s", unwrapUntrusted(r.Matches[0].Path))
}
}
@@ -1512,8 +1513,8 @@ func TestSearchFiles_FilesTargetHiddenDirSkipped(t *testing.T) {
}
mustUnmarshal(t, result, &r)
for _, m := range r.Matches {
- if strings.Contains(m.Path, ".hidden") {
- t.Errorf("should not include hidden dir contents: %s", m.Path)
+ if strings.Contains(unwrapUntrusted(m.Path), ".hidden") {
+ t.Errorf("should not include hidden dir contents: %s", unwrapUntrusted(m.Path))
}
}
if len(r.Matches) != 1 {
diff --git a/cmd/odek/main.go b/cmd/odek/main.go
index 58cd73d..3bcd20b 100644
--- a/cmd/odek/main.go
+++ b/cmd/odek/main.go
@@ -158,6 +158,11 @@ func buildSystemPrompt(resolved config.ResolvedConfig) string {
return base
}
+// maxIdentityFileBytes caps the size of ~/.odek/IDENTITY.md that will be
+// loaded into the system prompt. A tampered or corrupted identity file could
+// otherwise OOM the process or stuff every prompt.
+const maxIdentityFileBytes = 256 * 1024 // 256 KiB
+
// loadIdentityFile reads ~/.odek/IDENTITY.md and returns its content.
// Returns defaultSystem if the file does not exist or cannot be read.
func loadIdentityFile() string {
@@ -166,6 +171,14 @@ func loadIdentityFile() string {
return defaultSystem
}
path := filepath.Join(home, ".odek", "IDENTITY.md")
+ info, err := os.Stat(path)
+ if err != nil {
+ return defaultSystem
+ }
+ if info.Size() > maxIdentityFileBytes {
+ fmt.Fprintf(os.Stderr, "odek: warning: IDENTITY.md is too large (%d bytes, max %d) — using default identity\n", info.Size(), maxIdentityFileBytes)
+ return defaultSystem
+ }
data, err := os.ReadFile(path)
if err != nil {
return defaultSystem
@@ -1140,7 +1153,7 @@ func builtinTools(dc danger.DangerousConfig, sm *skills.SkillManager, approver d
&patchTool{dangerousConfig: dc, restrictToCWD: true},
&batchReadTool{dangerousConfig: dc},
&globTool{dangerousConfig: dc},
- &fileInfoTool{dangerousConfig: dc},
+ &fileInfoTool{dangerousConfig: dc, restrictToCWD: true},
&batchPatchTool{dangerousConfig: dc, restrictToCWD: true},
¶llelShellTool{dangerousConfig: dc, approver: approver},
newHTTPBatchTool(dc),
diff --git a/cmd/odek/next_security_vulnerabilities_test.go b/cmd/odek/next_security_vulnerabilities_test.go
new file mode 100644
index 0000000..ef13bd7
--- /dev/null
+++ b/cmd/odek/next_security_vulnerabilities_test.go
@@ -0,0 +1,1140 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/BackendStack21/odek/internal/config"
+ "github.com/BackendStack21/odek/internal/danger"
+ "github.com/BackendStack21/odek/internal/llm"
+ "github.com/BackendStack21/odek/internal/resource"
+ "github.com/BackendStack21/odek/internal/session"
+ "github.com/BackendStack21/odek/internal/skills"
+)
+
+// ── 1. Browser history must be capped to avoid memory DoS ────────────────
+
+func TestBrowser_HistoryCap(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ fmt.Fprint(w, "page")
+ }))
+ defer srv.Close()
+
+ tool := newBrowserTool(danger.DangerousConfig{})
+ for i := 0; i < 55; i++ {
+ callJSON(t, tool, fmt.Sprintf(`{"action":"navigate","url":%q}`, srv.URL))
+ }
+
+ if len(tool.state.history) > 50 {
+ t.Fatalf("browser history grew unbounded: got %d snapshots (max expected 50)", len(tool.state.history))
+ }
+}
+
+// ── 2. search_files / multi_grep must cap limit and result size ──────────
+
+func TestSearchFiles_LimitCap(t *testing.T) {
+ dir := t.TempDir()
+ var lines []string
+ for i := 0; i < 600; i++ {
+ lines = append(lines, "match")
+ }
+ os.WriteFile(filepath.Join(dir, "data.txt"), []byte(strings.Join(lines, "\n")), 0644)
+
+ tool := &searchFilesTool{}
+ result := callJSON(t, tool, fmt.Sprintf(`{"pattern":"match","path":%q,"limit":10000}`, dir))
+ var r struct {
+ Matches []any `json:"matches"`
+ }
+ mustUnmarshal(t, result, &r)
+ if len(r.Matches) > 500 {
+ t.Fatalf("search_files limit was not capped: got %d matches", len(r.Matches))
+ }
+}
+
+func TestSearchFiles_ResultByteCap(t *testing.T) {
+ dir := t.TempDir()
+ line := strings.Repeat("x", 500*1024) + " MATCH"
+ os.WriteFile(filepath.Join(dir, "big.txt"), []byte(line+"\n"+line+"\n"+line+"\n"), 0644)
+
+ tool := &searchFilesTool{}
+ result := callJSON(t, tool, fmt.Sprintf(`{"pattern":"MATCH","path":%q,"limit":10}`, dir))
+ var r struct {
+ Matches []struct {
+ Content string `json:"content"`
+ } `json:"matches"`
+ }
+ mustUnmarshal(t, result, &r)
+
+ total := 0
+ for _, m := range r.Matches {
+ total += len(unwrapUntrusted(m.Content))
+ }
+ if total > 1024*1024 {
+ t.Fatalf("search_files returned %d bytes of content, expected cap near 1 MiB", total)
+ }
+}
+
+func TestMultiGrep_LimitCap(t *testing.T) {
+ dir := t.TempDir()
+ var lines []string
+ for i := 0; i < 600; i++ {
+ lines = append(lines, "match")
+ }
+ os.WriteFile(filepath.Join(dir, "data.txt"), []byte(strings.Join(lines, "\n")), 0644)
+
+ tool := &multiGrepTool{dangerousConfig: danger.DangerousConfig{}}
+ result := callJSON(t, tool, fmt.Sprintf(`{"patterns":["match"],"path":%q,"limit":10000}`, dir))
+ var r struct {
+ Results []struct {
+ Matches []any `json:"matches"`
+ } `json:"results"`
+ }
+ mustUnmarshal(t, result, &r)
+ if len(r.Results) != 1 {
+ t.Fatalf("expected 1 pattern result, got %d", len(r.Results))
+ }
+ if len(r.Results[0].Matches) > 500 {
+ t.Fatalf("multi_grep limit was not capped: got %d matches", len(r.Results[0].Matches))
+ }
+}
+
+// ── 3. perf tools must reject (not load) huge files ──────────────────────
+
+func TestBase64_RejectsHugeFile(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "huge.bin")
+ os.WriteFile(path, make([]byte, 15*1024*1024), 0644)
+
+ tool := &base64Tool{dangerousConfig: danger.DangerousConfig{}}
+ result := callJSON(t, tool, fmt.Sprintf(`{"path":%q}`, path))
+ var r struct {
+ Encoded string `json:"encoded,omitempty"`
+ Error string `json:"error,omitempty"`
+ }
+ mustUnmarshal(t, result, &r)
+ if r.Encoded != "" {
+ t.Fatalf("base64 should reject a 15 MiB file, but returned encoded data")
+ }
+ if r.Error == "" {
+ t.Fatalf("base64 should return an error for a 15 MiB file")
+ }
+}
+
+func TestDiff_RejectsHugeFile(t *testing.T) {
+ dir := t.TempDir()
+ pathA := filepath.Join(dir, "a.txt")
+ pathB := filepath.Join(dir, "b.txt")
+ os.WriteFile(pathA, make([]byte, 15*1024*1024), 0644)
+ os.WriteFile(pathB, []byte("small"), 0644)
+
+ tool := &diffTool{dangerousConfig: danger.DangerousConfig{}}
+ result := callJSON(t, tool, fmt.Sprintf(`{"path_a":%q,"path_b":%q}`, pathA, pathB))
+ var r struct {
+ Error string `json:"error,omitempty"`
+ }
+ mustUnmarshal(t, result, &r)
+ if r.Error == "" {
+ t.Fatalf("diff should return an error for a 15 MiB file")
+ }
+}
+
+func TestJsonQuery_RejectsHugeFile(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "big.json")
+ // Build a ~15 MB JSON object without newlines so it looks like one value.
+ big := `{"x":"` + strings.Repeat("a", 15*1024*1024) + `"}`
+ os.WriteFile(path, []byte(big), 0644)
+
+ tool := &jsonQueryTool{dangerousConfig: danger.DangerousConfig{}}
+ result := callJSON(t, tool, fmt.Sprintf(`{"path":%q,"query":"x"}`, path))
+ var r struct {
+ Error string `json:"error,omitempty"`
+ }
+ mustUnmarshal(t, result, &r)
+ if r.Error == "" {
+ t.Fatalf("json_query should return an error for a 15 MiB file")
+ }
+}
+
+// ── 4. serve state-changing endpoints must require a local origin ────────
+
+func TestServe_CSRF_RejectForeignOrigin(t *testing.T) {
+ base := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ })
+ handler := requireLocalOrigin(base)
+
+ req := httptest.NewRequest(http.MethodPost, "/api/cancel", nil)
+ req.Header.Set("Origin", "http://evil.example.com")
+ rr := httptest.NewRecorder()
+ handler.ServeHTTP(rr, req)
+ if rr.Code != http.StatusForbidden {
+ t.Fatalf("foreign origin POST should be rejected (403), got %d", rr.Code)
+ }
+}
+
+func TestServe_CSRF_AllowsEmptyOrigin(t *testing.T) {
+ base := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNoContent)
+ })
+ handler := requireLocalOrigin(base)
+
+ req := httptest.NewRequest(http.MethodPost, "/api/cancel", nil)
+ rr := httptest.NewRecorder()
+ handler.ServeHTTP(rr, req)
+ if rr.Code != http.StatusNoContent {
+ t.Fatalf("empty-origin POST should be allowed, got %d", rr.Code)
+ }
+}
+
+func TestServe_CSRF_AllowsLocalhostOrigin(t *testing.T) {
+ base := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNoContent)
+ })
+ handler := requireLocalOrigin(base)
+
+ for _, origin := range []string{"http://localhost:8080", "http://127.0.0.1:8080"} {
+ req := httptest.NewRequest(http.MethodPost, "/api/cancel", nil)
+ req.Header.Set("Origin", origin)
+ rr := httptest.NewRecorder()
+ handler.ServeHTTP(rr, req)
+ if rr.Code != http.StatusNoContent {
+ t.Fatalf("localhost origin %q should be allowed, got %d", origin, rr.Code)
+ }
+ }
+}
+
+func TestServe_StaticSecurityHeaders(t *testing.T) {
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ rr := httptest.NewRecorder()
+ handleStatic().ServeHTTP(rr, req)
+
+ if rr.Code != http.StatusOK {
+ t.Fatalf("static handler returned %d", rr.Code)
+ }
+ if rr.Header().Get("X-Frame-Options") == "" {
+ t.Error("static handler missing X-Frame-Options")
+ }
+ if rr.Header().Get("Content-Security-Policy") == "" {
+ t.Error("static handler missing Content-Security-Policy")
+ }
+}
+
+// ── 5. file-reading perf tools must wrap content as untrusted ────────────
+
+func TestHeadTail_WrapsContent(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "test.txt")
+ os.WriteFile(path, []byte("hello world\n"), 0644)
+
+ tool := &headTailTool{dangerousConfig: danger.DangerousConfig{}}
+ result := callJSON(t, tool, fmt.Sprintf(`{"files":[{"path":%q}],"lines":10}`, path))
+ var r struct {
+ Results []struct {
+ Lines []string `json:"lines"`
+ } `json:"results"`
+ }
+ mustUnmarshal(t, result, &r)
+ if len(r.Results) == 0 || len(r.Results[0].Lines) == 0 {
+ t.Fatal("expected at least one line")
+ }
+ if !strings.HasPrefix(r.Results[0].Lines[0], " 1024*1024+200 {
+ t.Fatalf("shell returned %d bytes, expected cap near 1 MiB", len(body))
+ }
+}
+
+func TestParallelShell_CapsOutputSize(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "huge.txt")
+ os.WriteFile(path, []byte(strings.Repeat("x", 15*1024*1024)), 0644)
+
+ tool := ¶llelShellTool{}
+ result := callJSON(t, tool, fmt.Sprintf(`{"commands":[{"command":"cat %s"}]}`, path))
+ var r struct {
+ Results []struct {
+ Stdout string `json:"stdout"`
+ Stderr string `json:"stderr"`
+ Error string `json:"error,omitempty"`
+ } `json:"results"`
+ }
+ mustUnmarshal(t, result, &r)
+ if len(r.Results) != 1 {
+ t.Fatalf("expected 1 result, got %d", len(r.Results))
+ }
+ out := r.Results[0].Stdout + r.Results[0].Stderr
+ if len(out) > 1024*1024+200 {
+ t.Fatalf("parallel_shell returned %d bytes, expected cap near 1 MiB", len(out))
+ }
+}
+
+// ── 7. Browser must enforce an HTTP request timeout ──────────────────────
+
+func TestBrowser_NavigateTimeout(t *testing.T) {
+ orig := browserRequestTimeout
+ browserRequestTimeout = 100 * time.Millisecond
+ defer func() { browserRequestTimeout = orig }()
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ time.Sleep(300 * time.Millisecond)
+ fmt.Fprint(w, "page")
+ }))
+ defer srv.Close()
+
+ tool := newBrowserTool(danger.DangerousConfig{})
+ result := callJSON(t, tool, fmt.Sprintf(`{"action":"navigate","url":%q}`, srv.URL))
+ var r struct {
+ Error string `json:"error,omitempty"`
+ }
+ mustUnmarshal(t, result, &r)
+ if r.Error == "" || !strings.Contains(strings.ToLower(r.Error), "timeout") {
+ t.Fatalf("browser should time out on a slow server, got: %q", r.Error)
+ }
+}
+
+// ── 8. batch_patch must reject huge files and wrap diff output ───────────
+
+func TestBatchPatch_RejectsHugeFile(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "huge.txt")
+ os.WriteFile(path, []byte(strings.Repeat("x", 15*1024*1024)), 0644)
+
+ tool := &batchPatchTool{}
+ result := callJSON(t, tool, fmt.Sprintf(`{"patches":[{"path":%q,"old_string":"xxx","new_string":"yyy"}]}`, path))
+ var r struct {
+ Results []struct {
+ Success bool `json:"success"`
+ Error string `json:"error,omitempty"`
+ } `json:"results"`
+ }
+ mustUnmarshal(t, result, &r)
+ if len(r.Results) != 1 {
+ t.Fatalf("expected 1 result, got %d", len(r.Results))
+ }
+ if r.Results[0].Success {
+ t.Fatal("batch_patch should reject a 15 MiB file")
+ }
+ if r.Results[0].Error == "" {
+ t.Fatal("batch_patch should return an error for a 15 MiB file")
+ }
+}
+
+func TestBatchPatch_WrapsDiff(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "test.txt")
+ os.WriteFile(path, []byte("hello world\n"), 0644)
+
+ tool := &batchPatchTool{}
+ result := callJSON(t, tool, fmt.Sprintf(`{"patches":[{"path":%q,"old_string":"hello","new_string":"goodbye"}]}`, path))
+ var r struct {
+ Results []struct {
+ Diff string `json:"diff"`
+ } `json:"results"`
+ }
+ mustUnmarshal(t, result, &r)
+ if len(r.Results) == 0 || !strings.HasPrefix(r.Results[0].Diff, " 1000 {
+ t.Fatalf("tree did not cap directory width: got %d children", len(r.Tree.Children))
+ }
+}
+
+
+// ── 11. patch must reject huge files and preserve original permissions ───
+
+func TestPatch_RejectsHugeFile(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "huge.txt")
+ os.WriteFile(path, []byte(strings.Repeat("x", 15*1024*1024)), 0644)
+
+ tool := &patchTool{}
+ result := callJSON(t, tool, fmt.Sprintf(`{"path":%q,"old_string":"xxx","new_string":"yyy"}`, path))
+ var r struct {
+ Success bool `json:"success"`
+ Error string `json:"error,omitempty"`
+ }
+ mustUnmarshal(t, result, &r)
+ if r.Success {
+ t.Fatal("patch should reject a 15 MiB file")
+ }
+ if !strings.Contains(r.Error, "too large") {
+ t.Fatalf("patch should reject huge file with a size error, got: %q", r.Error)
+ }
+}
+
+func TestPatch_PreservesFileMode(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "script.sh")
+ os.WriteFile(path, []byte("#!/bin/sh\necho hello\n"), 0755)
+
+ tool := &patchTool{}
+ result := callJSON(t, tool, fmt.Sprintf(`{"path":%q,"old_string":"hello","new_string":"world"}`, path))
+ var r struct {
+ Success bool `json:"success"`
+ }
+ mustUnmarshal(t, result, &r)
+ if !r.Success {
+ t.Fatal("patch failed")
+ }
+
+ info, err := os.Stat(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if info.Mode().Perm() != 0755 {
+ t.Fatalf("patch changed mode from 0755 to %04o", info.Mode().Perm())
+ }
+}
+
+// ── 12. glob must cap match count and wrap paths as untrusted ────────────
+
+func TestGlob_CapsMatchCount(t *testing.T) {
+ dir := t.TempDir()
+ for i := 0; i < 1500; i++ {
+ os.WriteFile(filepath.Join(dir, fmt.Sprintf("file%d.txt", i)), []byte("x"), 0644)
+ }
+
+ tool := &globTool{dangerousConfig: danger.DangerousConfig{}}
+ result := callJSON(t, tool, fmt.Sprintf(`{"pattern":"*","path":%q,"limit":10000}`, dir))
+ var r struct {
+ Matches []struct {
+ Path string `json:"path"`
+ } `json:"matches"`
+ }
+ mustUnmarshal(t, result, &r)
+ if len(r.Matches) > 1000 {
+ t.Fatalf("glob did not cap match count: got %d", len(r.Matches))
+ }
+ if len(r.Matches) == 0 {
+ t.Fatal("expected at least one match")
+ }
+ if !strings.HasPrefix(r.Matches[0].Path, " 100 {
+ t.Fatalf("session_search get did not cap messages: got %d", len(r.SessionMessages))
+ }
+ if len(r.SessionMessages) == 0 {
+ t.Fatal("expected at least one message")
+ }
+ if !strings.HasPrefix(r.SessionMessages[0].Content, " 1024*1024+500 {
+ t.Fatalf("delegate_tasks summary returned %d bytes, expected cap near 1 MiB", len(result))
+ }
+}
+
+// ── 20. patch / batch_patch must cap ReplaceAll expansion ────────────────
+
+func TestPatch_RejectsOutputExpansion(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "data.txt")
+ // 2,000 'a' chars. Replacing each with 10,000 'x' => ~20M chars.
+ os.WriteFile(path, []byte(strings.Repeat("a", 2000)), 0644)
+
+ tool := &patchTool{}
+ result := callJSON(t, tool, fmt.Sprintf(`{"path":%q,"old_string":"a","new_string":%q,"replace_all":true}`, path, strings.Repeat("x", 10000)))
+ var r struct {
+ Success bool `json:"success"`
+ Error string `json:"error,omitempty"`
+ }
+ mustUnmarshal(t, result, &r)
+ if r.Success {
+ t.Fatal("patch should reject a ReplaceAll that explodes output size")
+ }
+ if !strings.Contains(r.Error, "too large") {
+ t.Fatalf("expected size error, got: %q", r.Error)
+ }
+}
+
+func TestBatchPatch_RejectsOutputExpansion(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "data.txt")
+ os.WriteFile(path, []byte(strings.Repeat("a", 2000)), 0644)
+
+ tool := &batchPatchTool{}
+ result := callJSON(t, tool, fmt.Sprintf(`{"patches":[{"path":%q,"old_string":"a","new_string":%q,"replace_all":true}]}`, path, strings.Repeat("x", 10000)))
+ var r struct {
+ Results []struct {
+ Success bool `json:"success"`
+ Error string `json:"error,omitempty"`
+ } `json:"results"`
+ }
+ mustUnmarshal(t, result, &r)
+ if len(r.Results) != 1 || r.Results[0].Success {
+ t.Fatal("batch_patch should reject a ReplaceAll that explodes output size")
+ }
+ if !strings.Contains(r.Results[0].Error, "too large") {
+ t.Fatalf("expected size error, got: %q", r.Results[0].Error)
+ }
+}
+
+// ── 21. write_file must cap content size to prevent DoS / disk exhaustion ─
+
+func TestWriteFile_CapsContentSize(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "out.txt")
+ huge := strings.Repeat("x", maxWriteFileContentBytes+1)
+
+ tool := &writeFileTool{}
+ result := callJSON(t, tool, fmt.Sprintf(`{"path":%q,"content":%q}`, path, huge))
+ var r struct {
+ Success bool `json:"success"`
+ Error string `json:"error,omitempty"`
+ }
+ mustUnmarshal(t, result, &r)
+ if r.Success {
+ t.Fatal("write_file should reject content above maxWriteFileContentBytes")
+ }
+ if !strings.Contains(r.Error, "too large") {
+ t.Fatalf("expected size error, got: %q", r.Error)
+ }
+}
+
+// ── 22. file_info must respect restrictToCWD and wrap its output ──────────
+
+func TestFileInfo_RestrictToCWD(t *testing.T) {
+ tool := &fileInfoTool{restrictToCWD: true}
+ result := callJSON(t, tool, `{"path":"/etc/passwd"}`)
+ var r struct {
+ Error string `json:"error,omitempty"`
+ }
+ mustUnmarshal(t, result, &r)
+ if r.Error == "" {
+ t.Fatal("file_info with restrictToCWD=true should reject paths outside CWD")
+ }
+}
+
+func TestFileInfo_WrapsPath(t *testing.T) {
+ t.Chdir(t.TempDir())
+ os.WriteFile("target.txt", []byte("hello"), 0644)
+
+ tool := &fileInfoTool{restrictToCWD: true}
+ result := callJSON(t, tool, `{"path":"target.txt"}`)
+ var r struct {
+ Path string `json:"path"`
+ Error string `json:"error,omitempty"`
+ }
+ mustUnmarshal(t, result, &r)
+ if r.Error != "" {
+ t.Fatalf("unexpected error: %s", r.Error)
+ }
+ if !strings.HasPrefix(r.Path, " 0")
+ }
+ if !strings.HasPrefix(r.Encoded, "Evil Titleclick me