1e65f4b6e5
executus CI / test (pull_request) Successful in 48s
The full swarm (5-6 models) flagged that stageInputFiles passed the untrusted attachment filename straight to StageInputFile and inlined it into the [ATTACHED FILES]/`/workspace/<name>` descriptor with no sanitization — a path the byte-cap already treats as a trust boundary. A name like ../../etc/passwd or an absolute/drive path could escape the host store or the sandbox workspace, and newlines in the name/mime could inject text into the prompt block. - sanitizeName: strips control chars/newlines, then reduces to a base name (path.Base after backslash-normalization) so ../, nested dirs, and absolute / drive paths all collapse to their last element; "attachment" fallback for empty/"."/"..". Applied BEFORE staging AND inlining. - sanitizeField: strips control chars from MimeType (also inlined verbatim). - maxInputFiles (32) count cap — defense-in-depth vs a flood of tiny files, independent of the per-file byte cap. Tests: sanitizeName table (traversal/absolute/backslash/control/fallback, + no-separator invariant); traversal staged+described under the base name only; oversize skip; count-cap truncation. Full suite green (-race). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
189 lines
6.8 KiB
Go
189 lines
6.8 KiB
Go
package run
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
|
|
|
"gitea.stevedudenhoeffer.com/steve/executus/tool"
|
|
)
|
|
|
|
// stagerFunc is a test InputFileStager: it records each staged file and returns
|
|
// a deterministic file_id ("file_<name>"), or an error if err is set.
|
|
type stagerFunc struct {
|
|
staged []stagedRec
|
|
err error
|
|
}
|
|
|
|
type stagedRec struct {
|
|
runID, agentID, name, mime string
|
|
size int
|
|
}
|
|
|
|
func (s *stagerFunc) StageInputFile(_ context.Context, runID, agentID, name, mime string, content []byte) (string, error) {
|
|
if s.err != nil {
|
|
return "", s.err
|
|
}
|
|
s.staged = append(s.staged, stagedRec{runID, agentID, name, mime, len(content)})
|
|
return "file_" + name, nil
|
|
}
|
|
|
|
func newStagerExecutor(s InputFileStager) *Executor {
|
|
return New(Config{
|
|
Registry: tool.NewRegistry(),
|
|
Models: func(ctx context.Context, _ string) (context.Context, llm.Model, error) { return ctx, nil, nil },
|
|
Ports: Ports{InputFiles: s},
|
|
})
|
|
}
|
|
|
|
// TestStageInputFiles: files are staged via the port and an [ATTACHED FILES]
|
|
// descriptor (with each file_id) is appended to the prompt.
|
|
func TestStageInputFiles(t *testing.T) {
|
|
st := &stagerFunc{}
|
|
ex := newStagerExecutor(st)
|
|
out := ex.stageInputFiles(context.Background(), "run-1", "agent-1",
|
|
[]tool.InputFile{{Name: "clip.mp3", MimeType: "audio/mpeg", Data: []byte("abcd")}},
|
|
"transcribe this")
|
|
|
|
if len(st.staged) != 1 || st.staged[0].name != "clip.mp3" {
|
|
t.Fatalf("staged = %+v, want one clip.mp3", st.staged)
|
|
}
|
|
if st.staged[0].runID != "run-1" || st.staged[0].agentID != "agent-1" {
|
|
t.Errorf("stager got runID/agentID = %q/%q, want run-1/agent-1", st.staged[0].runID, st.staged[0].agentID)
|
|
}
|
|
for _, want := range []string{"transcribe this", "[ATTACHED FILES]", "clip.mp3", "file_clip.mp3", "audio/mpeg"} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("output missing %q:\n%s", want, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestStageInputFilesNoStager: a nil port leaves the prompt untouched and never
|
|
// drops the run.
|
|
func TestStageInputFilesNoStager(t *testing.T) {
|
|
ex := newStagerExecutor(nil) // Ports.InputFiles == nil
|
|
out := ex.stageInputFiles(context.Background(), "r", "a",
|
|
[]tool.InputFile{{Name: "x.bin", Data: []byte("z")}}, "prompt")
|
|
if out != "prompt" {
|
|
t.Errorf("nil stager changed the prompt: %q", out)
|
|
}
|
|
}
|
|
|
|
// TestStageInputFilesNoFiles: no attachments leaves the prompt untouched.
|
|
func TestStageInputFilesNoFiles(t *testing.T) {
|
|
ex := newStagerExecutor(&stagerFunc{})
|
|
out := ex.stageInputFiles(context.Background(), "r", "a", nil, "prompt")
|
|
if out != "prompt" {
|
|
t.Errorf("no files changed the prompt: %q", out)
|
|
}
|
|
}
|
|
|
|
// TestStageInputFilesDedup: colliding base names are disambiguated so they don't
|
|
// clobber each other at /workspace/<name>.
|
|
func TestStageInputFilesDedup(t *testing.T) {
|
|
st := &stagerFunc{}
|
|
ex := newStagerExecutor(st)
|
|
out := ex.stageInputFiles(context.Background(), "r", "a", []tool.InputFile{
|
|
{Name: "a.wav", MimeType: "audio/wav", Data: []byte("1")},
|
|
{Name: "a.wav", MimeType: "audio/wav", Data: []byte("2")},
|
|
}, "go")
|
|
if len(st.staged) != 2 {
|
|
t.Fatalf("staged %d files, want 2", len(st.staged))
|
|
}
|
|
if st.staged[0].name != "a.wav" || st.staged[1].name != "a-2.wav" {
|
|
t.Errorf("dedup names = %q, %q; want a.wav, a-2.wav", st.staged[0].name, st.staged[1].name)
|
|
}
|
|
if !strings.Contains(out, "a-2.wav") {
|
|
t.Errorf("output missing disambiguated name:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// TestStageInputFilesSkipsBad: empty + oversized files are skipped; a save error
|
|
// drops only that file. With nothing staged, the prompt is unchanged.
|
|
func TestStageInputFilesSkipsBad(t *testing.T) {
|
|
// Empty data → skipped; with no good files the prompt is returned as-is.
|
|
ex := newStagerExecutor(&stagerFunc{})
|
|
if out := ex.stageInputFiles(context.Background(), "r", "a",
|
|
[]tool.InputFile{{Name: "empty.bin", Data: nil}}, "p"); out != "p" {
|
|
t.Errorf("empty file should be skipped, got %q", out)
|
|
}
|
|
// A stager error → that file is dropped; nothing staged → prompt unchanged.
|
|
exErr := newStagerExecutor(&stagerFunc{err: errors.New("disk full")})
|
|
if out := exErr.stageInputFiles(context.Background(), "r", "a",
|
|
[]tool.InputFile{{Name: "x.bin", Data: []byte("z")}}, "p"); out != "p" {
|
|
t.Errorf("save error should drop the file and leave the prompt, got %q", out)
|
|
}
|
|
}
|
|
|
|
// TestStageInputFilesOversize: a file past the byte cap is skipped (prompt
|
|
// unchanged), exercising the size guard directly.
|
|
func TestStageInputFilesOversize(t *testing.T) {
|
|
st := &stagerFunc{}
|
|
ex := newStagerExecutor(st)
|
|
big := make([]byte, maxInputFileBytes+1)
|
|
out := ex.stageInputFiles(context.Background(), "r", "a",
|
|
[]tool.InputFile{{Name: "huge.bin", Data: big}}, "p")
|
|
if out != "p" || len(st.staged) != 0 {
|
|
t.Errorf("oversized file should be skipped: out=%q staged=%d", out, len(st.staged))
|
|
}
|
|
}
|
|
|
|
// TestStageInputFilesCountCap: more than maxInputFiles attachments are truncated
|
|
// to the cap.
|
|
func TestStageInputFilesCountCap(t *testing.T) {
|
|
st := &stagerFunc{}
|
|
ex := newStagerExecutor(st)
|
|
files := make([]tool.InputFile, maxInputFiles+5)
|
|
for i := range files {
|
|
files[i] = tool.InputFile{Name: "f.bin", Data: []byte("x")}
|
|
}
|
|
ex.stageInputFiles(context.Background(), "r", "a", files, "p")
|
|
if len(st.staged) != maxInputFiles {
|
|
t.Errorf("count cap: staged %d, want %d", len(st.staged), maxInputFiles)
|
|
}
|
|
}
|
|
|
|
// TestSanitizeName: traversal + absolute + control-char filenames are reduced to
|
|
// a safe base name (no path separators, no newlines), with a fallback.
|
|
func TestSanitizeName(t *testing.T) {
|
|
cases := map[string]string{
|
|
"../../etc/passwd": "passwd",
|
|
"/etc/cron.d/x": "x",
|
|
`..\..\windows\sys`: "sys",
|
|
"clip.mp3": "clip.mp3",
|
|
"": "attachment",
|
|
"..": "attachment",
|
|
".": "attachment",
|
|
"evil\n- injected": "evil- injected",
|
|
"a/b/c.wav": "c.wav",
|
|
}
|
|
for in, want := range cases {
|
|
if got := sanitizeName(in); got != want {
|
|
t.Errorf("sanitizeName(%q) = %q, want %q", in, got, want)
|
|
}
|
|
// A sanitized name must never carry a path separator or newline.
|
|
got := sanitizeName(in)
|
|
if strings.ContainsAny(got, "/\\\n\r") {
|
|
t.Errorf("sanitizeName(%q) = %q still contains a separator/newline", in, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestStageInputFilesSanitizesTraversal: a traversal filename is staged AND
|
|
// described under its safe base name only.
|
|
func TestStageInputFilesSanitizesTraversal(t *testing.T) {
|
|
st := &stagerFunc{}
|
|
ex := newStagerExecutor(st)
|
|
out := ex.stageInputFiles(context.Background(), "r", "a",
|
|
[]tool.InputFile{{Name: "../../../etc/passwd", MimeType: "text/plain", Data: []byte("x")}}, "go")
|
|
if len(st.staged) != 1 || st.staged[0].name != "passwd" {
|
|
t.Fatalf("staged name = %+v, want passwd", st.staged)
|
|
}
|
|
if strings.Contains(out, "..") || strings.Contains(out, "/etc/") {
|
|
t.Errorf("descriptor leaked the traversal path:\n%s", out)
|
|
}
|
|
}
|