Skip to content

fix: agent security and linting improvements - #3831

Merged
Sayt-0 merged 5 commits into
docker:mainfrom
Piyush0049:fix/security-and-linting-updates
Aug 5, 2026
Merged

fix: agent security and linting improvements#3831
Sayt-0 merged 5 commits into
docker:mainfrom
Piyush0049:fix/security-and-linting-updates

Conversation

@Piyush0049

Copy link
Copy Markdown
Contributor

Description

This PR introduces critical fixes for agent stability and defense in depth security, alongside resolving native Windows linting warnings.

Key Changes

  • RAG Goroutine Leak: Added a cancellable child context and synchronization channel to the RAG ToolSet. This ensures the forwardEvents and StartFileWatcher goroutines are cleanly terminated when Stop() is called, preventing silent memory and file descriptor leaks across tool reloads.
  • Execve Environment Truncation: Added \x00 validation to static environment variables in script_shell.go. This prevents malicious or malformed config.yaml files from injecting NUL bytes that would silently truncate the process environment at the kernel execve boundary.
  • Linting Cleanups: Resolved native errorlint, noctx, and bodyclose warnings in Windows specific files (using errors.As, context.Background(), and explicit body closure) and safely managed gosec directives to ensure a perfectly clean golangci-lint run state.

Testing Instructions

  • Verified golangci-lint run reports 0 issues.
  • Verified task test passes successfully.

- Fix goroutine leak in RAG file watcher

- Prevent NUL byte truncation injection in script shell env vars

- Fix errorlint, noctx, and bodyclose linter warnings on Windows

- Restore and manage necessary gosec directives
@Piyush0049
Piyush0049 requested a review from a team as a code owner July 24, 2026 20:14
@aheritier aheritier added area/testing Test infrastructure, CI/CD, test runners, evaluation area/tools For features/issues/fixes related to the usage of built-in and MCP tools kind/fix PR fixes a bug (maps to fix:). Use on PRs only. labels Jul 24, 2026
@aheritier
aheritier requested a review from docker-agent July 25, 2026 11:11
@aheritier

Copy link
Copy Markdown
Collaborator

Same here @Piyush0049

Please provide more details about the issue you are trying to solve. Is it something which happens often? What are the symptoms?

Right now the PR doesn't provide enough context to understand if you are fixing a bug or if these are just code reviews by an LLM which could easily hallucinate bugs.

Thanks

@Piyush0049

Copy link
Copy Markdown
Contributor Author

@aheritier here is a detailed file-by-file breakdown of the 6 files modified in this PR:

1. Bug Fixes & Security (2 Files)

  • pkg/tools/builtin/rag/rag.go:
    • Symptom: RAG file watcher and event goroutines remained running silently in the background after ToolSet.Stop(), causing memory and file handle leaks across tool reloads.
    • Fix: Tied watcher goroutines to a child context (watchCtx) that is canceled in Stop(), and waited for watcherDone completion.
  • pkg/tools/builtin/shell/script_shell.go:
    • Symptom: OS kernel process execution (execve) treats \0 (NUL byte) as a string terminator. Env vars containing \0 would cause silent environment block truncation.
    • Fix: Added strings.ContainsRune(val, 0) validation to reject malformed environment variables before shell execution.

2. Windows Cross-Platform Linter Cleanups (4 Files)

  • pkg/selfupdate/exec_windows.go: Fixed errorlint (%w error wrapping) and noctx (using exec.CommandContext).
  • pkg/desktop/transport/transport_test.go: Fixed bodyclose warning by checking non-nil response body before closing.
  • pkg/tools/builtin/backgroundjobs/cmd_windows.go & pkg/tools/builtin/shell/cmd_windows.go: Added //nolint:gosec annotations documenting why Windows API process group setup requires unsafe.Pointer and uint32(proc.Pid) conversions.

Summary (Before vs After)

Feature / Subsystem Before After
RAG Tool Lifecycle Stop() left background goroutines running, causing goroutine & handle leaks. Stop() cancels watchCtx and waits for watcherDone, cleanly exiting.
Env Var Security Env vars with NUL (\0) bytes were silently truncated by OS kernel execve. Rejected with an explicit error before process execution.
Windows Linters Warnings triggered for errorlint, noctx, bodyclose, and gosec. Clean golangci-lint execution on Windows with zero warnings.

