Skip to content

Commit c9eac4f

Browse files
committed
fix(docker): capture DOCKER_* daemon vars from login shell for direct-exec spawn
Codex round-2 (P2) on PR #699: the round-1 fix only added DOCKER_HOST/ DOCKER_CONTEXT/… to the secure-env allowlist, which preserves vars already in os.Environ(). When mcpproxy is launched from the macOS tray / LaunchAgent those vars exist ONLY in the user's login shell rc files, so the allowlist was a no-op and the direct-exec docker spawn still hit the default socket (Colima/rootless/remote daemons unreachable). Fix: capture the docker daemon-access vars FROM the login shell, mirroring the existing LoginShellPATH machinery. - internal/shellwrap/shellwrap.go — new LoginShellEnvVars(logger, keys): one `<shell> -l -c` invocation prints the requested vars bracketed by markers and separated by 0x1F (banner-safe), parsed and cached per-key process-wide. Returns only non-empty values; no-op on Windows / on capture failure. - internal/secureenv/manager.go — BuildSecureEnvironment, gated on EnhancePath (the upstream-spawn signal), injects any docker daemon var still missing from the env via the login shell. Vars already present (allowlist + os.Environ) are left untouched, so it only forks when something is actually missing, and the underlying capture is cached. Overridable loginShellEnvFn for tests. The round-1 allowlist append (client.go) is kept: it still covers vars that ARE in os.Environ() under a custom operator allowlist. The two paths are complementary. TDD: TestLoginShellEnvVars_CapturesFromShellOnly (var exported only inside the fake login shell, absent from os.Environ) + TestBuildSecureEnvironment_Injects DockerVarFromLoginShell (restrictive allowlist proves the login-shell path, not the allowlist, supplied DOCKER_HOST) + EnhancePath-gating guard. All offline. Related #696
1 parent b54d0b0 commit c9eac4f

4 files changed

Lines changed: 280 additions & 0 deletions

File tree

internal/secureenv/launchd_path_test.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,72 @@ func withFakeLoginShellPath(path string) func() {
225225
return func() { loginShellPATHFn = prev }
226226
}
227227

