Skip to content

fix(agent/agentcontainers): forward execer args without shell interpretation#26201

Draft
zedkipp wants to merge 1 commit into
mainfrom
zedkipp/plat-261-docker-interoplation
Draft

fix(agent/agentcontainers): forward execer args without shell interpretation#26201
zedkipp wants to merge 1 commit into
mainfrom
zedkipp/plat-261-docker-interoplation

Conversation

@zedkipp

@zedkipp zedkipp commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

commandEnvExecer previously assembled an sh -c script by wrapping each argument in fmt.Sprintf("%q", arg). Go's %q produces double-quoted Go syntax, and POSIX shells still perform $(...), backtick, and $VAR expansion inside double quotes, so attacker-controlled values such as Docker container labels could be evaluated by the agent's shell as a container-to-host escape.

Drop the shell wrapper entirely and forward the cmd and args to the inner Execer as discrete argv entries. The PATH from CommandEnv is now honored by a small env-aware lookup (lookPathInEnv) so user-installed binaries (devcontainer, custom docker) continue to resolve as they did under the shell.

Authored with assistance from Coder Agents.

…ner labels

When the agent processed a container, attacker-controlled metadata
such as devcontainer.metadata labels reached the user's shell with
quoting that did not block command substitution. A container started
on the workspace's Docker daemon could therefore embed shell code in
a label and have the agent execute it as the agent user on the
workspace host, escaping the container.

Invoke docker and devcontainer commands directly rather than through
a shell. Untrusted strings are passed as discrete argv entries, so
they cannot be reinterpreted as shell syntax. The user's PATH
continues to apply so user-installed binaries still resolve.

Authored with assistance from Coder Agents.
@linear-code

linear-code Bot commented Jun 9, 2026

Copy link
Copy Markdown

PLAT-261

@zedkipp

zedkipp commented Jun 9, 2026

Copy link
Copy Markdown
Contributor Author

/coder-agents-review

@coder-agents-review

coder-agents-review Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Chat: Review in progress | View chat
Requested: 2026-06-09 22:10 UTC by @zedkipp

deep-review v0.7.1 | Round 1 | 242c4d7..fefa8d7

Last posted: Round 1, 9 findings (5 P3, 1 P4, 3 Nit), COMMENT. Review

Finding inventory

Findings

# Sev Status Location Summary Round Reviewer Posted
CRF-1 P3 Open execer.go:104 Windows lookPathInEnv ignores env-supplied PATH, falls back to agent process PATH R1 Netero Yes
CRF-2 Nit Open execer_internal_test.go:42 Test skip messages claim Windows coverage that does not exist R1 Netero Yes
CRF-3 P3 Open execer.go:87 isExecutable uses permission bits only, not effective access; "mirrors exec.LookPath" doc claim is inaccurate R1 Mafuuu P3, Meruem Note, Razor Note, Knov Note Yes
CRF-4 P3 Open execer.go:110 "POSIX: later definitions override earlier ones" comment misattributes to POSIX; actually Go's dedupEnv R1 Razor Yes
CRF-5 P3 Open execer.go:114 Empty PATH entry POSIX branch (lines 114-116) is new code with no test R1 Bisky Yes
CRF-6 P3 Open execer.go:62 Error log omits command name being resolved R1 Chopper Yes
CRF-7 Nit Open execer_internal_test.go:71 Tests use context.Background(); t.Context() available since Go 1.24 R1 Ging-Go Yes
CRF-8 P4 Open execer.go:50 Doc comments carry mechanism narration restating what code shows; aggregate pattern across prepare, lookPathInEnv, struct, and test docs R1 Gon P2, Leorio disagrees on prepare doc Yes
CRF-9 Nit Open execer_internal_test.go:142 dir1/dir2 names are opaque; descriptive names would be self-documenting R1 Gon Yes

Round log

Round 1

Panel round. Netero first pass: 1 P3, 1 Nit. Panel (18 reviewers): 4 P3, 1 P4, 2 Nit new. 0 dropped. Reviewed against 242c4d7..fefa8d7.

