Skip to content

Commit 5f0f861

Browse files
fix(shellwrap): drop proxy vars, fix launch gate for pre-seeded PATH
Codex round-3 scope reduction: - Remove HTTP_PROXY/HTTPS_PROXY/NO_PROXY from hydration and from secureenv DefaultEnvConfig allow-list. Proxy forwarding to every stdio upstream is out of scope; filed as a separate opt-in follow-up. - Fix the launch gate: hydration now triggers when PATH looks launchd- minimal OR any DOCKER_* curated var is absent. A GUI launcher can pre-seed PATH via /etc/paths yet still not export DOCKER_HOST from rc files, so PATH-comprehensiveness alone is insufficient. - PATH-merge stays gated on pathIsMinimal only — a comprehensive PATH is not widened even when the DOCKER_* gate fires. Tests: - Rename GateNoOpOnComprehensivePath → GateNoOpOnComprehensivePathAndAllDockerPresent (now requires both conditions, so all 5 DOCKER_* vars are pre-set) - Add ComprehensivePathStillHydratesDockerIfMissing — verifies the new secondary gate: comprehensive PATH, missing DOCKER_HOST → hydrates curated vars but leaves PATH unmodified - Drop PreservesIntentionallyEmptyVar and ProxyCaseAliasingPreservesOperatorIntent (proxy vars are no longer hydrated) - Update MinimalPathHydratesCuratedSet to assert proxy vars are NOT imported Co-Authored-By: Paperclip <noreply@paperclip.ing>
1 parent 3983b7e commit 5f0f861

4 files changed

Lines changed: 121 additions & 155 deletions

File tree

internal/secureenv/launchd_path_test.go

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -215,12 +215,12 @@ func TestLaunchdMinimalPath_AlreadyComprehensive(t *testing.T) {
215215
"comprehensive PATH must be returned unchanged — terminal-launched processes should not be polluted by login-shell capture")
216216
}
217217