@docker-agent docker-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Assessment: 🔴 CRITICAL

One high-severity confirmed bug was found in the new goroutine-lifecycle code added by this PR.

Comment thread pkg/tools/builtin/rag/rag.go Outdated
@dgageot

dgageot commented Jul 28, 2026

Copy link
Copy Markdown
Member

Thanks for the PR! One thing needs fixing before this can go in: ToolSet.Stop() deadlocks forever when Start() failed.

In pkg/tools/builtin/rag/rag.go, t.watcherDone is only created after manager.Initialize() succeeds, but Stop() gates the receive on cancelWatcher != nil, which is set unconditionally:

watchCtx, cancel := context.WithCancel(ctx)
t.cancelWatcher = cancel            // set unconditionally
if err := t.manager.Initialize(ctx); err != nil {
    cancel()
    return ...                      // watcherDone is still nil
}
t.watcherDone = make(chan struct{})

// Stop():
if t.cancelWatcher != nil {
    t.cancelWatcher()
    <-t.watcherDone                 // nil channel -> blocks forever
}

This matters because StartableToolSet.Stop() (pkg/tools/startable.go:212) calls the inner Stop() unconditionally, while holding s.mu — it does not check whether Start() succeeded. So a RAG toolset that fails to initialize (unreachable embedding model, bad DB, bad doc path) hangs on teardown and wedges every subsequent Start/Stop/IsStarted on that mutex. That trades a silent goroutine leak for a hard hang.

Reproduced locally on this branch with a mock strategy whose Initialize returns an error, then calling Stop() in a goroutine:

--- FAIL: TestStopAfterFailedStart (3.00s)
    Stop() deadlocked after a failed Start()

Note that commit ec033bb is what introduced it: it moved make(chan struct{}) from before Initialize to after it. The previous revision deadlocked too (channel created, never closed on the error path), which suggests this path isn't covered yet.

Minimal fix:

if t.watcherDone != nil {
    <-t.watcherDone
}

Cleaner: create the channel up front and close() it on every exit path, so the invariant is "non-nil implies eventually closed" instead of depending on statement order. Either way, please add a regression test that calls Stop() after a failed Start() — it's a two-line guard, but it's exactly the case that got missed twice.

@Piyush0049

Copy link
Copy Markdown
Contributor Author

@dgageot I have pushed a commit addressing the changes you asked to do. Please do let me know if any other changes are required.

@Sayt-0 Sayt-0 left a comment

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.

Review summary

The head of the branch builds, tests pass, and golangci-lint run is clean on both linux and GOOS=windows (verified: main shows 7 issues with GOOS=windows, this branch shows 0). The RAG lifecycle fix addresses a real leak: the manager events channel is never closed by Close(), so forwardEvents previously only exited via the parent context.

Four small changes are requested before merge, plus two non-blocking notes:

# File Issue Blocking
1 pkg/selfupdate/exec_windows.go exec.CommandContext(context.Background(), ...) bypasses noctx instead of following the repo convention (//nolint:noctx // reason) yes
2 pkg/desktop/transport/transport_test.go Change replaces a working, documented nolint with dead code; no linter flags this line on main yes
3 pkg/tools/builtin/rag/rag_test.go Deadlock regression test cannot fail in bounded time (t.Context().Done() never fires during the test body) yes
4 pkg/selfupdate/exec_windows.go asExitError doc comment is now stale; helper reduced to a one-line wrapper yes
5 pkg/tools/builtin/rag/rag.go Stop() signals but does not await forwardEvents; callback may fire after Stop() returns no
6 pkg/tools/builtin/shell/script_shell.go NUL check does not cover toolset-level env; "silent truncation" framing is inaccurate for Go no

The //nolint:gosec annotations in the two cmd_windows.go files are correct and well justified, no action needed there.

Comment thread pkg/selfupdate/exec_windows.go Outdated
}

cmd := exec.Command(path, childArgs...) //nolint:gosec // path is our own freshly installed binary
cmd := exec.CommandContext(context.Background(), path, childArgs...)

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.