About deep-review

CRF = Coder Review Finding (P0-P4, Nit, Note)

Reviewer Focus
Bisky tests
Chopper ops/errors
Churn-guard change verification
Ging language modernization
Gon naming
Hisoka edge cases
Killua perf
Kite change integrity
Knov contracts
Knuckle SQL
Kurapika security
Law decomposition
Leorio docs
Luffy product
Mafu-san process
Mafuuu contracts
Melody dispatch/pairing
Meruem structural
Nami frontend
Netero mechanical checks
Pariston premise testing
Pen-botter product gaps
Razor verification
Robin duplication
Ryosuke Go arch
Takumi concurrency
Zoro shape

🤖 Managed by Coder Agents.

@coder-agents-review coder-agents-review Bot 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.

Solid security fix. The old sh -c wrapper with Go %q quoting had a real shell injection surface for any attacker-controlled argv content (container labels, workspace paths). This PR eliminates the class by removing the shell entirely and forwarding argv directly. The test suite is thorough: ten distinct injection vectors, PATH resolution edge cases, and graceful degradation.

"The intermediate shell was pure indirection cost with a security surface as the interest rate." (Ryosuke)

5 P3, 1 P4, 3 Nit. All are documentation accuracy, test coverage gaps, or operational signal issues. The security fix itself is clean and complete.

Two behavioral observations worth knowing: (1) When lookPathInEnv fails to resolve a command, the bare name falls through to exec.Cmd's internal LookPath, which searches the agent's PATH. This is more forgiving than the old shell behavior, and for the hardcoded commands (docker, devcontainer), both PATHs almost always agree. (2) recordingExecer is the fifth agentexec.Execer test fake in the tree. The duplication is structurally forced by Go's test package boundary (internal test package cannot import external test fakes).

🤖 This review was automatically generated with Coder Agents.

if strings.ContainsRune(name, filepath.Separator) {
return name, nil
}
if runtime.GOOS == "windows" {

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.

P3 [CRF-1] On Windows, lookPathInEnv delegates to exec.LookPath(name), which searches the agent process's PATH rather than the env-supplied PATH. The old shell-based approach resolved commands against the user's PATH because the shell inherited the user's environment.

If a Windows user's CommandEnv PATH includes a custom docker location absent from the agent's PATH, container commands fail silently. The doc comment at lines 95-96 acknowledges this gap, but there is no ticket or follow-up.

Suggested fix: either implement Windows-specific PATH scanning (parsing PATHEXT) or file a ticket and update the skip message to reference it. (Netero)

🤖

t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("PATH resolution semantics differ on Windows; covered separately.")
}

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.

Nit [CRF-2] The skip message says "covered separately" but no Windows-specific test for lookPathInEnv's exec.LookPath delegation exists in this PR or elsewhere in agent/agentcontainers/. Same issue at line 139 ("covered there"). The skips are correct; the messages make a false claim about existing coverage.

Suggested: "Windows PATH resolution uses exec.LookPath; not tested here." (Netero)

🤖

return c
}

// lookPathInEnv resolves a command name against the PATH found in env. It

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.

P3 [CRF-3] The doc says "mirrors os/exec.LookPath" but diverges in at least two ways:

  1. isExecutable (line 128) checks Perm()&0o111 != 0 (any user class). The stdlib's findExecutable calls unix.Eaccess(file, unix.X_OK) to verify the calling process can actually execute the file, accounting for real UID, GID, and supplementary groups. If a binary is owned by root:docker with mode 0o750 and the agent runs as uid=1000 outside the docker group, isExecutable resolves it but exec fails with "permission denied," skipping a valid binary later in PATH.

  2. Names with path separators (line 101-102) are returned unchanged with nil error. The stdlib validates executability for such names.

"The old shell-based approach got correct per-user resolution for free because the shell itself performs eaccess-equivalent checks during PATH lookup." (Mafuuu)

Since eaccess lives in internal/syscall/unix (inaccessible outside stdlib), the behavioral fix is impractical. Update the doc to drop "mirrors os/exec.LookPath" and describe the actual behavior: permission-bit-based PATH lookup using the supplied env. (Mafuuu P3, Meruem Note, Razor Note, Knov Note)

🤖

var pathVar string
for _, kv := range env {
if k, v, ok := strings.Cut(kv, "="); ok && k == "PATH" {
pathVar = v // POSIX: later definitions override earlier ones.

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.

P3 [CRF-4] POSIX: later definitions override earlier ones is incorrect. POSIX does not guarantee last-wins for duplicate environment variable names; the behavior is explicitly undefined. With raw execve + glibc, getenv("PATH") returns the first match when the env array contains duplicate PATH entries.

The last-wins behavior comes from Go's exec.Cmd.Start(), which calls dedupEnv to remove duplicates in favor of the last entry (confirmed in os/exec/exec.go). Because both lookPathInEnv and dedupEnv keep the last value, the code is correct. The comment is misleading: applying this "POSIX rule" in a non-Go context would give the wrong answer.

Suggested: // Go's exec.Cmd deduplicates env to last-wins; match that here. (Razor)

🤖

}
}
for _, dir := range filepath.SplitList(pathVar) {
if dir == "" {

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.

P3 [CRF-5] This empty PATH entry branch is new code with no test exercising it. Coverage confirms these lines are uncovered. The existing "EmptyPATH" test case (PATH=) checks a different condition: filepath.SplitList("") returns [], so the loop body never runs and the dir == "" rewrite is never reached.

A test case like {name: "EmptyPATHEntry", nameArg: "tool", env: []string{"PATH=:" + dir2}, want: filepath.Join(dir2, "tool")} would exercise the branch. (Bisky)

(Bisky)

🤖

shell, dir, env, err := e.commandEnv(nil, nil)
_, dir, env, err := e.commandEnv(nil, nil)
if err != nil {
e.logger.Error(ctx, "get command environment failed", slog.Error(err))

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.

P3 [CRF-6] The error log reads "get command environment failed" with only slog.Error(err). An operator investigating workspace exec failures at scale sees this line but cannot tell which command (docker, devcontainer, a custom binary) triggered it without cross-referencing timestamps.

Fix: add slog.F("cmd", inName) to the logger.Error call. (Chopper)

🤖

{name: "Newline", args: []string{"inspect", "line1\nline2"}},
}

ctx := context.Background()

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.

Nit [CRF-7] Three call sites (lines 71, 111, 127) create context.Background(). The project is on Go 1.26; t.Context() (available since 1.24) returns a context canceled when the test ends, giving lifecycle-aware cleanup for free. (Ging-Go)

🤖

Comment on lines +50 to +58
// prepare returns the command name, args, working directory and environment
// to hand to the wrapped Execer.
//
// The shell returned by CommandEnv is intentionally discarded: this layer no
// longer invokes a shell, so attacker-controlled argv elements cannot be
// reinterpreted as shell syntax. To match what the user's shell would resolve,
// the command name is resolved against the PATH in env rather than the agent
// process's PATH. If lookup fails, the original name is forwarded so the
// wrapped execer can surface a normal "executable not found" error.

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.

P4 [CRF-8] Several doc comments in this PR carry mechanism narration that restates what the code already shows. The security rationale (lines 53-56) earns its place; the surrounding padding does not. Audit found 9 of 14 new comment blocks with bloat.

Examples: lines 50-51 ("returns the command name, args, working directory and environment to hand to the wrapped Execer") restate the function signature. recordingExecer doc (test file lines 19-21) says the same thing twice in different words.

Note: the lookPathInEnv doc rewrite is likely addressed by CRF-3's fix (dropping the inaccurate "mirrors" claim). (Gon)

🤖

t.Skip("lookPathInEnv defers to exec.LookPath on Windows; covered there")
}

dir1 := t.TempDir()

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.

Nit [CRF-9] dir1/dir2 tell the reader nothing about their contents. The test places non-executable fixtures in dir1 and executables in dir2. Names like noExecDir/execDir make the setup and every referencing test case self-documenting. (Gon)

🤖

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant