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
15 changes: 3 additions & 12 deletions pkg/selfupdate/exec_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package selfupdate

import (
"errors"
"fmt"
"os"
"os/exec"
Expand Down Expand Up @@ -48,15 +49,15 @@ func reExecProcess(path string, args, env []string) error {
childArgs = args[1:]
}

cmd := exec.Command(path, childArgs...) //nolint:noctx // path is our own freshly installed binary; no context needed for re-exec
cmd := exec.Command(path, childArgs...) //nolint:noctx // re-exec must outlive any request-scoped context
cmd.Env = env
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

if err := cmd.Run(); err != nil {
var exitErr *exec.ExitError
if ok := asExitError(err, &exitErr); ok {
if errors.As(err, &exitErr) {
os.Exit(exitErr.ExitCode())
}
return fmt.Errorf("running updated binary: %w", err)
Expand All @@ -65,13 +66,3 @@ func reExecProcess(path string, args, env []string) error {
os.Exit(0)
return nil
}

// asExitError is a tiny helper kept separate so exec_unix.go does not need to
// import errors solely for this Windows branch.
func asExitError(err error, target **exec.ExitError) bool {
if e, ok := err.(*exec.ExitError); ok { //nolint:errorlint // direct type assertion is intentional here
*target = e
return true
}
return false
}
4 changes: 2 additions & 2 deletions pkg/tools/builtin/backgroundjobs/cmd_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,13 @@ func createProcessGroup(proc *os.Process) (*processGroup, error) {
if _, err := windows.SetInformationJobObject(
job,
windows.JobObjectExtendedLimitInformation,
uintptr(unsafe.Pointer(&info)), //nolint:gosec // Windows API requires unsafe pointer
uintptr(unsafe.Pointer(&info)), //nolint:gosec // Windows API requires unsafe.Pointer
uint32(unsafe.Sizeof(info))); err != nil {
_ = windows.CloseHandle(job)
return nil, err
}

handle, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(proc.Pid)) //nolint:gosec // Pid is safe to convert to uint32 on Windows
handle, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(proc.Pid)) //nolint:gosec // proc.Pid fits in uint32 on Windows
if err != nil {
_ = windows.CloseHandle(job)
return nil, err
Expand Down
26 changes: 21 additions & 5 deletions pkg/tools/builtin/rag/rag.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"fmt"
"log/slog"
"slices"
"sync"

"github.com/docker/docker-agent/pkg/config"
"github.com/docker/docker-agent/pkg/config/latest"
Expand Down Expand Up @@ -49,6 +50,8 @@ type ToolSet struct {
manager *rag.Manager
toolName string
eventCallback EventCallback
cancelWatcher context.CancelFunc
wg sync.WaitGroup
}

// Verify interface compliance.
Expand Down Expand Up @@ -84,20 +87,29 @@ func (t *ToolSet) Start(ctx context.Context) error {
return nil
}

// We create a child context so we can explicitly cancel the watcher and event goroutines
// when Stop() is called, preventing goroutine leaks if the parent context outlives this toolset.
watchCtx, cancel := context.WithCancel(ctx)
t.cancelWatcher = cancel

// Forward RAG manager events if a callback is set.
if t.eventCallback != nil {
go t.forwardEvents(ctx)
t.wg.Go(func() {
t.forwardEvents(watchCtx)
})
}

if err := t.manager.Initialize(ctx); err != nil {
cancel()
t.wg.Wait()
return fmt.Errorf("failed to initialize RAG manager %q: %w", t.toolName, err)
}

go func() {
if err := t.manager.StartFileWatcher(ctx); err != nil {
slog.ErrorContext(ctx, "Failed to start RAG file watcher", "tool", t.toolName, "error", err)
t.wg.Go(func() {
if err := t.manager.StartFileWatcher(watchCtx); err != nil && !errors.Is(err, context.Canceled) {
slog.ErrorContext(watchCtx, "Failed to start RAG file watcher", "tool", t.toolName, "error", err)
}
}()
})
return nil
}

Expand All @@ -106,6 +118,10 @@ func (t *ToolSet) Stop(_ context.Context) error {
if t.manager == nil {
return nil
}
if t.cancelWatcher != nil {
t.cancelWatcher()
}
t.wg.Wait()
return t.manager.Close()
}

Expand Down
44 changes: 44 additions & 0 deletions pkg/tools/builtin/rag/rag_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"slices"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -143,3 +144,46 @@ func TestRAGTool_HandleQuery_Telemetry(t *testing.T) {
require.NoError(t, err)
assert.NotNil(t, res)
}

type failingMockStrategy struct {
mockStrategy
}

func (m *failingMockStrategy) Initialize(_ context.Context, _ []string, _ strategy.ChunkingConfig) error {
return assert.AnError
}

func TestStopAfterFailedStart(t *testing.T) {
t.Parallel()

strategyMock := &failingMockStrategy{}
cfg := rag.Config{
StrategyConfigs: []strategy.Config{
{Name: "failingStrategy", Strategy: strategyMock},
},
}

mgr, err := rag.New(t.Context(), "failing-rag", cfg, nil)
require.NoError(t, err)

tool := &ToolSet{
manager: mgr,
toolName: "failing-rag",
}

err = tool.Start(t.Context())
require.Error(t, err)

done := make(chan struct{})
go func() {
_ = tool.Stop(t.Context())
close(done)
}()

select {
case <-done:
// Success: Stop returned without deadlocking
case <-time.After(5 * time.Second):
t.Fatal("Stop() deadlocked after a failed Start()")
}
}
4 changes: 2 additions & 2 deletions pkg/tools/builtin/shell/cmd_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,13 @@ func createProcessGroup(proc *os.Process) (*processGroup, error) {
if _, err := windows.SetInformationJobObject(
job,
windows.JobObjectExtendedLimitInformation,
uintptr(unsafe.Pointer(&info)), //nolint:gosec // Windows API requires unsafe pointer
uintptr(unsafe.Pointer(&info)), //nolint:gosec // Windows API requires unsafe.Pointer
uint32(unsafe.Sizeof(info))); err != nil {
_ = windows.CloseHandle(job)
return nil, err
}

handle, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(proc.Pid)) //nolint:gosec // Pid is safe to convert to uint32 on Windows
handle, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(proc.Pid)) //nolint:gosec // proc.Pid fits in uint32 on Windows
if err != nil {
_ = windows.CloseHandle(job)
return nil, err
Expand Down
17 changes: 13 additions & 4 deletions pkg/tools/builtin/shell/script_shell.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ var (
)

func NewScript(shellTools map[string]latest.ScriptShellToolConfig, env []string) (*ScriptToolSet, error) {
for _, e := range env {
if strings.ContainsRune(e, 0) {
return nil, errors.New("toolset environment contains a NUL byte")
}
}

for toolName, tool := range shellTools {
if err := validateConfig(toolName, tool); err != nil {
return nil, err
Expand Down Expand Up @@ -248,7 +254,11 @@ func (t *ScriptToolSet) execute(ctx context.Context, rt tools.Runtime, toolConfi
// stay literal because env values may legitimately contain $ (issue
// #2615).
for _, key := range slices.Sorted(maps.Keys(toolConfig.Env)) {
envCopy = append(envCopy, key+"="+path.ExpandEnvRefs(toolConfig.Env[key]))
val := path.ExpandEnvRefs(toolConfig.Env[key])
if strings.ContainsRune(val, 0) {
return tools.ResultError(fmt.Sprintf("configured environment variable %q contains a NUL byte", key)), nil
}
Comment on lines +257 to +260

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking, two notes on scope and framing:

  • Coverage: only the per-tool toolConfig.Env is checked. The toolset-level env from the same config.yaml (NewToolSet, lines 32-39, via environment.ExpandAll) reaches envCopy unchecked. If NUL bytes are worth guarding against, that source should be covered too.
  • Framing: Go's os/exec already rejects NUL in env, EINVAL on Unix and "invalid environment variable" on Windows (Go 1.19.3+, CVE-2022-41716), so nothing is silently truncated. The check is still worthwhile as a clearer error message and matches the existing params-side guard below, but the "security fix" framing in the PR description overstates it.

envCopy = append(envCopy, key+"="+val)
}
for key, value := range params {
if value == nil {
Expand All @@ -262,9 +272,8 @@ func (t *ScriptToolSet) execute(ctx context.Context, rt tools.Runtime, toolConfi
continue
}
valueStr := fmt.Sprintf("%v", value)
// A NUL byte mid-string silently truncates env entries at the
// execve boundary; refuse rather than spawn a process with a
// surprising env.
// Go's os/exec rejects NUL bytes with generic errors. We check here
// to provide a clearer error message.
if strings.ContainsRune(valueStr, 0) {
return tools.ResultError(fmt.Sprintf("argument %q contains a NUL byte", key)), nil
}
Expand Down
Loading