Skip to content

Commit 22e4084

Browse files
committed
Reject dangling and non-absolute paths in AttachedFiles
Two correctness fixes uncovered while reviewing the AttachedFiles plumbing: 1. App.Run was recording att.FilePath on the session before processFileAttachment had a chance to verify the file existed or was a regular file. A user typing @some/missing/path or @some/directory in the editor would silently propagate that broken reference to every sub-agent created in the rest of the session. processFileAttachment now returns a bool meaning "the path resolves to a real, regular file we attempted to surface" and App.Run only records on true. Content-handling failures (too large to inline, unsupported MIME, transient read errors) still record the path because a sub-agent may have larger limits or different tools than the parent. 2. AddAttachedFile is documented as taking an absolute path but accepted any string. A buggy caller could leak a relative path into the sub-agent's system prompt, where it would be ambiguous. Add a filepath.IsAbs guard that silently drops non-absolute paths (with a debug log) and update the doc comment. Also clarify the Session.AttachedFiles field comment to mention all three entry points (editor @-mention, /attach directive, --attach CLI flag) and extend TestAddAttachedFile / TestWithAttachedFiles to cover the new validation. Assisted-By: docker-agent
1 parent 86641e4 commit 22e4084

3 files changed

Lines changed: 48 additions & 19 deletions

File tree

