Skip to content

Commit 8dbba3d

Browse files
committed
fix: wrap tree paths, cap head_tail/AGENTS/IDENTITY, harden search_files symlinks
1 parent db47d8f commit 8dbba3d

9 files changed

Lines changed: 222 additions & 25 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`, `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.
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`, `tree`, `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.
@@ -115,6 +115,11 @@ Layered prompt-injection / approval-fatigue defenses. Full reference: [docs/SECU
115115
- **@-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.
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.
118+
- **Tree path wrapping** (`cmd/odek/perf_tools.go`) — the `tree` tool wraps every filesystem-derived path as untrusted content.
119+
- **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.
120+
- **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.
121+
- **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.
122+
- **IDENTITY.md size cap** (`cmd/odek/main.go`) — `~/.odek/IDENTITY.md` is ignored if larger than 256 KiB, falling back to the default identity.
118123
- **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.
119124
- **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.
120125
- **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.

cmd/odek/file_tool.go

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -522,12 +522,18 @@ func (t *searchFilesTool) searchFiles(args searchFilesArgs) (string, error) {
522522
return jsonError(fmt.Sprintf("invalid glob %q: %v", pattern, err))
523523
}
524524
for _, p := range globMatches {
525-
info, err := os.Stat(p)
526-
if err == nil && !info.IsDir() {
527-
matches = append(matches, searchMatch{Path: p})
528-
if len(matches) >= limit {
529-
break
530-
}
525+
// Lstat so symlinks are not followed to their targets for metadata.
526+
info, err := os.Lstat(p)
527+
if err != nil {
528+
continue
529+
}
530+
// Skip directories and symlinks — same policy as the walk branch.
531+
if info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
532+
continue
533+
}
534+
matches = append(matches, searchMatch{Path: wrapUntrusted("search_files:"+p, p)})
535+
if len(matches) >= limit {
536+
break
531537
}
532538
}
533539
} else {
@@ -551,7 +557,7 @@ func (t *searchFilesTool) searchFiles(args searchFilesArgs) (string, error) {
551557
}
552558
match, _ := filepath.Match(pattern, info.Name())
553559
if match {
554-
matches = append(matches, searchMatch{Path: path})
560+
matches = append(matches, searchMatch{Path: wrapUntrusted("search_files:"+path, path)})
555561
if len(matches) >= limit {
556562
return filepath.SkipAll
557563
}
@@ -560,12 +566,13 @@ func (t *searchFilesTool) searchFiles(args searchFilesArgs) (string, error) {
560566
})
561567
}
562568

563-
// Sort by modification time (newest first)
569+
// Sort by modification time (newest first). Use Lstat so symlinks are not
570+
// followed and their own metadata is used for sorting.
564571
sort.Slice(matches, func(i, j int) bool {
565-
fi, _ := os.Stat(matches[i].Path)
566-
fj, _ := os.Stat(matches[j].Path)
572+
fi, _ := os.Lstat(unwrapUntrusted(matches[i].Path))
573+
fj, _ := os.Lstat(unwrapUntrusted(matches[j].Path))
567574
if fi == nil || fj == nil {
568-
return matches[i].Path < matches[j].Path
575+
return unwrapUntrusted(matches[i].Path) < unwrapUntrusted(matches[j].Path)
569576
}
570577
return fi.ModTime().After(fj.ModTime())
571578
})

cmd/odek/file_tool_test.go

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -402,7 +402,7 @@ func TestSearchFiles_FindByName(t *testing.T) {
402402
}
403403