228+
// withFakeLoginShellEnv swaps loginShellEnvFn for a stub returning `vals`.
229+
func withFakeLoginShellEnv(vals map[string]string) func() {
230+
prev := loginShellEnvFn
231+
loginShellEnvFn = func(keys []string) map[string]string {
232+
out := make(map[string]string, len(keys))
233+
for _, k := range keys {
234+
if v, ok := vals[k]; ok {
235+
out[k] = v
236+
}
237+
}
238+
return out
239+
}
240+
return func() { loginShellEnvFn = prev }
241+
}
242+
243+
// TestBuildSecureEnvironment_InjectsDockerVarFromLoginShell is the MCP-2744
244+
// round-2 regression: a docker daemon-access var (DOCKER_HOST) present ONLY in
245+
// the login shell — absent from os.Environ() and not allowlisted — must still
246+
// be injected into the direct-exec docker-isolation spawn env. The restrictive
247+
// allowlist guarantees os.Environ() cannot supply it, so a pass proves the
248+
// login-shell capture path (not the allowlist) did the work.
249+
func TestBuildSecureEnvironment_InjectsDockerVarFromLoginShell(t *testing.T) {
250+
defer withFakeLoginShellPath("/usr/bin:/bin")()
251+
defer withFakeLoginShellEnv(map[string]string{"DOCKER_HOST": "tcp://login-shell-only:2375"})()
252+
253+
m := NewManager(&EnvConfig{
254+
InheritSystemSafe: true,
255+
EnhancePath: true,
256+
AllowedSystemVars: []string{"PATH", "HOME"}, // DOCKER_* cannot come from os.Environ
257+
})
258+
259+
var got string
260+
for _, kv := range m.BuildSecureEnvironment() {
261+
if strings.HasPrefix(kv, "DOCKER_HOST=") {
262+
got = kv
263+
}
264+
}
265+
assert.Equal(t, "DOCKER_HOST=tcp://login-shell-only:2375", got,
266+
"DOCKER_HOST present only in the login shell must be injected into the spawn env")
267+
}
268+
269+
// TestBuildSecureEnvironment_NoDockerLoginShellWhenEnhanceDisabled verifies the
270+
// login-shell docker capture is gated on EnhancePath, so non-spawn callers do
271+
// not pay the login-shell fork.
272+
func TestBuildSecureEnvironment_NoDockerLoginShellWhenEnhanceDisabled(t *testing.T) {
273+
called := false
274+
prev := loginShellEnvFn
275+
loginShellEnvFn = func(keys []string) map[string]string {
276+
called = true
277+
return map[string]string{"DOCKER_HOST": "tcp://should-not-be-used:2375"}
278+
}
279+
t.Cleanup(func() { loginShellEnvFn = prev })
280+
281+
m := NewManager(&EnvConfig{
282+
InheritSystemSafe: true,
283+
EnhancePath: false,
284+
AllowedSystemVars: []string{"PATH", "HOME"},
285+
})
286+
287+
for _, kv := range m.BuildSecureEnvironment() {
288+
require.False(t, strings.HasPrefix(kv, "DOCKER_HOST=tcp://should-not-be-used"),
289+
"login-shell docker var must not be injected when EnhancePath is false")
290+
}
291+
assert.False(t, called, "login-shell env capture must not run when EnhancePath is false")
292+
}
293+
228294
func getPATH(envVars []string) string {
229295
for _, kv := range envVars {
230296
if strings.HasPrefix(kv, "PATH=") {

internal/secureenv/manager.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ import (
1313
// caches the result for the process lifetime via sync.Once.
1414
var loginShellPATHFn = func() string { return shellwrap.LoginShellPATH(nil) }
1515

16+
// loginShellEnvFn captures named env vars from the user's interactive login
17+
// shell. Overridable in tests. Default delegates to shellwrap.LoginShellEnvVars
18+
// which caches per-key for the process lifetime.
19+
var loginShellEnvFn = func(keys []string) map[string]string { return shellwrap.LoginShellEnvVars(nil, keys) }
20+
1621
const (
1722
osWindows = "windows"
1823
osDarwin = "darwin"
@@ -287,6 +292,52 @@ func (m *Manager) BuildSecureEnvironment() []string {
287292
envVars = m.ensureComprehensivePath(envVars)
288293
}
289294

295+
// MCP-2744: EnhancePath marks an upstream spawn env. The docker-isolation
296+
// spawn now direct-execs the resolved docker binary (no login-shell wrap),
297+
// so docker daemon-access vars the user exports ONLY in their rc files
298+
// (absent from os.Environ() — the macOS tray/LaunchAgent case) would be lost
299+
// and `docker run` would silently fall back to the default socket. Capture
300+
// any still-missing ones from the login shell. Vars already present (via the
301+
// allowlist when they ARE in os.Environ()) are left untouched.
302+
if m.config.EnhancePath {
303+
envVars = m.ensureDockerDaemonEnv(envVars)
304+
}
305+
306+
return envVars
307+
}
308+
309+
// ensureDockerDaemonEnv injects docker daemon-access vars (DOCKER_HOST etc.)
310+
// captured from the user's login shell for any that are not already present in
311+
// envVars. It only forks a login shell when at least one such var is missing,
312+
// and the underlying capture is cached process-wide.
313+
func (m *Manager) ensureDockerDaemonEnv(envVars []string) []string {
314+
dockerVars := DockerDaemonEnvVars()
315+
316+
present := make(map[string]struct{}, len(dockerVars))
317+
for _, ev := range envVars {
318+
for _, dv := range dockerVars {
319+
if strings.HasPrefix(ev, dv+"=") {
320+
present[dv] = struct{}{}
321+
}
322+
}
323+
}
324+
325+
missing := make([]string, 0, len(dockerVars))
326+
for _, dv := range dockerVars {
327+
if _, ok := present[dv]; !ok {
328+
missing = append(missing, dv)
329+
}
330+
}
331+
if len(missing) == 0 {
332+
return envVars
333+
}
334+
335+
fromShell := loginShellEnvFn(missing)
336+
for _, dv := range missing {
337+
if v, ok := fromShell[dv]; ok && v != "" {
338+
envVars = append(envVars, dv+"="+v)
339+
}
340+
}
290341
return envVars
291342
}
292343

internal/shellwrap/shellwrap.go

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,132 @@ func resetLoginShellPathCacheForTest() {
437437
loginShellPathVal = ""
438438
}
439439

440+
// --- Login-shell env-var capture -----------------------------------------
441+
442+
// loginShellEnv* markers bracket the captured values inside the login shell's
443+
// stdout so banner/motd/rc output (oh-my-zsh, direnv, etc.) does not leak into
444+
// the parsed result. Values are separated by ASCII Unit Separator (0x1F), which
445+
// cannot appear in a docker connection pointer (host URL, context, path).
446+
const (
447+
loginShellEnvMarkerBegin = "__MCPPROXY_LOGIN_ENV_BEGIN__"
448+
loginShellEnvMarkerEnd = "__MCPPROXY_LOGIN_ENV_END__"
449+
loginShellEnvFieldSep = "\x1f"
450+
)
451+
452+
var (
453+
loginShellEnvMu sync.Mutex
454+
loginShellEnvVals = map[string]string{} // captured non-empty values
455+
loginShellEnvTried = map[string]bool{} // keys already attempted (incl. empty/unset)
456+
)
457+
458+
// LoginShellEnvVars returns the values of the named environment variables as
459+
// seen by the user's interactive login shell (`<shell> -l -c …`). Each key is
460+
// captured at most once per process and cached; only variables with a non-empty
461+
// value are present in the returned map.
462+
//
463+
// Why this exists (mirrors LoginShellPATH): a macOS App Bundle / LaunchAgent
464+
// inherits a minimal os.Environ() that omits variables the user exports in
465+
// their rc files. For docker isolation that means DOCKER_HOST / DOCKER_CONTEXT
466+
// (Colima, rootless, remote daemons) are invisible to a direct-exec spawn even
467+
// though `docker` works fine in the user's terminal. Capturing them from the
468+
// login shell restores parity without re-introducing the brittle login-shell
469+
// command wrap. Returns an empty map on Windows or when capture fails.
470+
func LoginShellEnvVars(logger *zap.Logger, keys []string) map[string]string {
471+
out := make(map[string]string, len(keys))
472+
if runtime.GOOS == osWindows || len(keys) == 0 {
473+
return out
474+
}
475+
476+
loginShellEnvMu.Lock()
477+
defer loginShellEnvMu.Unlock()
478+
479+
var missing []string
480+
for _, k := range keys {
481+
if !loginShellEnvTried[k] {
482+
missing = append(missing, k)
483+
}
484+
}
485+
if len(missing) > 0 {
486+
captured := captureLoginShellEnv(logger, missing)
487+
for _, k := range missing {
488+
loginShellEnvTried[k] = true
489+
if v, ok := captured[k]; ok && v != "" {
490+
loginShellEnvVals[k] = v
491+
}
492+
}
493+
}
494+
495+
for _, k := range keys {
496+
if v, ok := loginShellEnvVals[k]; ok && v != "" {
497+
out[k] = v
498+
}
499+
}
500+
return out
501+
}
502+
503+
// captureLoginShellEnv runs one `<shell> -l -c` invocation that prints the
504+
// requested variables (bracketed by markers, separated by 0x1F) and parses the
505+
// result. keys are fixed internal constants (never user input).
506+
func captureLoginShellEnv(logger *zap.Logger, keys []string) map[string]string {
507+
result := make(map[string]string, len(keys))
508+
shell := resolveLoginShell()
509+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
510+
defer cancel()
511+
512+
var b strings.Builder
513+
b.WriteString("printf %s '" + loginShellEnvMarkerBegin + "'; ")
514+
for _, k := range keys {
515+
// \037 (octal 0x1F) is the field separator; printf interprets it.
516+
b.WriteString("printf '" + k + "=%s\\037' \"$" + k + "\"; ")
517+
}
518+
b.WriteString("printf %s '" + loginShellEnvMarkerEnd + "'")
519+
520+
cmd := exec.CommandContext(ctx, shell, "-l", "-c", b.String())
521+
out, err := cmd.Output()
522+
if err != nil {
523+
if logger != nil {
524+
logger.Debug("shellwrap: login-shell env capture failed",
525+
zap.String("shell", shell), zap.Error(err))
526+
}
527+
return result
528+
}
529+
530+
body := extractBetweenMarkers(string(out), loginShellEnvMarkerBegin, loginShellEnvMarkerEnd)
531+
for _, field := range strings.Split(body, loginShellEnvFieldSep) {
532+
eq := strings.IndexByte(field, '=')
533+
if eq <= 0 {
534+
continue
535+
}
536+
if val := field[eq+1:]; val != "" {
537+
result[field[:eq]] = val
538+
}
539+
}
540+
return result
541+
}
542+
543+
// extractBetweenMarkers returns the substring of s between begin and end, or ""
544+
// if either marker is absent.
545+
func extractBetweenMarkers(s, begin, end string) string {
546+
i := strings.Index(s, begin)
547+
if i < 0 {
548+
return ""
549+
}
550+
rest := s[i+len(begin):]
551+
j := strings.Index(rest, end)
552+
if j < 0 {
553+
return ""
554+
}
555+
return rest[:j]
556+
}
557+
558+
// resetLoginShellEnvCacheForTest is only referenced from shellwrap_test.go.
559+
func resetLoginShellEnvCacheForTest() {
560+
loginShellEnvMu.Lock()
561+
defer loginShellEnvMu.Unlock()
562+
loginShellEnvVals = map[string]string{}
563+
loginShellEnvTried = map[string]bool{}
564+
}
565+
440566
// --- Minimal environment for scanner subprocesses ------------------------
441567

442568
// MinimalEnv returns a minimal, allow-listed environment suitable for

internal/shellwrap/shellwrap_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,43 @@ func TestMergePathUnique(t *testing.T) {
349349
}
350350
}
351351

352+
// TestLoginShellEnvVars_CapturesFromShellOnly is the MCP-2744 round-2
353+
// regression guard: docker daemon-access vars that the user exports ONLY in
354+
// their login shell (absent from os.Environ — the macOS tray/LaunchAgent case)
355+
// must be captured from the login shell. The fake shell exports the vars
356+
// internally so they are NOT inherited from the parent process environment.
357+
func TestLoginShellEnvVars_CapturesFromShellOnly(t *testing.T) {
358+
if runtime.GOOS == "windows" {
359+
t.Skip("unix-only: login-shell env capture is a no-op on Windows")
360+
}
361+
resetLoginShellEnvCacheForTest()
362+
t.Cleanup(resetLoginShellEnvCacheForTest)
363+
364+
dir := t.TempDir()
365+
// Fake login shell that exports docker vars itself (so they exist only in
366+
// the shell, not in the parent os.Environ) and then evaluates the -c script.
367+
script := `#!/bin/sh
368+
export DOCKER_HOST=tcp://shell-only:2375
369+
export DOCKER_CONTEXT=colima
370+
while [ $# -gt 0 ]; do
371+
case "$1" in
372+
-l|--login) shift ;;
373+
-c) shift; eval "$1"; shift ;;
374+
*) shift ;;
375+
esac
376+
done
377+
`
378+
p := filepath.Join(dir, "fake-login-shell")
379+
require.NoError(t, os.WriteFile(p, []byte(script), 0o755))
380+
t.Setenv("SHELL", p)
381+
382+
got := LoginShellEnvVars(nil, []string{"DOCKER_HOST", "DOCKER_CONTEXT", "DOCKER_CONFIG"})
383+
assert.Equal(t, "tcp://shell-only:2375", got["DOCKER_HOST"])
384+
assert.Equal(t, "colima", got["DOCKER_CONTEXT"])
385+
_, hasEmpty := got["DOCKER_CONFIG"]
386+
assert.False(t, hasEmpty, "an unset var must be omitted from the result map, got: %v", got)
387+
}
388+
352389
// writeFakeShell writes a POSIX shell script at <dir>/fake-shell that
353390
// ignores its arguments and echoes `wantPath` on stdout. It returns the
354391
// absolute path to the script so tests can point $SHELL at it.

0 commit comments

Comments
 (0)