pkg/app/app.go

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -302,12 +302,14 @@ func (a *App) Run(ctx context.Context, cancel context.CancelFunc, message string
302302
for _, att := range attachments {
303303
switch {
304304
case att.FilePath != "":
305-
// The editor resolves @-mentions to absolute paths before this
306-
// point; remember them so sub-agents created via task transfer
307-
// inherit the same file references.
308-
a.session.AddAttachedFile(att.FilePath)
309305
// File-reference attachment: read and classify from disk.
310-
a.processFileAttachment(ctx, att, &textBuilder, &binaryParts)
306+
// Only remember the path on the session when the file actually
307+
// exists as a regular file — we don't want sub-agents to inherit
308+
// dangling references to directories or missing paths. The editor
309+
// resolves @-mentions to absolute paths before this point.
310+
if a.processFileAttachment(ctx, att, &textBuilder, &binaryParts) {
311+
a.session.AddAttachedFile(att.FilePath)
312+
}
311313
case att.Content != "":
312314
// Inline content attachment (e.g. pasted text).
313315
a.processInlineAttachment(att, &textBuilder)
@@ -348,7 +350,14 @@ func (a *App) Run(ctx context.Context, cancel context.CancelFunc, message string
348350

349351
// processFileAttachment reads a file from disk, classifies it, and either
350352
// appends its text content to textBuilder or adds a binary part to binaryParts.
351-
func (a *App) processFileAttachment(ctx context.Context, att messages.Attachment, textBuilder *strings.Builder, binaryParts *[]chat.MessagePart) {
353+
// Returns true when the path resolved to a real, regular file that we attempted
354+
// to surface to the model — even if the content itself was rejected (too
355+
// large, unsupported MIME, transient read error, etc.). The boolean is meant
356+
// for callers that want to record the path on the session for later reuse by
357+
// sub-agents; we don't want those references to point at directories or
358+
// missing files, but we do want them to cover "the agent has bigger tools
359+
// than us" cases.
360+
func (a *App) processFileAttachment(ctx context.Context, att messages.Attachment, textBuilder *strings.Builder, binaryParts *[]chat.MessagePart) bool {
352361
absPath := att.FilePath
353362

354363
fi, err := os.Stat(absPath)
@@ -364,20 +373,20 @@ func (a *App) processFileAttachment(ctx context.Context, att messages.Attachment
364373
}
365374
slog.Warn("skipping attachment", "path", absPath, "reason", reason)
366375
a.sendEvent(ctx, runtime.Warning(fmt.Sprintf("Skipped attachment %s: %s", att.Name, reason), ""))
367-
return
376+
return false
368377
}
369378

370379
if !fi.Mode().IsRegular() {
371380
slog.Warn("skipping attachment: not a regular file", "path", absPath, "mode", fi.Mode().String())
372381
a.sendEvent(ctx, runtime.Warning(fmt.Sprintf("Skipped attachment %s: not a regular file", att.Name), ""))
373-
return
382+
return false
374383
}
375384

376385
const maxAttachmentSize = 100 * 1024 * 1024 // 100MB
377386
if fi.Size() > maxAttachmentSize {
378387
slog.Warn("skipping attachment: file too large", "path", absPath, "size", fi.Size(), "max", maxAttachmentSize)
379388
a.sendEvent(ctx, runtime.Warning(fmt.Sprintf("Skipped attachment %s: file too large (max 100MB)", att.Name), ""))
380-
return
389+
return true
381390
}
382391

383392
mimeType := chat.DetectMimeType(absPath)
@@ -387,13 +396,13 @@ func (a *App) processFileAttachment(ctx context.Context, att messages.Attachment
387396
if fi.Size() > chat.MaxInlineFileSize {
388397
slog.Warn("skipping attachment: text file too large to inline", "path", absPath, "size", fi.Size(), "max", chat.MaxInlineFileSize)
389398
a.sendEvent(ctx, runtime.Warning(fmt.Sprintf("Skipped attachment %s: text file too large to inline (max 5MB)", att.Name), ""))
390-
return
399+
return true
391400
}
392401
content, err := chat.ReadFileForInline(absPath)
393402
if err != nil {
394403
slog.Warn("skipping attachment: failed to read file", "path", absPath, "error", err)
395404
a.sendEvent(ctx, runtime.Warning(fmt.Sprintf("Skipped attachment %s: failed to read file", att.Name), ""))
396-
return
405+
return true
397406
}
398407
textBuilder.WriteString("\n\n")
399408
textBuilder.WriteString(content)
@@ -406,14 +415,14 @@ func (a *App) processFileAttachment(ctx context.Context, att messages.Attachment
406415
if readErr != nil {
407416
slog.Warn("skipping attachment: failed to read image", "path", absPath, "error", readErr)
408417
a.sendEvent(ctx, runtime.Warning(fmt.Sprintf("Skipped attachment %s: failed to read image", att.Name), ""))
409-
return
418+
return true
410419
}
411420
resized, resizeErr := chat.ResizeImage(imgData, mimeType)
412421
if resizeErr != nil {
413422
// Don't bypass security checks - reject the file if resize failed
414423
slog.Warn("skipping attachment: image resize failed", "path", absPath, "error", resizeErr)
415424
a.sendEvent(ctx, runtime.Warning(fmt.Sprintf("Skipped attachment %s: %s", att.Name, resizeErr), ""))
416-
return
425+
return true
417426
}
418427
dataURL := fmt.Sprintf("data:%s;base64,%s", resized.MimeType, base64.StdEncoding.EncodeToString(resized.Data))
419428
*binaryParts = append(*binaryParts, chat.MessagePart{
@@ -441,6 +450,8 @@ func (a *App) processFileAttachment(ctx context.Context, att messages.Attachment
441450
slog.Warn("skipping attachment: unsupported file type", "path", absPath, "mime_type", mimeType)
442451
a.sendEvent(ctx, runtime.Warning(fmt.Sprintf("Skipped attachment %s: unsupported file type", att.Name), ""))
443452
}
453+
454+
return true
444455
}
445456

446457
// sendEvent sends an event to the TUI, respecting context cancellation to

pkg/session/session.go

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bytes"
55
"encoding/json"
66
"log/slog"
7+
"path/filepath"
78
"slices"
89
"strings"
910
"sync"
@@ -137,10 +138,11 @@ type Session struct {
137138
CustomModelsUsed []string `json:"custom_models_used,omitempty"`
138139

139140
// AttachedFiles records absolute paths of files the user attached to this
140-
// session via @-mentions in the editor or /attach directives. Sub-sessions
141-
// created via task transfer inherit this list so that delegated agents can
142-
// reference the same files without having to scan the workspace or guess
143-
// from a bare filename. Paths are deduplicated and order-preserved.
141+
// session via the editor's @-mentions, the in-message /attach directive, or
142+
// the CLI --attach flag. Sub-sessions created via task transfer inherit
143+
// this list so that delegated agents can reference the same files without
144+
// having to scan the workspace or guess from a bare filename. Paths are
145+
// deduplicated and order-preserved.
144146
AttachedFiles []string `json:"attached_files,omitempty"`
145147

146148
// ExcludedTools lists tool names that should be filtered out of the agent's
@@ -489,14 +491,22 @@ func (s *Session) AddMessageUsageRecord(agentName, model string, cost float64, u
489491
}
490492

491493
// AddAttachedFile records absPath as a file the user attached to this session.
492-
// Empty paths are ignored; duplicates (already in AttachedFiles) are dropped.
494+
// The path must be absolute; relative paths are silently dropped (with a debug
495+
// log) since they would be ambiguous to sub-agents started in a fresh working
496+
// directory. Empty paths and duplicates already present in AttachedFiles are
497+
// also dropped.
498+
//
493499
// The recorded paths are propagated to sub-sessions created via task transfer
494500
// so that delegated agents can read the same files without having to scan the
495501
// workspace or guess from a bare filename.
496502
func (s *Session) AddAttachedFile(absPath string) {
497503
if absPath == "" {
498504
return
499505
}
506+
if !filepath.IsAbs(absPath) {
507+
slog.Debug("ignoring non-absolute attached file path", "session_id", s.ID, "path", absPath)
508+
return
509+
}
500510
s.mu.Lock()
501511
defer s.mu.Unlock()
502512
if slices.Contains(s.AttachedFiles, absPath) {

pkg/session/session_options_test.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,14 @@ func TestAddAttachedFile(t *testing.T) {
8383
assert.Empty(t, s.AttachedFilesSnapshot())
8484
})
8585

86+
t.Run("ignores non-absolute paths", func(t *testing.T) {
87+
s := New()
88+
s.AddAttachedFile("foo.go")
89+
s.AddAttachedFile("./bar.go")
90+
s.AddAttachedFile("../baz.go")
91+
assert.Empty(t, s.AttachedFilesSnapshot())
92+
})
93+
8694
t.Run("snapshot is independent of session storage", func(t *testing.T) {
8795
s := New()
8896
s.AddAttachedFile("/abs/foo.go")
@@ -93,6 +101,6 @@ func TestAddAttachedFile(t *testing.T) {
93101
}
94102

95103
func TestWithAttachedFiles(t *testing.T) {
96-
s := New(WithAttachedFiles([]string{"/abs/foo.go", "", "/abs/bar.go", "/abs/foo.go"}))
104+
s := New(WithAttachedFiles([]string{"/abs/foo.go", "", "relative/path.go", "/abs/bar.go", "/abs/foo.go"}))
97105
assert.Equal(t, []string{"/abs/foo.go", "/abs/bar.go"}, s.AttachedFilesSnapshot())
98106
}

0 commit comments

Comments
 (0)