404404
for _, m := range r.Matches {
405-
name := filepath.Base(m.Path)
405+
name := filepath.Base(unwrapUntrusted(m.Path))
406406
if name != "main.go" && name != "main_test.go" {
407407
t.Errorf("unexpected match: %s", name)
408408
}
@@ -939,8 +939,9 @@ func TestSearchFiles_GlobWithPathSeparator(t *testing.T) {
939939
if len(r.Matches) != 1 {
940940
t.Fatalf("expected 1 match for 'subdir/*.txt', got %d", len(r.Matches))
941941
}
942-
if !strings.HasSuffix(r.Matches[0].Path, "subdir/result.txt") && !strings.HasSuffix(r.Matches[0].Path, "subdir\\result.txt") {
943-
t.Errorf("unexpected match path: %s", r.Matches[0].Path)
942+
p := unwrapUntrusted(r.Matches[0].Path)
943+
if !strings.HasSuffix(p, "subdir/result.txt") && !strings.HasSuffix(p, "subdir\\result.txt") {
944+
t.Errorf("unexpected match path: %s", p)
944945
}
945946
}
946947

@@ -1491,8 +1492,8 @@ func TestSearchFiles_FilesTargetWithPathSeparator(t *testing.T) {
14911492
if len(r.Matches) != 1 {
14921493
t.Fatalf("expected 1 match for 'sub/*.txt', got %d", len(r.Matches))
14931494
}
1494-
if !strings.Contains(r.Matches[0].Path, "nested.txt") {
1495-
t.Errorf("expected nested.txt match, got: %s", r.Matches[0].Path)
1495+
if !strings.Contains(unwrapUntrusted(r.Matches[0].Path), "nested.txt") {
1496+
t.Errorf("expected nested.txt match, got: %s", unwrapUntrusted(r.Matches[0].Path))
14961497
}
14971498
}
14981499

@@ -1512,8 +1513,8 @@ func TestSearchFiles_FilesTargetHiddenDirSkipped(t *testing.T) {
15121513
}
15131514
mustUnmarshal(t, result, &r)
15141515
for _, m := range r.Matches {
1515-
if strings.Contains(m.Path, ".hidden") {
1516-
t.Errorf("should not include hidden dir contents: %s", m.Path)
1516+
if strings.Contains(unwrapUntrusted(m.Path), ".hidden") {
1517+
t.Errorf("should not include hidden dir contents: %s", unwrapUntrusted(m.Path))
15171518
}
15181519
}
15191520
if len(r.Matches) != 1 {

cmd/odek/main.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,11 @@ func buildSystemPrompt(resolved config.ResolvedConfig) string {
158158
return base
159159
}
160160

161+
// maxIdentityFileBytes caps the size of ~/.odek/IDENTITY.md that will be
162+
// loaded into the system prompt. A tampered or corrupted identity file could
163+
// otherwise OOM the process or stuff every prompt.
164+
const maxIdentityFileBytes = 256 * 1024 // 256 KiB
165+
161166
// loadIdentityFile reads ~/.odek/IDENTITY.md and returns its content.
162167
// Returns defaultSystem if the file does not exist or cannot be read.
163168
func loadIdentityFile() string {
@@ -166,6 +171,14 @@ func loadIdentityFile() string {
166171
return defaultSystem
167172
}
168173
path := filepath.Join(home, ".odek", "IDENTITY.md")
174+
info, err := os.Stat(path)
175+
if err != nil {
176+
return defaultSystem
177+
}
178+
if info.Size() > maxIdentityFileBytes {
179+
fmt.Fprintf(os.Stderr, "odek: warning: IDENTITY.md is too large (%d bytes, max %d) — using default identity\n", info.Size(), maxIdentityFileBytes)
180+
return defaultSystem
181+
}
169182
data, err := os.ReadFile(path)
170183
if err != nil {
171184
return defaultSystem

cmd/odek/next_security_vulnerabilities_test.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -900,3 +900,117 @@ func TestSkillLoader_CapsFileSize(t *testing.T) {
900900
t.Fatalf("skill loader should reject a huge SKILL.md, got %d skills", len(result.AutoLoad)+len(result.Lazy))
901901
}
902902
}
903+
904+
// ── 25. tree must wrap filesystem-derived paths as untrusted ──────────────
905+
906+
func TestTree_WrapsPaths(t *testing.T) {
907+
dir := t.TempDir()
908+
os.WriteFile(filepath.Join(dir, "note.txt"), []byte("hello"), 0644)
909+
910+
tool := &treeTool{dangerousConfig: danger.DangerousConfig{}}
911+
result := callJSON(t, tool, fmt.Sprintf(`{"path":%q,"max_depth":1}`, dir))
912+
var r struct {
913+
Tree struct {
914+
Path string `json:"path"`
915+
Children []struct {
916+
Path string `json:"path"`
917+
} `json:"children"`
918+
} `json:"tree"`
919+
Error string `json:"error,omitempty"`
920+
}
921+
mustUnmarshal(t, result, &r)
922+
if r.Error != "" {
923+
t.Fatalf("tree error: %s", r.Error)
924+
}
925+
if !strings.HasPrefix(r.Tree.Path, "<untrusted_content_") {
926+
t.Fatalf("tree root path should be wrapped, got: %q", r.Tree.Path)
927+
}
928+
if len(r.Tree.Children) == 0 {
929+
t.Fatal("expected at least one child")
930+
}
931+
if !strings.HasPrefix(r.Tree.Children[0].Path, "<untrusted_content_") {
932+
t.Fatalf("tree child path should be wrapped, got: %q", r.Tree.Children[0].Path)
933+
}
934+
}
935+
936+
// ── 26. head_tail must cap total output size ──────────────────────────────
937+
938+
func TestHeadTail_CapsOutputSize(t *testing.T) {
939+
dir := t.TempDir()
940+
path := filepath.Join(dir, "biglines.txt")
941+
// 10 lines of 200 KB => 2 MB of content, exceeding the 1 MiB cap.
942+
var lines []string
943+
for i := 0; i < 10; i++ {
944+
lines = append(lines, fmt.Sprintf("line-%d-%s", i, strings.Repeat("x", 200*1024)))
945+
}
946+
os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0644)
947+
948+
tool := &headTailTool{dangerousConfig: danger.DangerousConfig{}}
949+
result := callJSON(t, tool, fmt.Sprintf(`{"files":[{"path":%q}],"lines":100}`, path))
950+
var r struct {
951+
Results []struct {
952+
Lines []string `json:"lines"`
953+
Total int `json:"total"`
954+
} `json:"results"`
955+
}
956+
mustUnmarshal(t, result, &r)
957+
if len(r.Results) != 1 {
958+
t.Fatalf("expected 1 result, got %d", len(r.Results))
959+
}
960+
961+
total := 0
962+
for _, line := range r.Results[0].Lines {
963+
total += len(unwrapUntrusted(line))
964+
}
965+
if total > maxHeadTailTotalBytes+200 {
966+
t.Fatalf("head_tail returned %d bytes of content, expected cap near %d", total, maxHeadTailTotalBytes)
967+
}
968+
}
969+
970+
// ── 27. search_files target=files must not follow symlinks for metadata ───
971+
972+
func TestSearchFiles_TargetFiles_NoSymlinkFollow(t *testing.T) {
973+
dir := t.TempDir()
974+
sub := filepath.Join(dir, "sub")
975+
os.MkdirAll(sub, 0755)
976+
// Regular file
977+
os.WriteFile(filepath.Join(sub, "real.txt"), []byte("hello"), 0644)
978+
// Symlink to a non-existent target — old os.Stat would skip it; Lstat lets us detect and skip it ourselves.
979+
os.Symlink("/nonexistent/odek-test", filepath.Join(sub, "link.txt"))
980+
981+
tool := &searchFilesTool{dangerousConfig: danger.DangerousConfig{}}
982+
// Pattern with a separator forces the filepath.Glob branch.
983+
result := callJSON(t, tool, fmt.Sprintf(`{"pattern":"**/*.txt","path":%q,"target":"files"}`, dir))
984+
var r struct {
985+
Matches []struct {
986+
Path string `json:"path"`
987+
} `json:"matches"`
988+
}
989+
mustUnmarshal(t, result, &r)
990+
if len(r.Matches) != 1 {
991+
t.Fatalf("expected 1 regular file match, got %d", len(r.Matches))
992+
}
993+
if !strings.Contains(r.Matches[0].Path, "real.txt") {
994+
t.Fatalf("expected real.txt match, got: %q", r.Matches[0].Path)
995+
}
996+
if !strings.HasPrefix(r.Matches[0].Path, "<untrusted_content_") {
997+
t.Fatalf("search_files file path should be wrapped, got: %q", r.Matches[0].Path)
998+
}
999+
}
1000+
1001+
// ── 28. IDENTITY.md must be size-capped ───────────────────────────────────
1002+
1003+
func TestIdentityFile_CapsSize(t *testing.T) {
1004+
t.Setenv("HOME", t.TempDir())
1005+
home, _ := os.UserHomeDir()
1006+
identityPath := filepath.Join(home, ".odek", "IDENTITY.md")
1007+
if err := os.MkdirAll(filepath.Dir(identityPath), 0755); err != nil {
1008+
t.Fatal(err)
1009+
}
1010+
os.WriteFile(identityPath, []byte(strings.Repeat("x", maxIdentityFileBytes+1)), 0644)
1011+
1012+
got := loadIdentityFile()
1013+
if got != defaultSystem {
1014+
t.Fatalf("loadIdentityFile should fall back to defaultSystem for a huge IDENTITY.md, got length %d", len(got))
1015+
}
1016+
}

cmd/odek/perf_tools.go

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1513,7 +1513,7 @@ func (t *treeTool) Call(argsJSON string) (result string, err error) {
15131513
func buildTree(root, path string, depth, maxDepth int, includeHidden bool) (treeEntry, error) {
15141514
info, err := os.Lstat(path)
15151515
if err != nil {
1516-
return treeEntry{Path: path, ErrMsg: err.Error()}, nil
1516+
return treeEntry{Path: wrapUntrusted("tree:"+root, path), ErrMsg: err.Error()}, nil
15171517
}
15181518

15191519
entry := treeEntry{
@@ -1526,6 +1526,10 @@ func buildTree(root, path string, depth, maxDepth int, includeHidden bool) (tree
15261526
entry.Path = path
15271527
}
15281528

1529+
// Tree paths come from the filesystem trust boundary, so mark them as
1530+
// untrusted before returning them to the model.
1531+
entry.Path = wrapUntrusted("tree:"+root, entry.Path)
1532+
15291533
if !info.IsDir() || depth >= maxDepth {
15301534
if !info.IsDir() {
15311535
entry.FileCount = 1
@@ -1880,6 +1884,11 @@ func (t *sortTool) Call(argsJSON string) (result string, err error) {
18801884
// 12. head_tail — Quick file preview (first/last N lines)
18811885
// ═════════════════════════════════════════════════════════════════════════
18821886

1887+
// maxHeadTailTotalBytes caps the total content returned by head_tail across
1888+
// all requested files. Without this, 10 files × 100 lines × 1 MiB lines can
1889+
// allocate roughly 1 GB in a single tool call.
1890+
const maxHeadTailTotalBytes = maxReadBytes // 1 MiB
1891+
18831892
type headTailTool struct {
18841893
dangerousConfig danger.DangerousConfig
18851894
}
@@ -1988,14 +1997,19 @@ func (t *headTailTool) readPreview(path string, n int, mode string) (result head
19881997
func (t *headTailTool) readHead(f *os.File, path string, n int) headTailFileResult {
19891998
scanner := bufio.NewScanner(f)
19901999
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
1991-
var lines []string
2000+
var rawLines []string
19922001
total := 0
19932002
for scanner.Scan() {
19942003
total++
1995-
if len(lines) < n {
1996-
lines = append(lines, wrapUntrusted(path, scanner.Text()))
2004+
if len(rawLines) < n {
2005+
rawLines = append(rawLines, scanner.Text())
19972006
}
19982007
}
2008+
rawLines = truncateHeadTailLines(rawLines)
2009+
lines := make([]string, len(rawLines))
2010+
for i, l := range rawLines {
2011+
lines[i] = wrapUntrusted(path, l)
2012+
}
19992013
return headTailFileResult{Path: path, Lines: lines, Count: len(lines), Total: total}
20002014
}
20012015

@@ -2012,17 +2026,39 @@ func (t *headTailTool) readTail(f *os.File, path string, n int) headTailFileResu
20122026
total++
20132027
}
20142028
// Extract in correct order
2015-
var lines []string
2029+
var rawLines []string
20162030
start := 0
20172031
if written >= n {
20182032
start = written % n
20192033
}
20202034
for i := 0; i < n && i < written; i++ {
2021-
lines = append(lines, wrapUntrusted(path, buf[(start+i)%n]))
2035+
rawLines = append(rawLines, buf[(start+i)%n])
2036+
}
2037+
rawLines = truncateHeadTailLines(rawLines)
2038+
lines := make([]string, len(rawLines))
2039+
for i, l := range rawLines {
2040+
lines[i] = wrapUntrusted(path, l)
20222041
}
20232042
return headTailFileResult{Path: path, Lines: lines, Count: len(lines), Total: total}
20242043
}
20252044

2045+
// truncateHeadTailLines truncates a slice of raw lines so the total byte
2046+
// count stays within maxHeadTailTotalBytes. It preserves leading lines and
2047+
// appends a marker when truncation occurs.
2048+
func truncateHeadTailLines(lines []string) []string {
2049+
total := 0
2050+
for i, l := range lines {
2051+
if total+len(l) > maxHeadTailTotalBytes {
2052+
if i == 0 {
2053+
return []string{"... [truncated]"}
2054+
}
2055+
return append(lines[:i], "... [truncated]")
2056+
}
2057+
total += len(l)
2058+
}
2059+
return lines
2060+
}
2061+
20262062
// ═════════════════════════════════════════════════════════════════════════
20272063
// 13. base64 — Encode/decode base64
20282064
// ═════════════════════════════════════════════════════════════════════════

docs/SECURITY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ Tools that wrap:
6262
| `web_search` | `web_search:<query>` (results + answers from SearXNG) |
6363
| `session_search` | `session_search` (whole result — past sessions may be tainted) |
6464
| `file_info` | `file_info:<path>` (metadata about an external file) |
65+
| `tree` | `tree:<root>` (directory/file names from the filesystem) |
6566
| any MCP tool | `mcp:<server>:<tool>` |
6667

6768
`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).

odek.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,11 @@ func ProfileLabel(model string) string {
298298
// that odek automatically loads from the working directory.
299299
const ProjectFileName = "AGENTS.md"
300300

301+
// maxProjectFileBytes caps the size of AGENTS.md that will be loaded into the
302+
// system prompt. A maliciously huge project file could otherwise OOM the
303+
// process at startup or bloat every prompt.
304+
const maxProjectFileBytes = 256 * 1024 // 256 KiB
305+
301306
// LoadProjectFile reads ProjectFileName from the current working directory.
302307
// Returns the file content (trimmed) if it exists and is readable.
303308
// Returns empty string if the file doesn't exist or can't be read.
@@ -315,6 +320,10 @@ func LoadProjectFile() string {
315320
fmt.Fprintf(os.Stderr, "odek: warning: %s is a symlink — refusing to follow for security\n", ProjectFileName)
316321
return ""
317322
}
323+
if info.Size() > maxProjectFileBytes {
324+
fmt.Fprintf(os.Stderr, "odek: warning: %s is too large (%d bytes, max %d) — ignoring\n", ProjectFileName, info.Size(), maxProjectFileBytes)
325+
return ""
326+
}
318327
data, err := os.ReadFile(ProjectFileName)
319328
if err != nil {
320329
return ""

0 commit comments

Comments
 (0)