Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ Downstream agents should note **agent-core** upgrades in their own changelogs.

## [Unreleased]

### Security

- **execution**: validate `fluid_log_path` before `os.Stat` / `os.ReadFile` (CodeQL `go/path-injection`); only absolute paths under `/tmp/fluid/`.

## [0.1.0] - 2026-05-26

### Added
Expand Down
9 changes: 5 additions & 4 deletions execution/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -233,8 +233,9 @@ func (a *Agent) startFileLogForwarder(meta map[string]interface{}, payload map[s
return func() {}
}
logPath, _ := payload["fluid_log_path"].(string)
logPath = strings.TrimSpace(logPath)
if logPath == "" {
safePath, err := safeFluidLogPath(logPath)
if err != nil {
log.Printf("fluid_log_path rejected: %v", err)
return func() {}
}

Expand All @@ -249,7 +250,7 @@ func (a *Agent) startFileLogForwarder(meta map[string]interface{}, payload map[s
return
default:
}
info, err := os.Stat(logPath)
info, err := os.Stat(safePath)
if err != nil {
time.Sleep(1 * time.Second)
continue
Expand All @@ -259,7 +260,7 @@ func (a *Agent) startFileLogForwarder(meta map[string]interface{}, payload map[s
lastSize = 0
}
if size > lastSize {
b, err := os.ReadFile(logPath)
b, err := os.ReadFile(safePath)
if err == nil {
chunk := b[int(lastSize):]
for _, line := range splitLogLines(string(chunk)) {
Expand Down
41 changes: 41 additions & 0 deletions execution/logpath.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package execution

import (
"fmt"
"path/filepath"
"strings"
)

// fluidLogPathRoot is where the control plane places per-run logs (see UseCaseEngine.FluidVars).
const fluidLogPathRoot = "/tmp/fluid"

// safeFluidLogPath validates fluid_log_path from skill payloads before os.Stat/os.ReadFile.
// Only absolute paths under /tmp/fluid/ are allowed (no traversal via ..).
func safeFluidLogPath(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", fmt.Errorf("empty log path")
}
if strings.Contains(raw, "\x00") {
return "", fmt.Errorf("invalid log path")
}
if !filepath.IsAbs(raw) {
return "", fmt.Errorf("log path must be absolute")
}

cleaned := filepath.Clean(raw)
root := filepath.Clean(fluidLogPathRoot)
if cleaned != root && !strings.HasPrefix(cleaned, root+string(filepath.Separator)) {
return "", fmt.Errorf("log path must be under %s", fluidLogPathRoot)
}

rel, err := filepath.Rel(root, cleaned)
if err != nil {
return "", fmt.Errorf("log path: %w", err)
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("log path escapes allowed directory")
}

return cleaned, nil
}
32 changes: 32 additions & 0 deletions execution/logpath_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package execution

import (
"path/filepath"
"testing"
)

func TestSafeFluidLogPath(t *testing.T) {
runID := "550e8400-e29b-41d4-a716-446655440000"
valid := filepath.Join(fluidLogPathRoot, runID, "logs", "use-case.log")

got, err := safeFluidLogPath(valid)
if err != nil {
t.Fatalf("expected valid path: %v", err)
}
if got != filepath.Clean(valid) {
t.Fatalf("got %q want %q", got, filepath.Clean(valid))
}

cases := []string{
"",
"relative/log",
"/etc/passwd",
"/tmp/fluid/../etc/passwd",
"/tmp/fluid/../../etc/passwd",
}
for _, p := range cases {
if _, err := safeFluidLogPath(p); err == nil {
t.Fatalf("expected error for %q", p)
}
}
}
Loading