218-
// TestBuildSecureEnvironment_AllowsHydratedDockerAndProxyVars verifies the
219-
// allow-list extension for MCP-2751: once shellwrap.HydrateFromLoginShell has
220-
// placed curated DOCKER_*/proxy vars into the process env, the default
221-
// allow-list must pass them through to spawned upstreams — while genuine
222-
// secrets remain filtered out.
223-
func TestBuildSecureEnvironment_AllowsHydratedDockerAndProxyVars(t *testing.T) {
218+
// TestBuildSecureEnvironment_AllowsHydratedDockerVars verifies the allow-list
219+
// extension for MCP-2751: once shellwrap.HydrateFromLoginShell has placed
220+
// curated DOCKER_*/tool-home vars into the process env, the default allow-list
221+
// must pass them through to spawned upstreams — while secrets remain filtered.
222+
// Proxy vars are intentionally excluded from the allow-list (separate follow-up).
223+
func TestBuildSecureEnvironment_AllowsHydratedDockerVars(t *testing.T) {
224224
if runtime.GOOS == "windows" {
225225
t.Skip("uses unix PATH semantics")
226226
}
@@ -243,21 +243,22 @@ func TestBuildSecureEnvironment_AllowsHydratedDockerAndProxyVars(t *testing.T) {
243243
os.Setenv("HOME", "/tmp/test-home")
244244
os.Setenv("DOCKER_HOST", "unix:///Users/me/.docker/run/docker.sock")
245245
os.Setenv("DOCKER_CONTEXT", "desktop-linux")
246-
os.Setenv("HTTPS_PROXY", "http://proxy.corp:8080")
247246
os.Setenv("HOMEBREW_PREFIX", "/opt/homebrew")
248247
// Genuine secrets that must still be filtered out by the allow-list.
249248
os.Setenv("AWS_ACCESS_KEY_ID", "AKIA_test_dummy_value_00000000")
250249
os.Setenv("GITHUB_TOKEN", "ghp_dummy_test_token_1234567890abcdef")
250+
// Proxy vars are excluded from the allow-list.
251+
os.Setenv("HTTPS_PROXY", "http://proxy.corp:8080")
251252

252253
manager := NewManager(DefaultEnvConfig())
253254
joined := strings.Join(manager.BuildSecureEnvironment(), "\n")
254255

255256
assert.Contains(t, joined, "DOCKER_HOST=unix:///Users/me/.docker/run/docker.sock",
256257
"curated DOCKER_HOST must survive the allow-list")
257258
assert.Contains(t, joined, "DOCKER_CONTEXT=desktop-linux")
258-
assert.Contains(t, joined, "HTTPS_PROXY=http://proxy.corp:8080")
259259
assert.Contains(t, joined, "HOMEBREW_PREFIX=/opt/homebrew")
260260

261+
assert.NotContains(t, joined, "HTTPS_PROXY", "proxy vars must be filtered (out of scope)")
261262
assert.NotContains(t, joined, "AWS_ACCESS_KEY_ID", "secrets must still be filtered out")
262263
assert.NotContains(t, joined, "GITHUB_TOKEN", "secrets must still be filtered out")
263264
}

internal/secureenv/manager.go

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -86,15 +86,13 @@ func DefaultEnvConfig() *EnvConfig {
8686
}
8787
allowedVars = append(allowedVars, localeVars...)
8888

89-
// Add container / proxy / tool-home passthrough variables (MCP-2751).
90-
// These are NOT secrets; they mirror the curated set hydrated from the
91-
// login shell by shellwrap.HydrateFromLoginShell so the now-present vars
92-
// survive this allow-list filter and reach upstream stdio/docker spawns.
93-
// Matching is case-insensitive (see isKeyAllowed), so the lowercase proxy
94-
// spellings are covered by the canonical entries.
89+
// Add container / tool-home passthrough variables (MCP-2751). These are NOT
90+
// secrets; they mirror the curated set hydrated by shellwrap.HydrateFromLoginShell
91+
// so the now-present vars survive this allow-list filter and reach upstream
92+
// stdio/docker spawns. Proxy vars (HTTP_PROXY etc.) are intentionally excluded
93+
// — proxy forwarding is a separate opt-in concern.
9594
allowedVars = append(allowedVars,
9695
"DOCKER_HOST", "DOCKER_CONTEXT", "DOCKER_CONFIG", "DOCKER_CERT_PATH", "DOCKER_TLS_VERIFY",
97-
"HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY",
9896
"NVM_DIR", "ASDF_DIR", "PYENV_ROOT", "VOLTA_HOME", "HOMEBREW_PREFIX", "COLIMA_HOME",
9997
)
10098

internal/shellwrap/hydrate.go

Lines changed: 49 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -162,59 +162,46 @@ func resetLoginShellEnvCacheForTest() {
162162
// Linux CI runners. Defaults to the real GOOS.
163163
var hydrationGOOS = runtime.GOOS
164164

165-
// curatedSingleKeys is the allow-list of single-spelling environment variables
166-
// hydrated from the login shell. It deliberately EXCLUDES secrets (AWS_*,
167-
// GITHUB_TOKEN, ANTHROPIC_API_KEY, …): wholesale import would pull developer
168-
// credentials into the daemon and every MCP subprocess. We import only PATH
169-
// (handled separately), container, and tool-home configuration. HOME / USER /
170-
// SHELL are never touched. Proxy vars are handled separately (proxyVarPairs)
171-
// because they are case-insensitive to clients.
165+
// curatedSingleKeys is the allow-list of environment variables hydrated from
166+
// the login shell. It deliberately EXCLUDES secrets (AWS_*, GITHUB_TOKEN,
167+
// ANTHROPIC_API_KEY, …) and proxy vars (HTTP_PROXY/HTTPS_PROXY — proxy
168+
// forwarding is out of scope here; filed as a separate opt-in follow-up).
169+
// HOME / USER / SHELL are never touched.
172170
var curatedSingleKeys = []string{
173171
// Docker / container engine selection (supersedes the per-spawn capture in #699).
174172
"DOCKER_HOST", "DOCKER_CONTEXT", "DOCKER_CONFIG", "DOCKER_CERT_PATH", "DOCKER_TLS_VERIFY",
175173
// Tool-home roots that node/python version managers rely on.
176174
"NVM_DIR", "ASDF_DIR", "PYENV_ROOT", "VOLTA_HOME", "HOMEBREW_PREFIX", "COLIMA_HOME",
177175
}
178176

179-
// proxyVarPairs groups each logical proxy variable's UPPER/lower spellings.
180-
// HTTP clients (Go's net/http, curl, docker, …) honor EITHER spelling, so the
181-
// two spellings are one logical var: if the operator set either one (even to
182-
// empty, to disable an inherited proxy), hydrating the other spelling from the
183-
// login shell would override that intent. We therefore skip BOTH spellings when
184-
// EITHER is already present.
185-
var proxyVarPairs = [][2]string{
186-
{"HTTP_PROXY", "http_proxy"},
187-
{"HTTPS_PROXY", "https_proxy"},
188-
{"NO_PROXY", "no_proxy"},
189-
}
190-
191177
// HydrateFromLoginShell performs a one-time, allow-listed merge of the user's
192178
// login-shell environment into the current process environment via os.Setenv,
193179
// so that every downstream spawn path (docker lifecycle, stdio servers,
194180
// uvx/npx, secureenv.BuildSecureEnvironment, ResolveDockerPath) inherits a
195181
// correct PATH and curated vars with no call-site changes.
196182
//
197-
// It is a no-op (applied=false) unless mcpproxy was launched into a degraded
198-
// environment: macOS only, AND the ambient PATH looks launchd-minimal (lacks
199-
// /usr/local/bin and /opt/homebrew/bin). Terminal launches are left untouched.
183+
// Hydration is triggered on macOS when:
184+
// - the ambient PATH looks launchd-minimal (lacks /usr/local/bin or
185+
// /opt/homebrew/bin), OR
186+
// - any DOCKER_* curated var is absent (a GUI launcher may pre-seed PATH
187+
// via /etc/paths yet still not export DOCKER_HOST from rc files).
200188
//
201-
// PATH is merged login-first (enriching, never shrinking). Curated keys are
202-
// applied set-if-unset only — any operator-set value (including an explicitly
203-
// empty value used to disable an inherited one) is never clobbered. The
204-
// returned snapshot maps each applied key to its value for diagnostics; this
205-
// function never logs values (key names + lengths only).
189+
// PATH is merged login-first (enriching, never shrinking) only when the PATH
190+
// is launchd-minimal. Curated keys are applied set-if-unset whenever hydration
191+
// triggers. The returned snapshot maps each applied key to its value for
192+
// diagnostics; this function never logs values (key names + lengths only).
206193
func HydrateFromLoginShell(logger *zap.Logger) (applied bool, snapshot map[string]string) {
207194
snapshot = make(map[string]string)
208195

209196
if hydrationGOOS != osDarwin {
210-
// Linux LaunchAgent-equivalent is systemd (Environment=/EnvironmentFile=),
211-
// not shell sourcing. Windows uses the registry PATH and has no login
212-
// shell. Gate off both.
197+
// Linux: systemd Environment=/EnvironmentFile=. Windows: registry PATH.
198+
// Neither uses login-shell sourcing.
213199
return false, snapshot
214200
}
215-
if !looksLikeLaunchdMinimalPath(os.Getenv("PATH")) {
216-
// Healthy interactive launch — preserve the zero-cost / no-pollution
217-
// guarantee (mirrors secureenv TestLaunchdMinimalPath_AlreadyComprehensive).
201+
202+
pathIsMinimal := looksLikeLaunchdMinimalPath(os.Getenv("PATH"))
203+
if !pathIsMinimal && !anyDockerVarMissing() {
204+
// Healthy interactive launch with all docker vars present — no-op.
218205
return false, snapshot
219206
}
220207

@@ -225,38 +212,26 @@ func HydrateFromLoginShell(logger *zap.Logger) (applied bool, snapshot map[strin
225212

226213
sep := string(os.PathListSeparator)
227214

228-
// PATH: always merge login-first so docker / uvx / npx and their credential
229-
// helpers resolve. Enrich only — never shrink an already-broader PATH.
230-
if loginPath := env["PATH"]; loginPath != "" {
231-
current := os.Getenv("PATH")
232-
merged := mergePathUnique(loginPath, current, sep)
233-
if merged != current {
234-
_ = os.Setenv("PATH", merged)
235-
snapshot["PATH"] = merged
215+
// PATH: merge login-first so docker / uvx / npx resolve correctly.
216+
// Only merge when PATH looks launchd-minimal — a comprehensive PATH is left
217+
// untouched even if curated vars triggered hydration.
218+
if pathIsMinimal {
219+
if loginPath := env["PATH"]; loginPath != "" {
220+
current := os.Getenv("PATH")
221+
merged := mergePathUnique(loginPath, current, sep)
222+
if merged != current {
223+
_ = os.Setenv("PATH", merged)
224+
snapshot["PATH"] = merged
225+
}
236226
}
237227
}
238228

239-
// Single-spelling curated keys: set-if-unset only, never clobber an
240-
// operator-set value (LookupEnv, so an explicit empty value is preserved).
229+
// Curated keys: set-if-unset only, never clobber an operator-set value
230+
// (LookupEnv so an explicit empty value is preserved).
241231
for _, key := range curatedSingleKeys {
242232
setEnvIfUnset(env, key, snapshot)
243233
}
244234

245-
// Proxy trio: alias-aware. If EITHER spelling is already present (even
246-
// intentionally empty, e.g. `https_proxy=` to disable), skip hydrating BOTH
247-
// — clients honor either spelling so importing the opposite case would
248-
// override operator intent.
249-
for _, pair := range proxyVarPairs {
250-
if _, up := os.LookupEnv(pair[0]); up {
251-
continue
252-
}
253-
if _, lo := os.LookupEnv(pair[1]); lo {
254-
continue
255-
}
256-
setEnvIfUnset(env, pair[0], snapshot)
257-
setEnvIfUnset(env, pair[1], snapshot)
258-
}
259-
260235
if len(snapshot) == 0 {
261236
return false, snapshot
262237
}
@@ -275,6 +250,22 @@ func HydrateFromLoginShell(logger *zap.Logger) (applied bool, snapshot map[strin
275250
return true, snapshot
276251
}
277252

253+
// anyDockerVarMissing reports whether any DOCKER_* entry from curatedSingleKeys
254+
// is absent from the current process environment. Used as a secondary hydration
255+
// trigger when PATH is already comprehensive but Docker connectivity vars are
256+
// absent (common when a GUI launcher pre-seeds PATH via /etc/paths but rc files
257+
// set DOCKER_HOST only in login-shell context).
258+
func anyDockerVarMissing() bool {
259+
for _, key := range curatedSingleKeys {
260+
if strings.HasPrefix(key, "DOCKER_") {
261+
if _, present := os.LookupEnv(key); !present {
262+
return true
263+
}
264+
}
265+
}
266+
return false
267+
}
268+
278269
// setEnvIfUnset hydrates a single key from the captured login-shell env when it
279270
// provides a non-empty value and the key is not already present in the process
280271
// env. Records applied keys in snapshot. Uses LookupEnv so an explicitly

0 commit comments

Comments
 (0)