From 11f06e607a03a2c49680e18c8a80b4430aee54d7 Mon Sep 17 00:00:00 2001 From: Felix-299 Date: Fri, 2 Oct 2026 01:16:08 -0400 Subject: [PATCH 1/3] fix(mcpl): preserve child CA bundle and proxy settings Extend the operating environment allowlist with Python requests/curl CA bundles and uppercase/lowercase HTTP(S) proxy and bypass variables. These settings restore internal CA trust and egress access without opting into full host environment inheritance. Declared server values still override host defaults. Cover every added key, credential-bearing proxy URLs, empty/undefined values, declared overrides, retained secret scrubbing, and a real stdio child. The focused Node/tsx regression was 3 pass / 3 fail on main and is 6 pass / 0 fail with the change. Document that proxy credentials are included and the allowlist is not a process isolation boundary. Full compiled-suite differential is being collected for the PR. Co-Authored-By: GPT-6 Astra --- CHANGELOG.md | 4 ++++ src/mcpl/transport.ts | 9 ++++++-- src/mcpl/types.ts | 4 +++- test/mcpl-child-env.test.ts | 43 +++++++++++++++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b9a30b4c..31ae4c89c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ Releases up to and including 0.7.3 predate this file; for their contents see ## Unreleased +### Fixed + +- Stdio MCPL children inherit `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, and uppercase/lowercase `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` so Python/curl CA trust and proxy-based egress survive the child environment allowlist (#191). Declared server `env` still overrides host values. Proxy URLs can carry credentials; the allowlist limits accidental inheritance rather than isolating a hostile child. + ## 0.19.0 — 2026-09-28 ### Added diff --git a/src/mcpl/transport.ts b/src/mcpl/transport.ts index 2fc8a523e..0059bdc07 100644 --- a/src/mcpl/transport.ts +++ b/src/mcpl/transport.ts @@ -104,10 +104,12 @@ export abstract class McplTransport extends EventEmitter { /** * Host variables a stdio child gets by default: just enough for an ordinary * process to run (find binaries, a home dir, temp dir, locale, TLS roots, - * a display for GUI helpers). Everything else in the host env — notably API + * network proxies, a display for GUI helpers). Everything else in the host env — notably API * keys and bot tokens — is NOT passed on; a server that needs a variable must * declare it in its `env` (recipes can `${VAR}`-substitute from .env), or set - * `inheritEnv: true` to get the whole host env as before. + * `inheritEnv: true` to get the whole host env as before. Proxy URLs may include + * credentials. This limits accidental inheritance, not access by a hostile + * same-user child process; it is not a process isolation boundary. */ export const CHILD_ENV_ALLOWLIST = [ 'PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL', 'TERM', 'LANG', 'TZ', @@ -115,6 +117,9 @@ export const CHILD_ENV_ALLOWLIST = [ 'DISPLAY', 'WAYLAND_DISPLAY', 'XDG_RUNTIME_DIR', 'XDG_CONFIG_HOME', 'XDG_CACHE_HOME', 'XDG_DATA_HOME', 'SSL_CERT_FILE', 'SSL_CERT_DIR', 'NODE_EXTRA_CA_CERTS', + 'REQUESTS_CA_BUNDLE', 'CURL_CA_BUNDLE', + 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', + 'http_proxy', 'https_proxy', 'no_proxy', '__CF_USER_TEXT_ENCODING', // Windows equivalents, harmless elsewhere. 'SYSTEMROOT', 'WINDIR', 'COMSPEC', 'PATHEXT', 'APPDATA', 'LOCALAPPDATA', 'USERPROFILE', diff --git a/src/mcpl/types.ts b/src/mcpl/types.ts index c080af76d..a5732e1d6 100644 --- a/src/mcpl/types.ts +++ b/src/mcpl/types.ts @@ -229,7 +229,9 @@ export interface McplServerConfig { /** * Environment variables for the child process. Stdio children do NOT inherit * the host environment wholesale: they get a small allowlist (PATH, HOME, - * LANG, LC_*, TMPDIR, ... — see CHILD_ENV_ALLOWLIST) plus exactly these. + * LANG, LC_*, TMPDIR, TLS CA bundles, and HTTP(S) proxy settings — see + * CHILD_ENV_ALLOWLIST) plus exactly these. Proxy URLs can contain credentials. + * This limits accidental inheritance; it does not isolate a hostile child. */ env?: Record; diff --git a/test/mcpl-child-env.test.ts b/test/mcpl-child-env.test.ts index 9458a5af7..14b3bc07a 100644 --- a/test/mcpl-child-env.test.ts +++ b/test/mcpl-child-env.test.ts @@ -30,6 +30,39 @@ test('drops host secrets, keeps operating vars and LC_*', () => { }); }); +test('keeps CA bundles and uppercase/lowercase proxy configuration', () => { + const networkEnv = { + REQUESTS_CA_BUNDLE: '/etc/ssl/certs/internal.pem', + CURL_CA_BUNDLE: '/etc/ssl/certs/curl.pem', + HTTP_PROXY: 'http://user:password@proxy.example:8080', + HTTPS_PROXY: 'http://proxy.example:8443', + NO_PROXY: 'localhost,127.0.0.1,.internal', + http_proxy: 'http://lowercase.example:8080', + https_proxy: 'http://lowercase.example:8443', + no_proxy: 'localhost,.lowercase.internal', + }; + const env = buildChildEnv({}, { ...HOST_ENV, ...networkEnv }); + for (const [key, value] of Object.entries(networkEnv)) { + assert.equal(env[key], value, key); + } + assert.equal(env.OPENAI_API_KEY, undefined); + assert.equal(env.DISCORD_TOKEN, undefined); + assert.equal(env.ANTHROPIC_AUTH_TOKEN, undefined); +}); + +test('declared network settings override the host and empty values are preserved', () => { + const env = buildChildEnv( + { env: { REQUESTS_CA_BUNDLE: '/server/ca.pem', HTTPS_PROXY: '', NO_PROXY: '*' } }, + { REQUESTS_CA_BUNDLE: '/host/ca.pem', HTTPS_PROXY: 'http://proxy.example', NO_PROXY: '', http_proxy: '', CURL_CA_BUNDLE: undefined }, + ); + assert.deepEqual(env, { + REQUESTS_CA_BUNDLE: '/server/ca.pem', + HTTPS_PROXY: '', + NO_PROXY: '*', + http_proxy: '', + }); +}); + test('declared env is passed through and wins over allowlisted host vars', () => { const env = buildChildEnv( { env: { DISCORD_TOKEN: 'declared-token', PATH: '/opt/bin' } }, @@ -50,7 +83,11 @@ test('inheritEnv: true passes the whole host env, declared env on top', () => { test('a real spawned stdio child sees only allowlist + declared env', async () => { const secretKey = 'MCPL_ENV_TEST_HOST_SECRET'; + const previousCaBundle = process.env.REQUESTS_CA_BUNDLE; + const previousProxy = process.env.HTTPS_PROXY; process.env[secretKey] = 'must-not-leak'; + process.env.REQUESTS_CA_BUNDLE = '/host/internal-ca.pem'; + process.env.HTTPS_PROXY = 'http://proxy.example:8443'; try { const transport = StdioTransport.spawn({ id: 'env-probe', @@ -66,9 +103,15 @@ test('a real spawned stdio child sees only allowlist + declared env', async () = const childEnv = JSON.parse(line) as Record; assert.equal(childEnv[secretKey], undefined); assert.equal(childEnv.DECLARED_VAR, 'declared-value'); + assert.equal(childEnv.REQUESTS_CA_BUNDLE, '/host/internal-ca.pem'); + assert.equal(childEnv.HTTPS_PROXY, 'http://proxy.example:8443'); if (process.env.PATH) assert.equal(childEnv.PATH, process.env.PATH); if (process.env.HOME) assert.equal(childEnv.HOME, process.env.HOME); } finally { delete process.env[secretKey]; + if (previousCaBundle === undefined) delete process.env.REQUESTS_CA_BUNDLE; + else process.env.REQUESTS_CA_BUNDLE = previousCaBundle; + if (previousProxy === undefined) delete process.env.HTTPS_PROXY; + else process.env.HTTPS_PROXY = previousProxy; } }); From 22b148329afb7676bc177bd81f88a83059ddb30d Mon Sep 17 00:00:00 2001 From: Felix-299 Date: Fri, 2 Oct 2026 02:03:39 -0400 Subject: [PATCH 2/3] fix(mcpl): honor Windows child environment overrides across casing The upstream PR #205 review identified that host HTTPS_PROXY and declared https_proxy could both survive the object spread. Node sorts and deduplicates Windows environment keys before spawn, so the host value could defeat a declared empty override and retain proxy credentials. Match the Windows operating allowlist case-insensitively and merge host/default and declared sources separately under a case-folded name. Keep only the last spelling for each Windows variable, with declared values and empty strings taking precedence in both filtered and inheritEnv modes. POSIX remains case-sensitive. The optional platform argument and mixed-case allowlist matching compose with PR #193; that PR does not yet resolve cross-case override merging. Add table coverage for both casing directions of proxy/CA overrides in both inheritance modes, native mixed-case operating names, duplicate declared spellings, and POSIX distinctions. Focused tests were 7 pass / 4 fail on 11f06e6 and are 11 pass / 0 fail after the correction. Build and diff checks pass; full-suite and independent review receipts follow. Windows merge behavior is exercised with an explicit platform selector on Linux, not a native Windows process. Co-Authored-By: GPT-6 Astra --- CHANGELOG.md | 2 +- src/mcpl/transport.ts | 37 ++++++++++++++++++++----- test/mcpl-child-env.test.ts | 54 ++++++++++++++++++++++++++++++++++++- 3 files changed, 84 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31ae4c89c..b597de8a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ Releases up to and including 0.7.3 predate this file; for their contents see ### Fixed -- Stdio MCPL children inherit `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, and uppercase/lowercase `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` so Python/curl CA trust and proxy-based egress survive the child environment allowlist (#191). Declared server `env` still overrides host values. Proxy URLs can carry credentials; the allowlist limits accidental inheritance rather than isolating a hostile child. +- Stdio MCPL children inherit `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, and uppercase/lowercase `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` so Python/curl CA trust and proxy-based egress survive the child environment allowlist (#191). Declared server `env` still overrides host values, including differently cased keys and empty values on Windows; POSIX names stay case-sensitive. Proxy URLs can carry credentials; the allowlist limits accidental inheritance rather than isolating a hostile child. ## 0.19.0 — 2026-09-28 diff --git a/src/mcpl/transport.ts b/src/mcpl/transport.ts index 0059bdc07..cf1d6f24b 100644 --- a/src/mcpl/transport.ts +++ b/src/mcpl/transport.ts @@ -126,18 +126,41 @@ export const CHILD_ENV_ALLOWLIST = [ ]; /** Environment for a stdio child: allowlisted host vars (+ LC_*), then the - * server's declared `env` on top. `inheritEnv: true` restores full inheritance. */ + * server's declared `env` on top. Windows names merge case-insensitively; + * POSIX names remain case-sensitive. `inheritEnv: true` restores full inheritance. */ export function buildChildEnv( config: Pick, hostEnv: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, ): NodeJS.ProcessEnv { - if (config.inheritEnv) return { ...hostEnv, ...config.env }; - const env: NodeJS.ProcessEnv = {}; - for (const [key, value] of Object.entries(hostEnv)) { - if (value === undefined) continue; - if (CHILD_ENV_ALLOWLIST.includes(key) || key.startsWith('LC_')) env[key] = value; + const win = platform === 'win32'; + const env: NodeJS.ProcessEnv = config.inheritEnv ? { ...hostEnv } : {}; + if (!config.inheritEnv) { + for (const [key, value] of Object.entries(hostEnv)) { + if (value === undefined) continue; + // Windows commonly enumerates Path/SystemRoot rather than PATH/SYSTEMROOT. + const probe = win ? key.toUpperCase() : key; + if (CHILD_ENV_ALLOWLIST.includes(probe) || probe.startsWith('LC_')) env[key] = value; + } + } + if (!win) return { ...env, ...config.env }; + + // Windows spawn folds environment names case-insensitively and may choose + // an earlier host spelling over a differently cased declared override. + // Merge the sources separately so every declared value (including '') wins, + // leaving exactly one spelling for each Windows variable. + const merged: NodeJS.ProcessEnv = {}; + const spellings = new Map(); + for (const source of [env, config.env ?? {}]) { + for (const [key, value] of Object.entries(source)) { + const canonical = key.toUpperCase(); + const previous = spellings.get(canonical); + if (previous !== undefined && previous !== key) delete merged[previous]; + merged[key] = value; + spellings.set(canonical, key); + } } - return { ...env, ...config.env }; + return merged; } export class StdioTransport extends McplTransport { diff --git a/test/mcpl-child-env.test.ts b/test/mcpl-child-env.test.ts index 14b3bc07a..2c83a30be 100644 --- a/test/mcpl-child-env.test.ts +++ b/test/mcpl-child-env.test.ts @@ -41,7 +41,7 @@ test('keeps CA bundles and uppercase/lowercase proxy configuration', () => { https_proxy: 'http://lowercase.example:8443', no_proxy: 'localhost,.lowercase.internal', }; - const env = buildChildEnv({}, { ...HOST_ENV, ...networkEnv }); + const env = buildChildEnv({}, { ...HOST_ENV, ...networkEnv }, 'linux'); for (const [key, value] of Object.entries(networkEnv)) { assert.equal(env[key], value, key); } @@ -115,3 +115,55 @@ test('a real spawned stdio child sees only allowlist + declared env', async () = else process.env.HTTPS_PROXY = previousProxy; } }); + +for (const inheritEnv of [false, true]) { + test(`win32: declared proxy/CA overrides win across case variants (inheritEnv=${inheritEnv})`, () => { + for (const name of ['HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'REQUESTS_CA_BUNDLE', 'CURL_CA_BUNDLE']) { + for (const [hostKey, declaredKey] of [[name, name.toLowerCase()], [name.toLowerCase(), name]]) { + const env = buildChildEnv( + { inheritEnv, env: { [declaredKey!]: '' } }, + { [hostKey!]: 'http://user:password@host-proxy.example', OPENAI_API_KEY: 'host-secret' }, + 'win32', + ); + assert.deepEqual( + Object.keys(env).filter((key) => key.toUpperCase() === name), + [declaredKey], + 'only the declared spelling may reach Windows spawn', + ); + assert.equal(env[declaredKey!], '', name + ' must remain explicitly disabled'); + assert.equal(env.OPENAI_API_KEY, inheritEnv ? 'host-secret' : undefined); + } + } + }); +} + +test('win32: operating allowlist matches native mixed-case spellings', () => { + const env = buildChildEnv({}, { + Path: 'C:\\Windows', SystemRoot: 'C:\\Windows', ComSpec: 'C:\\Windows\\System32\\cmd.exe', + Https_Proxy: 'http://proxy.example', Requests_CA_Bundle: 'C:\\internal.pem', + lc_ctype: 'UTF-8', OpenAI_Api_Key: 'host-secret', + }, 'win32'); + assert.deepEqual(env, { + Path: 'C:\\Windows', SystemRoot: 'C:\\Windows', ComSpec: 'C:\\Windows\\System32\\cmd.exe', + Https_Proxy: 'http://proxy.example', Requests_CA_Bundle: 'C:\\internal.pem', lc_ctype: 'UTF-8', + }); +}); + +test('win32: later declared spellings replace earlier ones without duplicate names', () => { + const env = buildChildEnv({ env: { HTTPS_PROXY: 'http://first.example', https_proxy: '' } }, { + HTTPS_PROXY: 'http://host.example', https_proxy: 'http://other-host.example', + }, 'win32'); + assert.deepEqual(env, { https_proxy: '' }); +}); + +test('posix: differently cased host and declared keys remain distinct', () => { + for (const inheritEnv of [false, true]) { + const env = buildChildEnv({ inheritEnv, env: { https_proxy: '' } }, { + HTTPS_PROXY: 'http://host.example', Path: '/mixed-case', PATH: '/usr/bin', + }, 'linux'); + assert.equal(env.HTTPS_PROXY, 'http://host.example'); + assert.equal(env.https_proxy, ''); + assert.equal(env.PATH, '/usr/bin'); + assert.equal(env.Path, inheritEnv ? '/mixed-case' : undefined); + } +}); From a656cb3ec29558b8330bbb9e2817b1694c7eaa9c Mon Sep 17 00:00:00 2001 From: Felix-299 Date: Fri, 2 Oct 2026 14:59:41 -0400 Subject: [PATCH 3/3] fix(mcpl): match Windows duplicate precedence within each env source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Weft’s review of companion PR #211 exposed a compatibility distinction: declared environment values must override host values, but duplicate case-equivalent spellings inside either source should retain Node’s first-lexicographic selection. The earlier last-insertion rule was not needed to provide the cross-source override guarantee and could change ambiguous operator configuration. Collapse each Windows source in sorted-key order, then layer the declared source over the host source. Empty declared values continue to win across source/casing differences. POSIX remains case-sensitive. Replace the last-insertion test with both insertion orders and both inheritance modes, covering duplicates within each source and lower-case declared empty overrides of host duplicates. The changed focused file is 10 pass / 1 fail at 22b1483, then 11 pass / 0 fail; build and diff checks pass. Move only this PR’s note into the unique 205-child-network-env.fixed.md fragment, explicitly requested by coordinating reviewer Iris in Commons #20084. Iris independently endorsed the sorted-within-source/declared-over-host contract in #20081 and is aligning PR #211’s baseline injection with it. Current-main integration and combined review follow before push. Co-Authored-By: GPT-6 Astra --- CHANGELOG.md | 4 ---- changelog.d/205-child-network-env.fixed.md | 1 + src/mcpl/transport.ts | 12 ++++++++---- test/mcpl-child-env.test.ts | 18 +++++++++++++----- 4 files changed, 22 insertions(+), 13 deletions(-) create mode 100644 changelog.d/205-child-network-env.fixed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index b597de8a5..6b9a30b4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,10 +12,6 @@ Releases up to and including 0.7.3 predate this file; for their contents see ## Unreleased -### Fixed - -- Stdio MCPL children inherit `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, and uppercase/lowercase `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` so Python/curl CA trust and proxy-based egress survive the child environment allowlist (#191). Declared server `env` still overrides host values, including differently cased keys and empty values on Windows; POSIX names stay case-sensitive. Proxy URLs can carry credentials; the allowlist limits accidental inheritance rather than isolating a hostile child. - ## 0.19.0 — 2026-09-28 ### Added diff --git a/changelog.d/205-child-network-env.fixed.md b/changelog.d/205-child-network-env.fixed.md new file mode 100644 index 000000000..76e8c49f4 --- /dev/null +++ b/changelog.d/205-child-network-env.fixed.md @@ -0,0 +1 @@ +- Stdio MCPL children inherit `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, and uppercase/lowercase `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` so Python/curl CA trust and proxy-based egress survive the child environment allowlist (#191). Declared server `env` still overrides host values, including differently cased keys and empty values on Windows; POSIX names stay case-sensitive. Duplicate Windows spellings within one source use Node's first lexicographic spelling before declared values override host values. Proxy URLs can carry credentials; the allowlist limits accidental inheritance rather than isolating a hostile child. diff --git a/src/mcpl/transport.ts b/src/mcpl/transport.ts index cf1d6f24b..044249cc4 100644 --- a/src/mcpl/transport.ts +++ b/src/mcpl/transport.ts @@ -147,16 +147,20 @@ export function buildChildEnv( // Windows spawn folds environment names case-insensitively and may choose // an earlier host spelling over a differently cased declared override. - // Merge the sources separately so every declared value (including '') wins, - // leaving exactly one spelling for each Windows variable. + // Select Node's first lexicographic spelling within each source, then layer + // declared values (including '') over host values. Source precedence and + // duplicate-name resolution are separate decisions. const merged: NodeJS.ProcessEnv = {}; const spellings = new Map(); for (const source of [env, config.env ?? {}]) { - for (const [key, value] of Object.entries(source)) { + const seen = new Set(); + for (const key of Object.keys(source).sort()) { const canonical = key.toUpperCase(); + if (seen.has(canonical)) continue; + seen.add(canonical); const previous = spellings.get(canonical); if (previous !== undefined && previous !== key) delete merged[previous]; - merged[key] = value; + merged[key] = source[key]; spellings.set(canonical, key); } } diff --git a/test/mcpl-child-env.test.ts b/test/mcpl-child-env.test.ts index 2c83a30be..6773837a5 100644 --- a/test/mcpl-child-env.test.ts +++ b/test/mcpl-child-env.test.ts @@ -149,11 +149,19 @@ test('win32: operating allowlist matches native mixed-case spellings', () => { }); }); -test('win32: later declared spellings replace earlier ones without duplicate names', () => { - const env = buildChildEnv({ env: { HTTPS_PROXY: 'http://first.example', https_proxy: '' } }, { - HTTPS_PROXY: 'http://host.example', https_proxy: 'http://other-host.example', - }, 'win32'); - assert.deepEqual(env, { https_proxy: '' }); +test('win32: sorted-first spelling wins within each source regardless of insertion order', () => { + for (const reverse of [false, true]) { + const hostEntries = [['https_proxy', 'http://lower-host.example'], ['HTTPS_PROXY', 'http://upper-host.example']]; + const declaredEntries = [['https_proxy', 'http://lower-declared.example'], ['HTTPS_PROXY', '']]; + const host = Object.fromEntries(reverse ? hostEntries.reverse() : hostEntries); + const declared = Object.fromEntries(reverse ? declaredEntries.reverse() : declaredEntries); + for (const inheritEnv of [false, true]) { + assert.deepEqual(buildChildEnv({ inheritEnv }, host, 'win32'), { HTTPS_PROXY: 'http://upper-host.example' }); + assert.deepEqual(buildChildEnv({ inheritEnv, env: declared }, host, 'win32'), { HTTPS_PROXY: '' }); + assert.deepEqual(buildChildEnv({ inheritEnv, env: { https_proxy: '' } }, host, 'win32'), { https_proxy: '' }, + 'declared source still outranks every host spelling'); + } + } }); test('posix: differently cased host and declared keys remain distinct', () => {