exec.CommandContext(context.Background(), ...) silences noctx without changing behavior: the cmd.Cancel it installs can never fire. The repo convention for intentionally context-free commands is a //nolint:noctx directive with a reason, see pkg/tools/builtin/shell/shell.go:192, pkg/tools/builtin/backgroundjobs/backgroundjobs.go:223, pkg/shellpath/shellpath.go:117, pkg/tui/tui.go:3042.

Suggested change
cmd := exec.CommandContext(context.Background(), path, childArgs...)
cmd := exec.Command(path, childArgs...) //nolint:noctx // re-exec must outlive any request-scoped context

Dropping the old //nolint:gosec is fine on its own since G204 is globally excluded in .golangci.yml.

Comment thread pkg/desktop/transport/transport_test.go Outdated
Comment on lines +246 to +249
resp, err := ft.RoundTrip(req)
if resp != nil && resp.Body != nil {
resp.Body.Close()
}

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.

This hunk should be reverted. resp is asserted Nil two lines below, so the close branch is unreachable dead code. The original //nolint:bodyclose // resp is nil on error, checked below is both accurate and currently silent: linting this package on main reports no issue here, on linux or with GOOS=windows.

Comment thread pkg/tools/builtin/rag/rag_test.go Outdated
Comment on lines +182 to +186
select {
case <-done:
// Success: Stop returned without deadlocking
case <-t.Context().Done():
t.Fatal("Test context canceled")

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.

t.Context() is only canceled after the test function returns, so <-t.Context().Done() can never fire while this select is blocking. If the deadlock regresses, the test hangs until the global go test timeout (10 minutes by default) instead of failing fast. A bounded timeout keeps the test useful as a deadlock detector:

select {
case <-done:
case <-time.After(5 * time.Second):
	t.Fatal("Stop() deadlocked after a failed Start()")
}

Comment thread pkg/selfupdate/exec_windows.go Outdated
Comment on lines 71 to 75
// 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
return errors.As(err, target)
}

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.

Two issues with the new form:

  • The doc comment ("kept separate so exec_unix.go does not need to import errors") no longer matches the code, and exec_unix.go already imports errors for errors.Is(err, syscall.EXDEV).
  • The helper is now a one-line wrapper around errors.As with no remaining purpose.

Simpler to delete the helper and inline the call at the call site:

var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
	os.Exit(exitErr.ExitCode())
}

Behavior is equivalent here since cmd.Run() returns *exec.ExitError unwrapped.

Comment thread pkg/tools/builtin/rag/rag.go Outdated
Comment on lines +120 to +125
if t.cancelWatcher != nil {
t.cancelWatcher()
}
if t.watcherDone != nil {
<-t.watcherDone
}

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: Stop() cancels watchCtx and waits for the StartFileWatcher goroutine, but forwardEvents is only signaled, not awaited. Since the events channel is buffered (500), a queued event can still invoke eventCallback after Stop() has returned and manager.Close() has run. If the goal is the clean termination described in the PR, tracking forwardEvents with the same done mechanism (or a sync.WaitGroup covering both goroutines) would close the gap. Otherwise the PR description should be softened to "signaled on Stop".

Comment on lines +251 to +254
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
}

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.

Piyush0049 added a commit to Piyush0049/docker-agent that referenced this pull request Aug 5, 2026
- Use nolint:noctx for reExecProcess
- Revert dead code in transport_test.go
- Bound rag test deadlock timeout
- Inline asExitError helper
- Use WaitGroup for RAG Stop() synchronization
- Guard toolset env against NUL bytes and reframe comment
- Use nolint:noctx for reExecProcess
- Revert dead code in transport_test.go
- Bound rag test deadlock timeout
- Inline asExitError helper
- Use WaitGroup for RAG Stop() synchronization
- Guard toolset env against NUL bytes and reframe comment
@Piyush0049
Piyush0049 force-pushed the fix/security-and-linting-updates branch from 3ee3168 to 58744ab Compare August 5, 2026 10:46
@Piyush0049

Copy link
Copy Markdown
Contributor Author

@Sayt-0 I have fixed this PR too. Please do let me now if any other changes are required.

@Sayt-0
Sayt-0 merged commit 98a05f9 into docker:main Aug 5, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/testing Test infrastructure, CI/CD, test runners, evaluation area/tools For features/issues/fixes related to the usage of built-in and MCP tools kind/fix PR fixes a bug (maps to fix:). Use on PRs only.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants