fix(docker): wrap docker CLI invocations in user shell to inherit PATH#378
fix(docker): wrap docker CLI invocations in user shell to inherit PATH#378
Conversation
Dumbris
left a comment
There was a problem hiding this comment.
Code review: fix/docker-path-execution
Thanks for tackling this — the macOS-tray PATH problem has been a real pain. Overall the approach (shell-wrapping all docker invocations so the login shell populates PATH) is sound and consistent with how connection_stdio.go already wraps stdio upstream commands. A few issues though, one of which is a hard blocker.
Blocker: build is broken
internal/upstream/core/monitoring.go still imports os/exec but no longer references it after the two call sites were migrated to c.newDockerCmd. go build ./... fails with:
internal/upstream/core/monitoring.go:8:2: \"os/exec\" imported and not used
Fix: drop the os/exec import. This alone will block CI and merge.
Correctness / design issues
-
Two parallel shell-wrap implementations.
internal/upstream/core/docker.gocorrectly reuses the existingc.wrapWithUserShell(...)+shellescape(...)helpers fromconnection_stdio.go. Butinternal/security/scanner/docker.goreinvents the wheel with an inlinegetDockerCmdthat:- Hardcodes
sh -l -c(ignores$SHELL, unlikewrapWithUserShellwhich respects it and falls back sensibly). - Hardcodes
cmd /con Windows even when the user runs Git Bash (wrapWithUserShellhas explicit logic for this viaisBash). - Uses a hand-rolled POSIX single-quote escape (
'\"'\"') and a naive Windowsstrings.Trim(arg, '\"')that silently destroys any legitimately-quoted argument.
This is a maintainability and correctness liability. Either extract
wrapWithUserShell/shellescapeto a small shared package (e.g.internal/shellwrap) and use it from both call sites, or at minimum document why the scanner needs its own variant. Right now the two implementations WILL drift. - Hardcodes
-
Security: shell metacharacters in scanner args. The inline escape in the scanner is correct for fully-controlled arguments (container names, image IDs), but
RunScannerpassescfg-derived args that can include user-provided image names, volume mounts, and env vars. If any of those ever flow from remote config / registry data, the hand-rolled quoting is the only thing standing between an attacker and arbitrary shell execution on the host. The existingshellescapeinconnection_stdio.gohas been battle-tested; please reuse it. -
Windows branch in scanner is broken for args containing spaces or quotes.
strings.Trim(arg, '\"')only strips leading/trailing quotes — an arg likeC:\\Program Files\\Docker\\docker.exewill be wrapped as\"C:\\Program Files\\Docker\\docker.exe\"and passed through cmd.exe unchanged, but an arg containing an embedded\"will yield a malformed command line.cmd.exequoting rules are notoriously bad; again, reuse the existing Windows path fromshellescape. -
Performance / latency. Spawning
sh -l -cfor every health check (checkDockerContainerHealth,GetConnectionDiagnostics— both run on a 2s interval) reads the user's full login rc files every time. On a machine with a heavy.zshrc/.bashrc, this can add 100–500ms per call and run CPU hot in the background. Consider caching the resolved Docker binary path once at startup (via one login-shell call) and then callingexec.CommandContext(ctx, cachedDockerPath, args...)directly. This is how most GUI apps on macOS solve this problem (it's essentially what the Docker Desktop helper does). -
No tests. Given this changes how every Docker command is launched, there should be at least:
- A unit test for
newDockerCmd/getDockerCmdasserting the resultingcmd.Pathandcmd.Argsfor both OSes (useruntime.GOOSmocking or build tags). - A regression test that arguments containing spaces, single quotes, and double quotes survive round-tripping through the wrapper.
The existing
internal/upstream/core/shell_test.goalready testswrapWithUserShell; if the scanner reuses that helper, you get the tests for free. - A unit test for
Minor / style
getDockerCmduses} else {after areturn—gofmtdoesn't flag it butgolangci-lint'sgolint/revivewill. Drop theelse.- The new helper in
upstream/core/docker.gosilently assumesc.envManagermay be nil (nil-check is present — good), butwrapWithUserShellitself dereferencesc.envManager.GetSystemEnvVarunconditionally. If there's any code path whereenvManageris nil, this will panic. Worth an assertion or nil-guard at construction. newDockerCmddoes not log the wrapped command —wrapWithUserShelllogs at debug level, so the upstream path gets logging for free, but the scanner'sgetDockerCmdis silent. Add ad.logger.Debug(...)for parity; it's invaluable when this breaks on a user's machine.
Regression risk
- The scanner currently inherits the parent process environment via
exec.CommandContext. Switching tosh -l -cmeans scanner subprocesses now get a full login environment — this could expose env vars (AWS creds, GitHub tokens from~/.zshrc) to Docker images being scanned. This probably isn't what you want for a security scanner. Consider whetherDockerRunnershould rundockerdirectly with a resolved absolute path (option 4 above) rather than with a full login shell.
Verdict
Do not merge as-is — the build is broken. After fixing the import, the rest is reviewable-but-fixable: I'd strongly prefer reusing wrapWithUserShell / shellescape in the scanner rather than maintaining two escape implementations, and I think the "resolve docker path once, then exec directly" approach would be a meaningfully better fix long-term, especially for the health-check hot path.
Happy to pair on any of this.
After migrating monitoring.go to use c.newDockerCmd, the os/exec import was left dangling and broke the build. Remove it. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Introduces internal/shellwrap to centralize the login-shell wrapping
logic and docker binary resolution that were previously duplicated
(or reinvented) across internal/upstream/core and
internal/security/scanner.
Key helpers:
* Shellescape — POSIX single-quote / cmd.exe quoting.
* WrapWithUserShell — wraps a command in $SHELL -l -c for Unix
(and /c for Windows cmd), honoring $SHELL instead of hardcoding
sh so zsh/fish users get their real interactive PATH.
* ResolveDockerPath — sync.Once-cached absolute path for the docker
binary. Tries exec.LookPath first and only falls back to a
one-shot login-shell probe when the fast path fails. Callers can
then exec docker directly on every subsequent call, avoiding the
cost of respawning a login shell on hot paths (health check,
diagnostics, ~every 2-5s).
* MinimalEnv — returns an allow-listed PATH+HOME environment
for subprocesses that must not inherit ambient secrets.
Includes unit tests covering:
* Shellescape round-trips for spaces, single quotes, $, backticks,
and glob stars.
* WrapWithUserShell honors $SHELL overrides and falls back to
/bin/bash when unset.
* ResolveDockerPath is cached across calls (second call returns
the same value even after PATH is sabotaged).
* MinimalEnv strips AWS/GitHub/Anthropic/OpenAI credentials while
retaining PATH.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The previous implementation of the scanner's getDockerCmd had three
problems flagged in PR review:
1. It hardcoded \`sh -l -c\` on Unix, ignoring \$SHELL. Users whose login
shell is zsh/fish would get a different PATH than the one they see
in Terminal.
2. It hardcoded \`cmd /c\` on Windows, breaking Git Bash / WSL users,
and used strings.Trim(arg, "\"") which silently mangles legitimately
quoted arguments.
3. It inherited the full user environment via sh -l -c. Because the
scanner runs untrusted container images (vulnerability scanners,
SBOM generators, …), ambient secrets such as AWS_ACCESS_KEY_ID,
GITHUB_TOKEN, or ANTHROPIC_API_KEY could leak into scan sandboxes.
Fix:
* Use shellwrap.ResolveDockerPath to locate the docker binary once
(cached process-wide) and exec it directly — no shell wrapping.
* Set cmd.Env to shellwrap.MinimalEnv(), which retains only the
PATH + HOME (+ Windows equivalents) needed for docker to run.
* Drop the custom quoting logic entirely; exec.Command already
passes arguments verbatim when we skip the shell.
Added TestDockerRunnerDoesNotLeakAmbientSecrets which pollutes the
process env with fake AWS/GitHub/Anthropic credentials and then
asserts cmd.Env is non-nil and does NOT contain any of those keys
while still carrying PATH.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…lwrap
The Docker PATH fix in this PR made every newDockerCmd call re-spawn
sh -l -c so that docker would be discoverable when mcpproxy is
launched from Launchpad / a LaunchAgent. That works, but
checkDockerContainerHealth and GetConnectionDiagnostics fire every
few seconds each, which means we were re-reading .zshrc / .bashrc
dozens of times a minute per Docker upstream.
Fix:
* Resolve the docker binary once via shellwrap.ResolveDockerPath
(sync.Once) and exec it directly on subsequent calls. Retain a
fallback to the existing c.wrapWithUserShell path in case the
cache resolution fails (e.g. uncommon install layouts), so the
original "works when launched from Launchpad" guarantee stands.
Also deduplicate the shell wrapping implementation:
* c.wrapWithUserShell is now a thin wrapper around
shellwrap.WrapWithUserShell. It still emits the server-scoped
debug log line that existed before for log continuity.
* The package-local shellescape helper is kept as an alias of
shellwrap.Shellescape for backward compatibility with the
existing TestShellescape suite in shell_test.go.
No behavior change for the personal edition binary beyond the perf
improvement — docker commands are still launched with the correct
PATH and the secure environment built by c.envManager.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Deploying mcpproxy-docs with
|
| Latest commit: |
1c7dea3
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://ccf94409.mcpproxy-docs.pages.dev |
| Branch Preview URL: | https://fix-docker-path-execution.mcpproxy-docs.pages.dev |
|
Pushed review fixes (4 new commits on top of Blocker
Unified shell wrapping (new shared package)
Scanner fixes (unify + security)
Upstream perf fix
Verification on macOS arm64
Personal edition binary behavior is unchanged beyond the perf improvement. The scanner now runs with a minimal, allow-listed env that does not inherit ambient credentials. |
📦 Build ArtifactsWorkflow Run: View Run Available Artifacts
How to DownloadOption 1: GitHub Web UI (easiest)
Option 2: GitHub CLI gh run download 24284567542 --repo smart-mcp-proxy/mcpproxy-go
|
Summary
dockerCLI invocations in the user's login shell (sh -l -con Unix,cmd /con Windows) so Docker-related subprocesses inherit the samePATHas the user's interactive shell.mcpproxy(launched from the macOS tray / LaunchAgent) cannot finddockerbecause Docker Desktop adds itself toPATHonly via the user's shell rc files, not the GUI environment.internal/upstream/core/docker.go) and the security scanner Docker runner (internal/security/scanner/docker.go). UpstreamClientchanges reuse the existingwrapWithUserShellhelper and applyenvManager.BuildSecureEnvironment(); scanner introduces a localgetDockerCmdhelper with inline POSIX/Windows quoting.Test plan
go build ./...passes (currently FAILS — see review comment about unusedos/execimport ininternal/upstream/core/monitoring.go)go test ./internal/upstream/core/... -racego test ./internal/security/scanner/... -race./scripts/test-api-e2e.sh