From 4ed84f787691930861e9696858edf9638f69efd5 Mon Sep 17 00:00:00 2001 From: slimepriestess Date: Mon, 28 Sep 2026 10:58:51 -0700 Subject: [PATCH 01/18] fix(code-execution): the python interpreter gets the child allowlist too, not the host's secrets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #175 stops stdio MCPL children inheriting the host environment. The other child the framework spawns, the python interpreter behind code_execution, still got `{ ...process.env }` — and it runs model-authored code, so any agent with code execution could read every provider key and bot token on the host with one os.environ. The changelog's "one server's credentials are no longer readable by another" was not yet true on a host with code execution on. PyRunner builds its child env through buildChildEnv: the same operating allowlist (+ LC_*), plus the runner's own `env`, with `inheritEnv: true` as the same escape hatch. CodeExecutionConfig carries both and the framework threads them to both runner sites. PYTHONUNBUFFERED stays. Test: model-authored python asks os.environ for a host secret and gets None (red on the PR head: it printed the secret); declared env reaches the interpreter; inheritEnv restores the host env. Suite 964 / 960 / 0. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CoQK2cP55YhezE6ajSx58h --- .../fix-mcpl-child-env-scrub.breaking.md | 5 +++ src/code-execution/py-runner.ts | 14 ++++++- src/framework.ts | 4 ++ src/types/framework.ts | 6 +++ test/code-execution-env.test.ts | 42 +++++++++++++++++++ 5 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 test/code-execution-env.test.ts diff --git a/changelog.d/fix-mcpl-child-env-scrub.breaking.md b/changelog.d/fix-mcpl-child-env-scrub.breaking.md index 41b109f83..6c49f6e92 100644 --- a/changelog.d/fix-mcpl-child-env-scrub.breaking.md +++ b/changelog.d/fix-mcpl-child-env-scrub.breaking.md @@ -5,3 +5,8 @@ are no longer readable by another. A server that relied on an inherited variable must declare it in its `env` (recipes can `${VAR}`-substitute), or set `inheritEnv: true` to restore full inheritance. +- **Operators with `codeExecution` enabled:** the python interpreter that runs + model-authored scripts gets the same allowlist plus `codeExecution.env`; + `codeExecution.inheritEnv: true` restores full inheritance. Before this, + any agent with code execution could read every host secret via + `os.environ`. diff --git a/src/code-execution/py-runner.ts b/src/code-execution/py-runner.ts index b9f2cf805..336e92ee3 100644 --- a/src/code-execution/py-runner.ts +++ b/src/code-execution/py-runner.ts @@ -15,6 +15,7 @@ */ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { buildChildEnv } from '../mcpl/transport.js'; import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -70,6 +71,13 @@ export interface PyRunnerOptions { onToolCall: ScriptToolCallHandler; /** Log prefix, typically the agent name. */ label?: string; + /** Extra environment for the interpreter (PYTHONPATH, a venv, …). The + * child gets the same operating allowlist stdio MCPL children get + * (CHILD_ENV_ALLOWLIST + LC_*) plus exactly these — never the host's + * secrets, since the code it runs is the model's. */ + env?: Record; + /** Escape hatch: pass the host's entire environment, as before. */ + inheritEnv?: boolean; } const DEFAULT_TOOL_CALL_TIMEOUT_MS = 270_000; @@ -92,6 +100,7 @@ export class PyRunner { private readonly idleReclaimMs: number; private readonly onToolCall: ScriptToolCallHandler; private readonly label: string; + private readonly childEnv: NodeJS.ProcessEnv; private onWake: ((line: number, payload: unknown) => Promise) | null = null; private child: ChildProcessWithoutNullStreams | null = null; @@ -110,6 +119,9 @@ export class PyRunner { this.idleReclaimMs = options.idleReclaimMs ?? DEFAULT_IDLE_RECLAIM_MS; this.onToolCall = options.onToolCall; this.label = options.label ?? 'pytc'; + // The interpreter runs model-authored code: the same env discipline as an + // MCPL connector child (#175), for a child that deserves it more. + this.childEnv = { ...buildChildEnv({ env: options.env, inheritEnv: options.inheritEnv }), PYTHONUNBUFFERED: '1' }; } get busy(): boolean { @@ -244,7 +256,7 @@ export class PyRunner { const child = spawn(this.pythonPath, [runtimePath], { stdio: ['pipe', 'pipe', 'pipe'], - env: { ...process.env, PYTHONUNBUFFERED: '1' }, + env: this.childEnv, }); this.child = child; diff --git a/src/framework.ts b/src/framework.ts index 976d39ac4..ba80dc4b3 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -10246,6 +10246,8 @@ export class AgentFramework { toolCallTimeoutMs: cfg?.toolCallTimeoutMs, scriptTimeoutMs: cfg?.scriptTimeoutMs, idleReclaimMs: 0, // dedicated runner; lifetime is the exec deadline + env: cfg?.env, + inheritEnv: cfg?.inheritEnv, label: `${agentName}:${scriptId}`, onToolCall: (toolName, args) => this.handleScriptToolCall(agentName, toolName, args), }); @@ -10569,6 +10571,8 @@ export class AgentFramework { toolCallTimeoutMs: cfg?.toolCallTimeoutMs, scriptTimeoutMs: cfg?.scriptTimeoutMs, idleReclaimMs: cfg?.idleReclaimMs, + env: cfg?.env, + inheritEnv: cfg?.inheritEnv, label: agentName, onToolCall: (toolName, args) => this.handleScriptToolCall(agentName, toolName, args), }); diff --git a/src/types/framework.ts b/src/types/framework.ts index e7fb509ac..584da99f9 100644 --- a/src/types/framework.ts +++ b/src/types/framework.ts @@ -68,6 +68,12 @@ export interface CodeExecutionConfig { /** Max wake_agent() calls per background script (default 100); further * calls raise RuntimeError inside the script. */ maxWakesPerScript?: number; + /** Environment for the python interpreter on top of the operating + * allowlist (see CHILD_ENV_ALLOWLIST); the host's secrets are not passed + * to model-authored code. `${VAR}`-substitute from .env in a recipe. */ + env?: Record; + /** Escape hatch: the interpreter inherits the host's whole environment. */ + inheritEnv?: boolean; } /** See `FrameworkConfig.providerHold`. */ diff --git a/test/code-execution-env.test.ts b/test/code-execution-env.test.ts new file mode 100644 index 000000000..65eac6ffc --- /dev/null +++ b/test/code-execution-env.test.ts @@ -0,0 +1,42 @@ +/** + * The code-execution child runs MODEL-AUTHORED code. It must not inherit the + * host's secrets any more than an MCPL connector child does (#175): on a + * multi-resident host the .env carries provider keys and every bot's token, + * and `os.environ` would hand all of them to any agent with code_execution. + * Same operating allowlist as stdio MCPL children, plus the runner's own + * declared env; `inheritEnv: true` restores full inheritance. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { PyRunner } from '../src/index.js'; + +test('model-authored python does not see a host secret', async () => { + const key = 'AF_CODE_EXEC_TEST_HOST_SECRET'; + process.env[key] = 'must-not-leak'; + const runner = new PyRunner({ onToolCall: async () => '' }); + try { + const r = await runner.exec(`import os; print(repr(os.environ.get(${JSON.stringify(key)})))`, []); + assert.equal(r.returnCode, 0, r.stderr); + assert.match(r.stdout, /None/, `the code-execution child inherited a host secret: ${r.stdout.trim()}`); + } finally { + runner.dispose(); + delete process.env[key]; + } +}); + +test('declared env reaches the interpreter; inheritEnv restores the host env', async () => { + const key = 'AF_CODE_EXEC_TEST_HOST_SECRET'; + process.env[key] = 'must-not-leak'; + const declared = new PyRunner({ onToolCall: async () => '', env: { AF_CODE_EXEC_DECLARED: 'yes' } }); + const inheriting = new PyRunner({ onToolCall: async () => '', inheritEnv: true }); + try { + const d = await declared.exec(`import os; print(os.environ.get('AF_CODE_EXEC_DECLARED'), repr(os.environ.get(${JSON.stringify(key)})))`, []); + assert.match(d.stdout, /^yes None/m, d.stdout + d.stderr); + const i = await inheriting.exec(`import os; print(repr(os.environ.get(${JSON.stringify(key)})))`, []); + assert.match(i.stdout, /must-not-leak/, i.stdout + i.stderr); + } finally { + declared.dispose(); inheriting.dispose(); + delete process.env[key]; + } +}); From d8ea631edbdc34c211c19075fa4a784799e5582d Mon Sep 17 00:00:00 2001 From: slimepriestess Date: Tue, 29 Sep 2026 13:15:21 -0700 Subject: [PATCH 02/18] fix: rebuild the child env per spawn; match the allowlist case-insensitively on Windows Greptile's two findings on #193, both real: - The runner snapshotted its child env at construction, so a respawn after idle reclaim reused stale host values where the old per-spawn `{ ...process.env }` saw current ones. The env options are stored and buildChildEnv runs at every spawn. - Windows enumerates env names in arbitrary spellings (`Path`, `SystemRoot`, `ComSpec`) and the exact-case allowlist dropped them, starting every stdio/python child without its search path. On win32 the allowlist now matches by upper-cased name, keeping the original spelling; POSIX stays case-sensitive (`Path` is a different variable there). This also closes the #175 review's standing Windows note. Both tests red on the previous head: freshness 0/1, win32 casing 0/1; full suite 976/972/0. Co-Authored-By: Claude Fable 5 --- src/code-execution/py-runner.ts | 11 +++++++---- src/mcpl/transport.ts | 9 ++++++++- test/code-execution-env.test.ts | 15 +++++++++++++++ test/mcpl-child-env.test.ts | 21 +++++++++++++++++++++ 4 files changed, 51 insertions(+), 5 deletions(-) diff --git a/src/code-execution/py-runner.ts b/src/code-execution/py-runner.ts index 336e92ee3..5fed85d2e 100644 --- a/src/code-execution/py-runner.ts +++ b/src/code-execution/py-runner.ts @@ -100,7 +100,7 @@ export class PyRunner { private readonly idleReclaimMs: number; private readonly onToolCall: ScriptToolCallHandler; private readonly label: string; - private readonly childEnv: NodeJS.ProcessEnv; + private readonly envOptions: { env?: Record; inheritEnv?: boolean }; private onWake: ((line: number, payload: unknown) => Promise) | null = null; private child: ChildProcessWithoutNullStreams | null = null; @@ -120,8 +120,11 @@ export class PyRunner { this.onToolCall = options.onToolCall; this.label = options.label ?? 'pytc'; // The interpreter runs model-authored code: the same env discipline as an - // MCPL connector child (#175), for a child that deserves it more. - this.childEnv = { ...buildChildEnv({ env: options.env, inheritEnv: options.inheritEnv }), PYTHONUNBUFFERED: '1' }; + // MCPL connector child (#175), for a child that deserves it more. Stored + // as options, not a snapshot — the env is rebuilt at every spawn so a + // respawn after idle reclaim sees current host values, as the old + // per-spawn `{ ...process.env }` did. + this.envOptions = { env: options.env, inheritEnv: options.inheritEnv }; } get busy(): boolean { @@ -256,7 +259,7 @@ export class PyRunner { const child = spawn(this.pythonPath, [runtimePath], { stdio: ['pipe', 'pipe', 'pipe'], - env: this.childEnv, + env: { ...buildChildEnv(this.envOptions), PYTHONUNBUFFERED: '1' }, }); this.child = child; diff --git a/src/mcpl/transport.ts b/src/mcpl/transport.ts index 2fc8a523e..536e17390 100644 --- a/src/mcpl/transport.ts +++ b/src/mcpl/transport.ts @@ -125,12 +125,19 @@ export const CHILD_ENV_ALLOWLIST = [ 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 = {}; + // Windows env var names are case-insensitive and enumerate in arbitrary + // spellings (`Path`, `SystemRoot`, `ComSpec`); match the allowlist by + // upper-cased name there and keep the original spelling. POSIX names are + // case-sensitive, so `Path` staying excluded there is correct. + const win = platform === 'win32'; 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 probe = win ? key.toUpperCase() : key; + if (CHILD_ENV_ALLOWLIST.includes(probe) || probe.startsWith('LC_')) env[key] = value; } return { ...env, ...config.env }; } diff --git a/test/code-execution-env.test.ts b/test/code-execution-env.test.ts index 65eac6ffc..e18d81e60 100644 --- a/test/code-execution-env.test.ts +++ b/test/code-execution-env.test.ts @@ -40,3 +40,18 @@ test('declared env reaches the interpreter; inheritEnv restores the host env', a delete process.env[key]; } }); + +test('the child env is rebuilt per spawn, not snapshotted at construction', async () => { + const key = 'AF_CODE_EXEC_TEST_LATE_VAR'; + const runner = new PyRunner({ onToolCall: async () => '', inheritEnv: true }); + process.env[key] = 'set-after-construction'; + try { + const r = await runner.exec(`import os; print(repr(os.environ.get(${JSON.stringify(key)})))`, []); + assert.equal(r.returnCode, 0, r.stderr); + assert.match(r.stdout, /set-after-construction/, + `a var set after construction must reach the next spawn: ${r.stdout.trim()}`); + } finally { + runner.dispose(); + delete process.env[key]; + } +}); diff --git a/test/mcpl-child-env.test.ts b/test/mcpl-child-env.test.ts index 9458a5af7..4751227ed 100644 --- a/test/mcpl-child-env.test.ts +++ b/test/mcpl-child-env.test.ts @@ -72,3 +72,24 @@ test('a real spawned stdio child sees only allowlist + declared env', async () = delete process.env[secretKey]; } }); + +test('win32: allowlist matches case-insensitively, original spelling kept', () => { + const env = buildChildEnv({}, { + Path: 'C:\\Windows;C:\\Windows\\System32', + SystemRoot: 'C:\\Windows', + ComSpec: 'C:\\Windows\\System32\\cmd.exe', + lc_ctype: 'UTF-8', + ANTHROPIC_AUTH_TOKEN: 'host-anthropic-secret', + }, 'win32'); + assert.deepEqual(env, { + Path: 'C:\\Windows;C:\\Windows\\System32', + SystemRoot: 'C:\\Windows', + ComSpec: 'C:\\Windows\\System32\\cmd.exe', + lc_ctype: 'UTF-8', + }); +}); + +test('posix: allowlist stays case-sensitive (`Path` is a different variable)', () => { + const env = buildChildEnv({}, { Path: '/spoofed', PATH: '/usr/bin' }, 'linux'); + assert.deepEqual(env, { PATH: '/usr/bin' }); +}); From 36d51d733ff3311828dbe33f3ca645d95aaab335 Mon Sep 17 00:00:00 2001 From: slimepriestess Date: Tue, 29 Sep 2026 18:12:56 -0700 Subject: [PATCH 03/18] docs(changelog): fragment for the py-runner env scrub The branch's earlier fragment landed on main with #175, so the check saw no new entry for this PR. Co-Authored-By: Claude Fable 5 --- changelog.d/fix-code-execution-child-env.fixed.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 changelog.d/fix-code-execution-child-env.fixed.md diff --git a/changelog.d/fix-code-execution-child-env.fixed.md b/changelog.d/fix-code-execution-child-env.fixed.md new file mode 100644 index 000000000..9e4be7171 --- /dev/null +++ b/changelog.d/fix-code-execution-child-env.fixed.md @@ -0,0 +1,7 @@ +- The code-execution python interpreter no longer inherits the host + environment: it gets the same operating allowlist as stdio MCPL children + (#175), plus the runner's declared `env`, with `inheritEnv: true` as the + escape hatch — model-authored code can no longer read host secrets via + `os.environ`. The child env is rebuilt at every spawn, and on Windows the + allowlist matches names case-insensitively (`Path`, `SystemRoot`, + `ComSpec` survive with their original spelling). From a6d43bc21e71626990abfacc6fe7671a9ac1dc88 Mon Sep 17 00:00:00 2001 From: Effie-2756 Date: Sat, 17 Oct 2026 02:18:48 -0400 Subject: [PATCH 04/18] test(mcpl): drop the two child-env tests main already has This branch appended a win32 case-insensitive allowlist test and a posix case-sensitivity test to test/mcpl-child-env.test.ts for its own transport.ts change. main's #205 brought that behaviour with tests that cover both and more: "win32: operating allowlist matches native mixed-case spellings" and "posix: differently cased host and declared keys remain distinct" (the house's Oct 12 review of #193, item 5; Anarchid's R1-3). The file is now main's. Co-Authored-By: Claude Opus 5.5 --- test/mcpl-child-env.test.ts | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/test/mcpl-child-env.test.ts b/test/mcpl-child-env.test.ts index 53a511cf3..2a12deb90 100644 --- a/test/mcpl-child-env.test.ts +++ b/test/mcpl-child-env.test.ts @@ -175,24 +175,3 @@ test('posix: differently cased host and declared keys remain distinct', () => { assert.equal(env.Path, inheritEnv ? '/mixed-case' : undefined); } }); - -test('win32: allowlist matches case-insensitively, original spelling kept', () => { - const env = buildChildEnv({}, { - Path: 'C:\\Windows;C:\\Windows\\System32', - SystemRoot: 'C:\\Windows', - ComSpec: 'C:\\Windows\\System32\\cmd.exe', - lc_ctype: 'UTF-8', - ANTHROPIC_AUTH_TOKEN: 'host-anthropic-secret', - }, 'win32'); - assert.deepEqual(env, { - Path: 'C:\\Windows;C:\\Windows\\System32', - SystemRoot: 'C:\\Windows', - ComSpec: 'C:\\Windows\\System32\\cmd.exe', - lc_ctype: 'UTF-8', - }); -}); - -test('posix: allowlist stays case-sensitive (`Path` is a different variable)', () => { - const env = buildChildEnv({}, { Path: '/spoofed', PATH: '/usr/bin' }, 'linux'); - assert.deepEqual(env, { PATH: '/usr/bin' }); -}); From 3e857de71f11d54e93f59130f37aea2f15f4ec2a Mon Sep 17 00:00:00 2001 From: Effie-2756 Date: Sat, 17 Oct 2026 02:21:45 -0400 Subject: [PATCH 05/18] fix(code-execution): refuse a codeExecution env or inheritEnv the interpreter would misread buildChildEnv reads inheritEnv by truthiness, and validateCodeExecutionConfig checked only the time limits. A recipe's `"inheritEnv": "false"`, or a `${VAR}` substituted as a string, reaches create() untyped (connectome-host spreads the recipe object), and would hand the interpreter the host's whole environment while its author believed the opposite (the house's Oct 12 review of #193, item 3; Anarchid's R1-2). validateCodeExecutionConfig, run at create when code execution is enabled, now refuses: - an `inheritEnv` that isn't true or false, naming the value it got; - an `env` that isn't a plain object, or one with a value that isn't a string, naming the variable and the value's kind but not the value, which may be a credential. `codeExecution.env` is new with this PR, so no existing configuration holds a non-string value. Tests: the validator's refusals and what it accepts, in test/code-execution-env.test.ts; and create() refusing `inheritEnv: "false"`, in test/code-execution.test.ts. Co-Authored-By: Claude Opus 5.5 --- src/code-execution/tool-definition.ts | 47 +++++++++++++++++++++++++-- test/code-execution-env.test.ts | 26 +++++++++++++++ test/code-execution.test.ts | 16 +++++++++ 3 files changed, 87 insertions(+), 2 deletions(-) diff --git a/src/code-execution/tool-definition.ts b/src/code-execution/tool-definition.ts index cab62de22..729d63896 100644 --- a/src/code-execution/tool-definition.ts +++ b/src/code-execution/tool-definition.ts @@ -113,8 +113,18 @@ export function scriptTimeLimits(opts?: { }; } -/** Refuse a configuration whose time limits contradict each other. */ -export function validateCodeExecutionConfig(cfg: { scriptTimeoutMs?: number; maxScriptTimeoutMs?: number }): void { +/** + * Refuse a configuration whose time limits contradict each other, or whose + * interpreter environment would be misread. A recipe's `"inheritEnv": "false"`, + * or a `${VAR}` substituted as a string, arrives untyped, and any truthy value + * would hand the interpreter the host's whole environment. + */ +export function validateCodeExecutionConfig(cfg: { + scriptTimeoutMs?: number; + maxScriptTimeoutMs?: number; + env?: unknown; + inheritEnv?: unknown; +}): void { const defaultMs = cfg.scriptTimeoutMs ?? 600_000; if (cfg.maxScriptTimeoutMs !== undefined && !(cfg.maxScriptTimeoutMs >= defaultMs)) { throw new Error( @@ -122,4 +132,37 @@ export function validateCodeExecutionConfig(cfg: { scriptTimeoutMs?: number; max 'it is the most an agent may ask for, and the default is always allowed', ); } + if (cfg.inheritEnv !== undefined && typeof cfg.inheritEnv !== 'boolean') { + throw new Error( + `codeExecution.inheritEnv must be true or false, not ${kindOf(cfg.inheritEnv, true)}: ` + + "true hands the interpreter the host's whole environment, and false or leaving it out keeps the allowlist", + ); + } + if (cfg.env !== undefined) { + if (!isPlainObject(cfg.env)) { + throw new Error(`codeExecution.env must be an object of variable names and string values, not ${kindOf(cfg.env, false)}`); + } + for (const [name, value] of Object.entries(cfg.env)) { + // The value isn't shown: it may be a credential. + if (typeof value !== 'string') { + throw new Error(`codeExecution.env.${name} must be a string, not ${kindOf(value, false)}: quote it in the config`); + } + } + } +} + +function isPlainObject(value: unknown): value is Record { + if (typeof value !== 'object' || value === null) return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +/** What a configured value is, for an error: its kind, and with `show` a primitive's value too. */ +function kindOf(value: unknown, show: boolean): string { + if (value === null) return 'null'; + if (Array.isArray(value)) return 'an array'; + const type = typeof value; + const kind = type === 'object' ? 'an object' : `a ${type}`; + if (!show || type === 'object' || type === 'function') return kind; + return `${type === 'string' ? JSON.stringify(value) : String(value)} (${kind})`; } diff --git a/test/code-execution-env.test.ts b/test/code-execution-env.test.ts index e18d81e60..acaeb1db7 100644 --- a/test/code-execution-env.test.ts +++ b/test/code-execution-env.test.ts @@ -10,6 +10,32 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { PyRunner } from '../src/index.js'; +import { validateCodeExecutionConfig } from '../src/code-execution/tool-definition.js'; + +test('a codeExecution.inheritEnv that is not a boolean is refused, so "false" never hands over the host env', () => { + for (const inheritEnv of ['false', 'true', null, 0, 1, [], {}]) { + assert.throws(() => validateCodeExecutionConfig({ inheritEnv }), /codeExecution\.inheritEnv must be true or false/, + JSON.stringify(inheritEnv)); + } + assert.throws(() => validateCodeExecutionConfig({ inheritEnv: 'false' }), /not "false" \(a string\)/); + for (const inheritEnv of [undefined, true, false]) validateCodeExecutionConfig({ inheritEnv }); +}); + +test('a codeExecution.env that is not an object of strings is refused, naming the variable but not its value', () => { + for (const env of [null, [], 'PYTHONPATH=/opt/lib', 7]) { + assert.throws(() => validateCodeExecutionConfig({ env }), /codeExecution\.env must be an object of variable names and string values/, + JSON.stringify(env)); + } + for (const value of [8080, true, null, ['x'], { nested: 'y' }]) { + assert.throws(() => validateCodeExecutionConfig({ env: { PYTHONPATH: '/opt/lib', BAD: value } }), (err: Error) => { + assert.match(err.message, /^codeExecution\.env\.BAD must be a string/); + assert.ok(!err.message.includes('8080'), err.message); + return true; + }, JSON.stringify(value)); + } + validateCodeExecutionConfig({ env: {} }); + validateCodeExecutionConfig({ env: { PYTHONPATH: '/opt/lib', EMPTY: '' } }); +}); test('model-authored python does not see a host secret', async () => { const key = 'AF_CODE_EXEC_TEST_HOST_SECRET'; diff --git a/test/code-execution.test.ts b/test/code-execution.test.ts index 5deec59aa..b9eb07380 100644 --- a/test/code-execution.test.ts +++ b/test/code-execution.test.ts @@ -427,6 +427,22 @@ async function createFrameworkWithCodeExecution(storePath: string, membrane: Moc } describe('framework code_execution integration (real python3)', () => { + it('create() refuses a codeExecution.inheritEnv that is not a boolean', async () => { + const { tempDir, storePath } = tempStorePath('pytc-inherit-'); + try { + await assert.rejects(AgentFramework.create({ + storePath, + membrane: new MockMembrane().asMembrane(), + agents: [], + modules: [new ScriptToolModule()], + syncIntervalMs: 0, + codeExecution: { enabled: true, inheritEnv: 'false' as unknown as boolean }, + }), /codeExecution\.inheritEnv must be true or false, not "false" \(a string\)/); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + it('synthesizes the tool only when enabled', async () => { const { tempDir, storePath } = tempStorePath('pytc-tools-'); const membrane = new MockMembrane(); From 71ff84907b3c907865d616221485ca31955c1a5e Mon Sep 17 00:00:00 2001 From: Effie-2756 Date: Sat, 17 Oct 2026 02:21:45 -0400 Subject: [PATCH 06/18] test(code-execution): codeExecution.env reaches a script through the framework, on both runner paths The PR's tests construct PyRunner directly, so nothing covered the four lines that pass `env` and `inheritEnv` to the two `new PyRunner` sites in src/framework.ts: with them deleted, the tests still passed (the house's Oct 12 review of #193, item 4; Anarchid's R1-4). Two tests in test/code-execution.test.ts's harness, each with a host variable no allowlist names set in process.env and a codeExecution.env variable declared: - an ordinary code_execution call (the per-agent runner) prints both, and the model's tool result shows the declared value and None for the host's; - a background script (its dedicated runner) wakes the agent with both, and the wake shows the declared value and null for the host's. Co-Authored-By: Claude Opus 5.5 --- test/code-execution.test.ts | 71 +++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/test/code-execution.test.ts b/test/code-execution.test.ts index b9eb07380..a02640978 100644 --- a/test/code-execution.test.ts +++ b/test/code-execution.test.ts @@ -426,7 +426,45 @@ async function createFrameworkWithCodeExecution(storePath: string, membrane: Moc }); } +/** A host variable no allowlist names, and a declared one, for the env tests below. */ +const HOST_ONLY = 'AF_CODE_EXEC_TEST_HOST_ONLY'; +const DECLARED = 'AF_CODE_EXEC_TEST_DECLARED'; + describe('framework code_execution integration (real python3)', () => { + it("a script gets codeExecution.env and not a host variable outside the allowlist", async () => { + const { tempDir, storePath } = tempStorePath('pytc-env-'); + const membrane = new MockMembrane(); + const code = `import os; print("declared:", os.environ.get("${DECLARED}"), "host:", repr(os.environ.get("${HOST_ONLY}")))`; + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'call_env_1', name: 'code_execution', input: { code } }, + ], 'tool_use')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Done.' }])); + process.env[HOST_ONLY] = 'host-value'; + const framework = await AgentFramework.create({ + storePath, + membrane: membrane.asMembrane(), + agents: [], + modules: [new ScriptToolModule()], + syncIntervalMs: 0, + codeExecution: { enabled: true, env: { [DECLARED]: 'from-config' } }, + }); + try { + const created = await framework.createEphemeralAgent({ + name: 'worker', model: 'test-model', systemPrompt: 'Do the task.', allowedTools: 'all', + }); + created.contextManager.addMessage('user', [{ type: 'text', text: 'Go.' }]); + const promise = framework.runEphemeralToCompletion(created.agent, created.contextManager); + framework.start(); + await promise; + const wireResult = membrane.lastStream!.receivedToolResults[0][0] as { content: string }; + assert.match(wireResult.content, /declared: from-config host: None/, wireResult.content); + } finally { + await framework.stop(); + delete process.env[HOST_ONLY]; + rmSync(tempDir, { recursive: true, force: true }); + } + }); + it('create() refuses a codeExecution.inheritEnv that is not a boolean', async () => { const { tempDir, storePath } = tempStorePath('pytc-inherit-'); try { @@ -821,6 +859,39 @@ describe('framework background scripts + spill (real python3)', () => { } }); + it('a background script gets codeExecution.env and not a host variable outside the allowlist', async () => { + const { tempDir, storePath } = tempStorePath('pytc-bgenv-'); + const membrane = new MockMembrane(); + const bgCode = `import os\nawait wake_agent({"declared": os.environ.get("${DECLARED}"), "host": os.environ.get("${HOST_ONLY}")})`; + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'call_bgenv', name: 'code_execution', input: { code: bgCode, background: true } }, + ], 'tool_use')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Armed.' }])); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Woke.' }])); + process.env[HOST_ONLY] = 'host-value'; + const framework = await createBgFramework(storePath, membrane, { codeExecution: { env: { [DECLARED]: 'from-config' } } }); + try { + framework.start(); + framework.pushEvent({ + type: 'external-message', source: 'test', content: [{ type: 'text', text: 'arm it' }], metadata: {}, triggerInference: true, + } as unknown as ProcessEvent); + const deadline = Date.now() + 20_000; + let wakeText = ''; + while (Date.now() < deadline && !wakeText) { + await new Promise((r) => setTimeout(r, 100)); + const msgs = (framework.getAgent('prime')?.getContextManager().queryMessages({}).messages ?? []) as unknown as Array<{ content: ContentBlockLike[]; metadata?: { source?: string } }>; + const wake = msgs.find((m) => m.metadata?.source === 'background-script'); + wakeText = (wake?.content[0] as { text?: string } | undefined)?.text ?? ''; + } + assert.match(wakeText, /"declared": "from-config"/, wakeText); + assert.match(wakeText, /"host": null/, wakeText); + } finally { + await framework.stop(); + delete process.env[HOST_ONLY]; + rmSync(tempDir, { recursive: true, force: true }); + } + }); + it('list and cancel manage the daemon fleet; cap enforced', async () => { const { tempDir, storePath } = tempStorePath('pytc-bglist-'); const membrane = new MockMembrane(); From 545c81d67853f9993d0d51035011c036b2879d5a Mon Sep 17 00:00:00 2001 From: Effie-2756 Date: Sat, 17 Oct 2026 02:22:04 -0400 Subject: [PATCH 07/18] test(code-execution): the env test for a respawn respawns The third test set its variable after construction but before the first spawn, so an implementation that snapshotted the env at the first spawn passed it too, and it never reached a respawn (Anarchid's R1-4). A constructor snapshot was Greptile's round-1 finding on this PR, fixed in its second commit. It now execs once, waits for the runner's own idle-reclaim line on stderr (idleReclaimMs 20) rather than a guessed delay, changes the host value, and execs again: the respawned interpreter must see the new value. Co-Authored-By: Claude Opus 5.5 --- test/code-execution-env.test.ts | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/test/code-execution-env.test.ts b/test/code-execution-env.test.ts index acaeb1db7..61de0679b 100644 --- a/test/code-execution-env.test.ts +++ b/test/code-execution-env.test.ts @@ -67,16 +67,31 @@ test('declared env reaches the interpreter; inheritEnv restores the host env', a } }); -test('the child env is rebuilt per spawn, not snapshotted at construction', async () => { +test('the child env is built at each spawn: a respawn after idle reclaim sees a value changed since', async () => { const key = 'AF_CODE_EXEC_TEST_LATE_VAR'; - const runner = new PyRunner({ onToolCall: async () => '', inheritEnv: true }); - process.env[key] = 'set-after-construction'; + const read = `import os; print(repr(os.environ.get(${JSON.stringify(key)})))`; + process.env[key] = 'first'; + const runner = new PyRunner({ onToolCall: async () => '', inheritEnv: true, idleReclaimMs: 20, label: 'respawn' }); + // The runner reports its idle reclaim on stderr; wait for that line rather than a guessed delay. + const error = console.error; + let reclaimed = false; + console.error = (...args: unknown[]) => { + if (args.map(String).join(' ').includes('[pytc:respawn] reclaiming python interpreter (idle)')) reclaimed = true; + else error(...args); + }; try { - const r = await runner.exec(`import os; print(repr(os.environ.get(${JSON.stringify(key)})))`, []); - assert.equal(r.returnCode, 0, r.stderr); - assert.match(r.stdout, /set-after-construction/, - `a var set after construction must reach the next spawn: ${r.stdout.trim()}`); + const before = await runner.exec(read, []); + assert.equal(before.returnCode, 0, before.stderr); + assert.match(before.stdout, /'first'/, before.stdout); + const deadline = Date.now() + 10_000; + while (!reclaimed && Date.now() < deadline) await new Promise((r) => setTimeout(r, 10)); + assert.ok(reclaimed, 'the idle interpreter should have been reclaimed'); + process.env[key] = 'second'; + const after = await runner.exec(read, []); + assert.equal(after.returnCode, 0, after.stderr); + assert.match(after.stdout, /'second'/, `the respawned interpreter must see the value set since the first spawn: ${after.stdout.trim()}`); } finally { + console.error = error; runner.dispose(); delete process.env[key]; } From c0d6ce8a8a3f98e42eeea8f9036c8f516f96ff6f Mon Sep 17 00:00:00 2001 From: Effie-2756 Date: Sat, 17 Oct 2026 02:23:42 -0400 Subject: [PATCH 08/18] fix(code-execution): set PYTHONIOENCODING=utf-8 beside PYTHONUNBUFFERED The runtime writes its protocol frames as JSON with ensure_ascii=False and reads sys.stdin as text (src/code-execution/runtime-py.ts). On Windows, Python before 3.15 encodes piped stdio in the ANSI code page unless UTF-8 mode or PYTHONIOENCODING is set, so non-ANSI text in a frame breaks the protocol. A host's own PYTHONUTF8=1 used to cover for that, and this PR stops it reaching the interpreter, since it isn't in the allowlist (the house's Oct 12 review of #193 suggested it; Anarchid's R1-6 asks for it in this PR). Both are set last, over a declared env, as PYTHONUNBUFFERED already was. Nobody has run this on Windows. The new test shows both reach the interpreter over a declared env that sets them otherwise. Co-Authored-By: Claude Opus 5.5 --- src/code-execution/py-runner.ts | 6 +++++- test/code-execution-env.test.ts | 11 +++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/code-execution/py-runner.ts b/src/code-execution/py-runner.ts index 0dc83ef53..e4b7596e3 100644 --- a/src/code-execution/py-runner.ts +++ b/src/code-execution/py-runner.ts @@ -306,7 +306,11 @@ export class PyRunner { const child = spawn(this.pythonPath, [runtimePath], { stdio: ['pipe', 'pipe', 'pipe'], - env: { ...buildChildEnv(this.envOptions), PYTHONUNBUFFERED: '1' }, + // Set last, over a declared env: the protocol needs both. It writes + // non-ASCII JSON on piped stdio, which Python before 3.15 on Windows + // encodes in the ANSI code page unless told otherwise, and a host's + // own PYTHONUTF8 no longer reaches the interpreter. + env: { ...buildChildEnv(this.envOptions), PYTHONUNBUFFERED: '1', PYTHONIOENCODING: 'utf-8' }, }); this.child = child; diff --git a/test/code-execution-env.test.ts b/test/code-execution-env.test.ts index 61de0679b..dff3528c0 100644 --- a/test/code-execution-env.test.ts +++ b/test/code-execution-env.test.ts @@ -96,3 +96,14 @@ test('the child env is built at each spawn: a respawn after idle reclaim sees a delete process.env[key]; } }); + +test('PYTHONUNBUFFERED and PYTHONIOENCODING are set last, over a declared env, since the protocol needs both', async () => { + const runner = new PyRunner({ onToolCall: async () => '', env: { PYTHONUNBUFFERED: '0', PYTHONIOENCODING: 'latin-1' } }); + try { + const r = await runner.exec(`import os; print(os.environ.get('PYTHONUNBUFFERED'), os.environ.get('PYTHONIOENCODING'))`, []); + assert.equal(r.returnCode, 0, r.stderr); + assert.match(r.stdout, /^1 utf-8$/m, r.stdout); + } finally { + runner.dispose(); + } +}); From 77c1214ec3cd6788b21f07bd3aa036f44e70697b Mon Sep 17 00:00:00 2001 From: Effie-2756 Date: Sat, 17 Oct 2026 02:24:02 -0400 Subject: [PATCH 09/18] docs(code-execution): say what the interpreter's env does and doesn't keep from model code The option docs said the interpreter gets "never the host's secrets" and that "the host's secrets are not passed to model-authored code". An environment allowlist limits what a child inherits, not what it can read: any process running as the same user can read the host's /proc//environ and its files, as main's CHILD_ENV_ALLOWLIST doc already says (src/mcpl/transport.ts:125-126; the house's Oct 12 review of #193, item 1; Anarchid's R1-1). - PyRunnerOptions.env and CodeExecutionConfig.env: what the interpreter inherits (the allowlist, whose CA bundle and proxy variables come too, and a proxy URL can carry credentials), then the declared variables, then PYTHONUNBUFFERED and PYTHONIOENCODING, set last; and that this limits inheritance, not access. - PyRunnerOptions.inheritEnv and CodeExecutionConfig.inheritEnv: that it hands every host variable, provider keys included, to model code and every subprocess a script starts, and that listing what scripts need in `env` is the safer route. - The first env test is named for what it shows: "does not inherit a host variable outside the allowlist". Co-Authored-By: Claude Opus 5.5 --- src/code-execution/py-runner.ts | 16 +++++++++++----- src/types/framework.ts | 13 +++++++++---- test/code-execution-env.test.ts | 2 +- 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/src/code-execution/py-runner.ts b/src/code-execution/py-runner.ts index e4b7596e3..9a31ffc8e 100644 --- a/src/code-execution/py-runner.ts +++ b/src/code-execution/py-runner.ts @@ -73,12 +73,18 @@ export interface PyRunnerOptions { onToolCall: ScriptToolCallHandler; /** Log prefix, typically the agent name. */ label?: string; - /** Extra environment for the interpreter (PYTHONPATH, a venv, …). The - * child gets the same operating allowlist stdio MCPL children get - * (CHILD_ENV_ALLOWLIST + LC_*) plus exactly these — never the host's - * secrets, since the code it runs is the model's. */ + /** Variables for the interpreter beyond what it inherits, such as + * PYTHONPATH or a venv's. It inherits only what stdio MCPL children do + * (CHILD_ENV_ALLOWLIST and LC_*, CA bundles and proxy variables among + * them, and a proxy URL can carry credentials), then these, then + * PYTHONUNBUFFERED and PYTHONIOENCODING, which the runner sets last. + * Like the allowlist, this limits what the model's code inherits, not + * what a process running as the same user can read. */ env?: Record; - /** Escape hatch: pass the host's entire environment, as before. */ + /** Pass the host's whole environment instead of the allowlist, as before + * `env` existed. That hands every host variable, provider keys included, + * to the model's code and to every subprocess a script starts; listing + * what scripts need in `env` is the safer route. */ inheritEnv?: boolean; } diff --git a/src/types/framework.ts b/src/types/framework.ts index 7848a907b..5535b928d 100644 --- a/src/types/framework.ts +++ b/src/types/framework.ts @@ -74,11 +74,16 @@ export interface CodeExecutionConfig { /** Max wake_agent() calls per background script (default 100); further * calls raise RuntimeError inside the script. */ maxWakesPerScript?: number; - /** Environment for the python interpreter on top of the operating - * allowlist (see CHILD_ENV_ALLOWLIST); the host's secrets are not passed - * to model-authored code. `${VAR}`-substitute from .env in a recipe. */ + /** Variables for the python interpreter beyond the operating allowlist it + * inherits (see CHILD_ENV_ALLOWLIST), such as PYTHONPATH. A recipe can + * `${VAR}`-substitute them from .env. Other host variables aren't + * inherited, which limits what model-authored code inherits, not what a + * process running as the same user can read. */ env?: Record; - /** Escape hatch: the interpreter inherits the host's whole environment. */ + /** The interpreter inherits the host's whole environment instead of the + * allowlist: every host variable, provider keys included, reaches + * model-authored code and every subprocess a script starts. Listing what + * scripts need in `env` is the safer route. */ inheritEnv?: boolean; } diff --git a/test/code-execution-env.test.ts b/test/code-execution-env.test.ts index dff3528c0..c4942f31c 100644 --- a/test/code-execution-env.test.ts +++ b/test/code-execution-env.test.ts @@ -37,7 +37,7 @@ test('a codeExecution.env that is not an object of strings is refused, naming th validateCodeExecutionConfig({ env: { PYTHONPATH: '/opt/lib', EMPTY: '' } }); }); -test('model-authored python does not see a host secret', async () => { +test('model-authored python does not inherit a host variable outside the allowlist', async () => { const key = 'AF_CODE_EXEC_TEST_HOST_SECRET'; process.env[key] = 'must-not-leak'; const runner = new PyRunner({ onToolCall: async () => '' }); From 63fe1dc380988147c52299518426c89e059f1c62 Mon Sep 17 00:00:00 2001 From: Effie-2756 Date: Sat, 17 Oct 2026 02:28:56 -0400 Subject: [PATCH 10/18] fix(code-execution): the interpreter starts in its runtime directory, not the host's spawn() passed no cwd, so model-authored code started where the host was started, which is usually beside its .env and data directory. A relative open('.env'), or a stray relative write, reached them (Anarchid's R1-5). The interpreter now starts in the per-spawn runtime directory that already holds runtime.py and that teardownChild removes, so a script's relative paths land there and go with it. This guards against accidents, not access. An absolute path still reaches the host's files, and the code comment says so. A script that wrote a file by a relative path to keep it now needs an absolute path. The new test writes a file by a relative path, checks it landed in an af-pytc- directory other than the host's cwd, and that the directory is gone after dispose(). Co-Authored-By: Claude Opus 5.5 --- src/code-execution/py-runner.ts | 4 ++++ test/code-execution-env.test.ts | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/code-execution/py-runner.ts b/src/code-execution/py-runner.ts index 9a31ffc8e..cd22f671a 100644 --- a/src/code-execution/py-runner.ts +++ b/src/code-execution/py-runner.ts @@ -312,6 +312,10 @@ export class PyRunner { const child = spawn(this.pythonPath, [runtimePath], { stdio: ['pipe', 'pipe', 'pipe'], + // The runtime directory, not the host's working directory: a script's + // relative paths land here, and go at teardown, rather than beside the + // host's .env and data. An absolute path still reaches the host's files. + cwd: this.runtimeDir, // Set last, over a declared env: the protocol needs both. It writes // non-ASCII JSON on piped stdio, which Python before 3.15 on Windows // encodes in the ANSI code page unless told otherwise, and a host's diff --git a/test/code-execution-env.test.ts b/test/code-execution-env.test.ts index c4942f31c..7e407b6c9 100644 --- a/test/code-execution-env.test.ts +++ b/test/code-execution-env.test.ts @@ -8,6 +8,8 @@ */ import { test } from 'node:test'; import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import { basename, join } from 'node:path'; import { PyRunner } from '../src/index.js'; import { validateCodeExecutionConfig } from '../src/code-execution/tool-definition.js'; @@ -107,3 +109,19 @@ test('PYTHONUNBUFFERED and PYTHONIOENCODING are set last, over a declared env, s runner.dispose(); } }); + +test("the interpreter runs in its own runtime directory, which goes at teardown, not the host's working directory", async () => { + const runner = new PyRunner({ onToolCall: async () => '' }); + try { + const r = await runner.exec(`import os; open('scratch.txt', 'w').write('x'); print(os.getcwd())`, []); + assert.equal(r.returnCode, 0, r.stderr); + const cwd = r.stdout.trim(); + assert.notEqual(cwd, process.cwd()); + assert.match(basename(cwd), /^af-pytc-/, cwd); + assert.ok(existsSync(join(cwd, 'scratch.txt')), 'a relative path lands in the runtime directory'); + runner.dispose(); + assert.ok(!existsSync(cwd), 'the runtime directory goes at teardown, with what the script wrote there'); + } finally { + runner.dispose(); + } +}); From 6d227b896a723cfd7f7e99f5fff3bb301aeffe30 Mon Sep 17 00:00:00 2001 From: Effie-2756 Date: Sat, 17 Oct 2026 02:29:18 -0400 Subject: [PATCH 11/18] fix(mcpl): USERNAME joins the child-env allowlist The shared allowlist passes USER and LOGNAME, so a POSIX child knows its user's name, but not USERNAME, which is where Windows keeps it. Python's getpass.getuser() reads LOGNAME, USER, LNAME and USERNAME, then falls back to the pwd module, which Windows doesn't have, so it raises there. That happens in stdio MCPL children today, and would in the code-execution interpreter once this PR gives it the allowlist (Anarchid's R1-7). USERNAME carries the same information that USER and LOGNAME already pass on POSIX. The new win32 test of buildChildEnv shows USERNAME passing beside SystemRoot while USERDOMAIN and a planted key stay out. Nobody has run it on Windows itself. Co-Authored-By: Claude Opus 5.5 --- src/mcpl/transport.ts | 3 ++- test/mcpl-child-env.test.ts | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/mcpl/transport.ts b/src/mcpl/transport.ts index 65fc538cb..8ca9c913f 100644 --- a/src/mcpl/transport.ts +++ b/src/mcpl/transport.ts @@ -136,7 +136,8 @@ export const CHILD_ENV_ALLOWLIST = [ 'http_proxy', 'https_proxy', 'no_proxy', '__CF_USER_TEXT_ENCODING', // Windows equivalents, harmless elsewhere. - 'SYSTEMROOT', 'WINDIR', 'COMSPEC', 'PATHEXT', 'APPDATA', 'LOCALAPPDATA', 'USERPROFILE', + // USERNAME is Windows' USER, which Python's getpass.getuser() reads there. + 'SYSTEMROOT', 'WINDIR', 'COMSPEC', 'PATHEXT', 'APPDATA', 'LOCALAPPDATA', 'USERPROFILE', 'USERNAME', ]; /** Environment for a stdio child: allowlisted host vars (+ LC_*), then the diff --git a/test/mcpl-child-env.test.ts b/test/mcpl-child-env.test.ts index 2a12deb90..16c2b8310 100644 --- a/test/mcpl-child-env.test.ts +++ b/test/mcpl-child-env.test.ts @@ -137,6 +137,13 @@ for (const inheritEnv of [false, true]) { }); } +test('win32: USERNAME passes, as USER and LOGNAME do on POSIX', () => { + const env = buildChildEnv({}, { + USERNAME: 'ada', USERDOMAIN: 'HOUSE', SystemRoot: 'C:\\Windows', OpenAI_Api_Key: 'host-secret', + }, 'win32'); + assert.deepEqual(env, { USERNAME: 'ada', SystemRoot: 'C:\\Windows' }); +}); + test('win32: operating allowlist matches native mixed-case spellings', () => { const env = buildChildEnv({}, { Path: 'C:\\Windows', SystemRoot: 'C:\\Windows', ComSpec: 'C:\\Windows\\System32\\cmd.exe', From 34dd027c1c33699855cd06c09af82443c3a9fc03 Mon Sep 17 00:00:00 2001 From: Effie-2756 Date: Sat, 17 Oct 2026 02:31:00 -0400 Subject: [PATCH 12/18] docs(changelog): #193's entry as a breaking fragment, and USERNAME as a fixed one The branch's breaking bullet went into fix-mcpl-child-env-scrub.breaking.md, which the 0.20.0 release consumed (the merge removed it, as main has it). Its fixed fragment said what a breaking entry would, plus two lines about code that never shipped: the Windows casing, main's since #205 and already in 0.21.0's changelog, and "rebuilt at every spawn", a fix within this PR (the house's Oct 12 review of #193, items 2 and 5; Anarchid's R1-3). - fix-code-execution-child-env.breaking.md, new, as CONTRIBUTING asks of a breaking entry (who acts, migration, unchanged): what the interpreter gets and in what order; what stops arriving, and how to get it back through codeExecution.env, or all of it with inheritEnv: true and what that hands over; the runtime directory and what a file meant to last needs; direct PyRunner users; the refusal at create; and what's unchanged, with the limit of an allowlist stated as main states it. "A venv's interpreter finds its prefix and site-packages without VIRTUAL_ENV" rests on a run: a venv made with Python 3.13.5 on Linux, run with only PATH set, reports a venv sys.prefix and its site-packages on sys.path. - fix-code-execution-child-env.fixed.md, rewritten to the one fix that isn't the breaking change: USERNAME in the allowlist, which MCPL children on Windows need too. Co-Authored-By: Claude Opus 5.5 --- changelog.d/fix-code-execution-child-env.breaking.md | 6 ++++++ changelog.d/fix-code-execution-child-env.fixed.md | 8 +------- 2 files changed, 7 insertions(+), 7 deletions(-) create mode 100644 changelog.d/fix-code-execution-child-env.breaking.md diff --git a/changelog.d/fix-code-execution-child-env.breaking.md b/changelog.d/fix-code-execution-child-env.breaking.md new file mode 100644 index 000000000..1c836debe --- /dev/null +++ b/changelog.d/fix-code-execution-child-env.breaking.md @@ -0,0 +1,6 @@ +- **Operators with `codeExecution` enabled:** the python interpreter that runs model-authored scripts no longer inherits the host's environment, and it starts in its own runtime directory instead of the host's working directory. It gets the allowlist stdio MCPL children get (#175): `PATH`, `HOME`, the locale, temp directories, CA bundles and proxy variables, whose URLs can carry credentials. Then come `codeExecution.env`, and last `PYTHONUNBUFFERED=1` and `PYTHONIOENCODING=utf-8`, which the runner sets over any declared value. + - **What stops arriving:** every host variable outside the allowlist, such as `PYTHONPATH`, `VIRTUAL_ENV`, `PYTHONHOME`, `LD_LIBRARY_PATH`, `PYTHONUTF8` and `ALL_PROXY`, and any credential a script read from `os.environ`. To get one back, list it in `codeExecution.env`, which a recipe can `${VAR}`-substitute from `.env`. `codeExecution.inheritEnv: true` restores the whole environment, and with it hands every host variable, provider keys included, to model code and to every subprocess a script starts. + - **A script's relative paths** now land in the runtime directory, which goes when the interpreter is reclaimed. A file meant to outlast the interpreter needs an absolute path, or the workspace. + - **Direct `PyRunner` users:** `PyRunnerOptions` gains `env` and `inheritEnv`, and a runner built without them gets the allowlist. + - **A misread configuration is refused at create:** an `inheritEnv` that isn't `true` or `false`, such as `"false"`, which would have handed over the whole environment, and an `env` that isn't an object of strings. + - **Unchanged:** `pythonPath` as given, since a venv's interpreter finds its prefix and site-packages without `VIRTUAL_ENV`, and everything the allowlist passes. The allowlist limits what model code inherits, not what it can read: on Linux a process running as the same user can still read the host's environment through `/proc//environ`, and any process can open the host's files by absolute path. diff --git a/changelog.d/fix-code-execution-child-env.fixed.md b/changelog.d/fix-code-execution-child-env.fixed.md index 9e4be7171..cd2e20a2d 100644 --- a/changelog.d/fix-code-execution-child-env.fixed.md +++ b/changelog.d/fix-code-execution-child-env.fixed.md @@ -1,7 +1 @@ -- The code-execution python interpreter no longer inherits the host - environment: it gets the same operating allowlist as stdio MCPL children - (#175), plus the runner's declared `env`, with `inheritEnv: true` as the - escape hatch — model-authored code can no longer read host secrets via - `os.environ`. The child env is rebuilt at every spawn, and on Windows the - allowlist matches names case-insensitively (`Path`, `SystemRoot`, - `ComSpec` survive with their original spelling). +- `USERNAME` joins the child-env allowlist, beside `USER` and `LOGNAME`, so a Windows child gets its user's name, as a POSIX one does. Without it, Python's `getpass.getuser()` raises on Windows, in stdio MCPL children and the code-execution interpreter alike. From adada6efbc53c10e9e366b002a5a47e5fcb51885 Mon Sep 17 00:00:00 2001 From: Effie-2756 Date: Sat, 17 Oct 2026 06:41:14 -0400 Subject: [PATCH 13/18] fix(code-execution): answer the round's review: paths, cleanup, refusals and their tests Our internal review of this branch found that starting the interpreter in its runtime directory moved more than a script's relative writes, that two framework lines had no test, and that the validator's refusals showed values and missed names spawn can't pass (R1-2, R1-4, R1-5). - A relative pythonPath with a separator resolves against the host's working directory, as it did before the interpreter got its own (spawnablePath). spawn resolves a relative command against its cwd, so `.venv/bin/python` started from the repository root failed with ENOENT under the runtime directory. A bare name is still looked up on PATH. - The runtime directory goes once the interpreter has exited, not right after the kill: on Windows a directory can't be removed while it is a live process's working directory, so each reclaim left one behind. A child that never started has no exit to wait for and is cleaned at once. Nobody has run this on Windows. - validateCodeExecutionConfig's refusals name a value's kind, never the value, since a substituted value can be a credential: `codeExecution. inheritEnv must be true or false, unquoted, not a string`, with "unquoted" said only for a string. kindOf names undefined, and a non-plain object by its constructor (`a Map`). - A null inheritEnv or env counts as not set, as JSON writes that and as declaredReactionBaseline already reads a null. A null env value is still refused, since spawn would write it as the string "null". - It also refuses an env variable name that is empty or holds `=` or NUL, and a value that holds NUL: spawn writes `name=value`, so the first two don't reach the interpreter as written, and a NUL makes spawn throw at every start (probed on Node 24). The value refusal's remedy now reads "give its value as a string, or leave NAME out". - Two framework tests run with codeExecution.inheritEnv: true, an ordinary call and a background script, and see a host variable beside the declared one: nothing pinned the two `inheritEnv: cfg?.inheritEnv` lines. - Text: the tool description tells the model that relative paths resolve in a scratch directory that goes at reclaim; CodeExecutionConfig.env says the runner sets PYTHONUNBUFFERED and PYTHONIOENCODING over it and that its relative paths resolve in the runtime directory; the breaking entry names relative reads and env paths beside writes, the name refusals, and a relative pythonPath as unchanged. Tests: the validator's kinds and messages, null taken as not set, the name and NUL refusals, a relative pythonPath that starts only from the host's working directory (bin/python under a directory of the test's own: a path that climbs to the root would find the interpreter from anywhere), the runtime directory gone after dispose() (waiting for the exit), and the two inheritEnv framework tests. Co-Authored-By: Claude Opus 5.5 --- .../fix-code-execution-child-env.breaking.md | 6 +- src/code-execution/py-runner.ts | 42 +++++++--- src/code-execution/tool-definition.ts | 40 ++++++--- src/types/framework.ts | 9 ++- test/code-execution-env.test.ts | 81 +++++++++++++++---- test/code-execution.test.ts | 69 +++++++++++++++- 6 files changed, 202 insertions(+), 45 deletions(-) diff --git a/changelog.d/fix-code-execution-child-env.breaking.md b/changelog.d/fix-code-execution-child-env.breaking.md index 1c836debe..e40d597b2 100644 --- a/changelog.d/fix-code-execution-child-env.breaking.md +++ b/changelog.d/fix-code-execution-child-env.breaking.md @@ -1,6 +1,6 @@ - **Operators with `codeExecution` enabled:** the python interpreter that runs model-authored scripts no longer inherits the host's environment, and it starts in its own runtime directory instead of the host's working directory. It gets the allowlist stdio MCPL children get (#175): `PATH`, `HOME`, the locale, temp directories, CA bundles and proxy variables, whose URLs can carry credentials. Then come `codeExecution.env`, and last `PYTHONUNBUFFERED=1` and `PYTHONIOENCODING=utf-8`, which the runner sets over any declared value. - **What stops arriving:** every host variable outside the allowlist, such as `PYTHONPATH`, `VIRTUAL_ENV`, `PYTHONHOME`, `LD_LIBRARY_PATH`, `PYTHONUTF8` and `ALL_PROXY`, and any credential a script read from `os.environ`. To get one back, list it in `codeExecution.env`, which a recipe can `${VAR}`-substitute from `.env`. `codeExecution.inheritEnv: true` restores the whole environment, and with it hands every host variable, provider keys included, to model code and to every subprocess a script starts. - - **A script's relative paths** now land in the runtime directory, which goes when the interpreter is reclaimed. A file meant to outlast the interpreter needs an absolute path, or the workspace. + - **Relative paths** now resolve in the runtime directory, which goes when the interpreter is reclaimed: a script's relative reads and writes, and a relative path in `codeExecution.env`, such as a `PYTHONPATH` entry. A file a script reads from the host, or writes to keep, needs an absolute path, or the workspace, and so does a path in `codeExecution.env`. - **Direct `PyRunner` users:** `PyRunnerOptions` gains `env` and `inheritEnv`, and a runner built without them gets the allowlist. - - **A misread configuration is refused at create:** an `inheritEnv` that isn't `true` or `false`, such as `"false"`, which would have handed over the whole environment, and an `env` that isn't an object of strings. - - **Unchanged:** `pythonPath` as given, since a venv's interpreter finds its prefix and site-packages without `VIRTUAL_ENV`, and everything the allowlist passes. The allowlist limits what model code inherits, not what it can read: on Linux a process running as the same user can still read the host's environment through `/proc//environ`, and any process can open the host's files by absolute path. + - **A misread configuration is refused at create:** an `inheritEnv` that isn't `true` or `false`, such as `"false"`, which would have handed over the whole environment; an `env` that isn't an object of strings; and a variable name that is empty or holds `=` or NUL, or a value that holds NUL. A `null` `inheritEnv` or `env` counts as left out. + - **Unchanged:** `pythonPath`, which a relative path still resolves from the host's working directory, and which a venv's interpreter needs no `VIRTUAL_ENV` for, since it finds its prefix and site-packages itself. Everything the allowlist passes is unchanged too. The allowlist limits what model code inherits, not what it can read: on Linux a process running as the same user can still read the host's environment through `/proc//environ`, and any process can open the host's files by absolute path. diff --git a/src/code-execution/py-runner.ts b/src/code-execution/py-runner.ts index cd22f671a..235661e94 100644 --- a/src/code-execution/py-runner.ts +++ b/src/code-execution/py-runner.ts @@ -18,7 +18,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; import { buildChildEnv } from '../mcpl/transport.js'; import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; import { createInterface, type Interface } from 'node:readline'; import { PYTHON_RUNTIME_SOURCE } from './runtime-py.js'; @@ -117,6 +117,17 @@ interface PendingExec { deadlineMs: number | null; } +/** + * The interpreter to spawn. A bare name is looked up on PATH. A path with a + * separator is resolved against the host's working directory, where it + * resolved before the interpreter got a working directory of its own: spawn + * would resolve it against the runtime directory instead. + */ +function spawnablePath(pythonPath: string): string { + const hasSeparator = pythonPath.includes('/') || (process.platform === 'win32' && pythonPath.includes('\\')); + return hasSeparator ? resolve(pythonPath) : pythonPath; +} + export class PyRunner { private readonly pythonPath: string; private readonly toolCallTimeoutMs: number; @@ -310,7 +321,7 @@ export class PyRunner { const runtimePath = join(this.runtimeDir, 'runtime.py'); writeFileSync(runtimePath, PYTHON_RUNTIME_SOURCE, 'utf8'); - const child = spawn(this.pythonPath, [runtimePath], { + const child = spawn(spawnablePath(this.pythonPath), [runtimePath], { stdio: ['pipe', 'pipe', 'pipe'], // The runtime directory, not the host's working directory: a script's // relative paths land here, and go at teardown, rather than beside the @@ -530,8 +541,8 @@ export class PyRunner { this.reader.close(); this.reader = null; } - if (this.child) { - const child = this.child; + const child = this.child; + if (child) { this.child = null; this.childReady = null; this.readyResolver = null; @@ -545,13 +556,22 @@ export class PyRunner { child.kill('SIGKILL'); } } - if (this.runtimeDir) { - try { - rmSync(this.runtimeDir, { recursive: true, force: true }); - } catch { - // temp cleanup is best-effort - } - this.runtimeDir = null; + const dir = this.runtimeDir; + this.runtimeDir = null; + if (dir) { + const remove = (): void => { + try { + rmSync(dir, { recursive: true, force: true }); + } catch { + // temp cleanup is best-effort + } + }; + // The directory is the interpreter's working directory, which Windows + // won't remove while the process lives, so it goes once the process + // has. A child that never started has no exit to wait for. + const running = child !== null && child.pid !== undefined && child.exitCode === null && child.signalCode === null; + if (running) child.once('exit', remove); + else remove(); } } diff --git a/src/code-execution/tool-definition.ts b/src/code-execution/tool-definition.ts index 729d63896..3463f1fd5 100644 --- a/src/code-execution/tool-definition.ts +++ b/src/code-execution/tool-definition.ts @@ -39,7 +39,9 @@ export function buildCodeExecutionToolDefinition(opts?: { 'Only what you print() (plus stderr and the exit code) comes back to you — intermediate ' + 'tool results stay in the script, so filter or aggregate large data there and print only ' + 'what you need. Interpreter state (variables, imports) persists across code_execution ' + - `calls but is reclaimed after ~${idleMinutes} minutes idle. A tool call that receives no ` + + `calls but is reclaimed after ~${idleMinutes} minutes idle. Relative paths resolve in a ` + + 'scratch directory of the interpreter\'s, which goes when it is reclaimed, so a file meant to ' + + 'outlast the interpreter needs an absolute path. A tool call that receives no ' + `response within ~${callTimeoutSeconds}s raises TimeoutError inside the script. ` + `A foreground script is stopped after ${formatLimit(limits.defaultMs)}; pass time_limit_ms to set this call's limit ` + `(at most ${formatLimit(limits.maxMs)}; a background script runs up to ${formatLimit(limits.backgroundMaxMs)}). ` + @@ -132,20 +134,31 @@ export function validateCodeExecutionConfig(cfg: { 'it is the most an agent may ask for, and the default is always allowed', ); } - if (cfg.inheritEnv !== undefined && typeof cfg.inheritEnv !== 'boolean') { + // No refusal shows the value it refuses, only its kind: a substituted + // value can be a credential. null counts as not set, as JSON writes that. + if (cfg.inheritEnv !== undefined && cfg.inheritEnv !== null && typeof cfg.inheritEnv !== 'boolean') { + const wanted = typeof cfg.inheritEnv === 'string' ? 'true or false, unquoted,' : 'true or false,'; throw new Error( - `codeExecution.inheritEnv must be true or false, not ${kindOf(cfg.inheritEnv, true)}: ` + + `codeExecution.inheritEnv must be ${wanted} not ${kindOf(cfg.inheritEnv)}: ` + "true hands the interpreter the host's whole environment, and false or leaving it out keeps the allowlist", ); } - if (cfg.env !== undefined) { + if (cfg.env !== undefined && cfg.env !== null) { if (!isPlainObject(cfg.env)) { - throw new Error(`codeExecution.env must be an object of variable names and string values, not ${kindOf(cfg.env, false)}`); + throw new Error(`codeExecution.env must be an object of variable names and string values, not ${kindOf(cfg.env)}`); } for (const [name, value] of Object.entries(cfg.env)) { - // The value isn't shown: it may be a credential. + // spawn writes each variable as name=value: a name that is empty or + // holds '=' doesn't reach the interpreter as written, and a NUL in a + // name or a value makes spawn throw at every start. + if (name === '' || name.includes('=') || name.includes('\0')) { + throw new Error(`codeExecution.env has a variable name ${JSON.stringify(name)}, and a name can't be empty or hold '=' or NUL`); + } if (typeof value !== 'string') { - throw new Error(`codeExecution.env.${name} must be a string, not ${kindOf(value, false)}: quote it in the config`); + throw new Error(`codeExecution.env.${name} must be a string, not ${kindOf(value)}: give its value as a string, or leave ${name} out`); + } + if (value.includes('\0')) { + throw new Error(`codeExecution.env.${name} holds a NUL character, which no environment variable can carry`); } } } @@ -157,12 +170,13 @@ function isPlainObject(value: unknown): value is Record { return proto === Object.prototype || proto === null; } -/** What a configured value is, for an error: its kind, and with `show` a primitive's value too. */ -function kindOf(value: unknown, show: boolean): string { +/** What kind of value a configuration holds, for an error that names it without showing it. */ +function kindOf(value: unknown): string { if (value === null) return 'null'; + if (value === undefined) return 'undefined'; if (Array.isArray(value)) return 'an array'; - const type = typeof value; - const kind = type === 'object' ? 'an object' : `a ${type}`; - if (!show || type === 'object' || type === 'function') return kind; - return `${type === 'string' ? JSON.stringify(value) : String(value)} (${kind})`; + if (typeof value !== 'object') return `a ${typeof value}`; + if (isPlainObject(value)) return 'an object'; + const name = (Object.getPrototypeOf(value) as { constructor?: { name?: string } }).constructor?.name; + return name ? `a ${name}` : 'an object that isn\'t plain'; } diff --git a/src/types/framework.ts b/src/types/framework.ts index 5535b928d..34eb3cdea 100644 --- a/src/types/framework.ts +++ b/src/types/framework.ts @@ -78,12 +78,17 @@ export interface CodeExecutionConfig { * inherits (see CHILD_ENV_ALLOWLIST), such as PYTHONPATH. A recipe can * `${VAR}`-substitute them from .env. Other host variables aren't * inherited, which limits what model-authored code inherits, not what a - * process running as the same user can read. */ + * process running as the same user can read. The runner sets + * PYTHONUNBUFFERED and PYTHONIOENCODING last, over any value given here, + * since its protocol needs both. The interpreter's working directory is + * its own runtime directory, so a relative path given here, as in + * PYTHONPATH, resolves there: give absolute paths. */ env?: Record; /** The interpreter inherits the host's whole environment instead of the * allowlist: every host variable, provider keys included, reaches * model-authored code and every subprocess a script starts. Listing what - * scripts need in `env` is the safer route. */ + * scripts need in `env` is the safer route. null counts as not set, and + * `create()` refuses any value but true or false. */ inheritEnv?: boolean; } diff --git a/test/code-execution-env.test.ts b/test/code-execution-env.test.ts index 7e407b6c9..2f69bbf41 100644 --- a/test/code-execution-env.test.ts +++ b/test/code-execution-env.test.ts @@ -8,37 +8,63 @@ */ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { existsSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { basename, join } from 'node:path'; import { PyRunner } from '../src/index.js'; import { validateCodeExecutionConfig } from '../src/code-execution/tool-definition.js'; -test('a codeExecution.inheritEnv that is not a boolean is refused, so "false" never hands over the host env', () => { - for (const inheritEnv of ['false', 'true', null, 0, 1, [], {}]) { - assert.throws(() => validateCodeExecutionConfig({ inheritEnv }), /codeExecution\.inheritEnv must be true or false/, - JSON.stringify(inheritEnv)); +test('a codeExecution.inheritEnv that is not a boolean is refused by its kind, so "false" never hands over the host env', () => { + const kinds: Array<[unknown, string]> = [ + ['false', 'true or false, unquoted, not a string'], ['true', 'true or false, unquoted, not a string'], + [0, 'true or false, not a number'], [1, 'true or false, not a number'], + [[], 'true or false, not an array'], [{}, 'true or false, not an object'], + ]; + for (const [inheritEnv, wanted] of kinds) { + assert.throws(() => validateCodeExecutionConfig({ inheritEnv }), (err: Error) => { + assert.ok(err.message.startsWith(`codeExecution.inheritEnv must be ${wanted}: `), err.message); + return true; + }, JSON.stringify(inheritEnv)); } - assert.throws(() => validateCodeExecutionConfig({ inheritEnv: 'false' }), /not "false" \(a string\)/); - for (const inheritEnv of [undefined, true, false]) validateCodeExecutionConfig({ inheritEnv }); + // null is how JSON writes "not set". + for (const inheritEnv of [undefined, null, true, false]) validateCodeExecutionConfig({ inheritEnv }); }); -test('a codeExecution.env that is not an object of strings is refused, naming the variable but not its value', () => { - for (const env of [null, [], 'PYTHONPATH=/opt/lib', 7]) { - assert.throws(() => validateCodeExecutionConfig({ env }), /codeExecution\.env must be an object of variable names and string values/, - JSON.stringify(env)); +test('a codeExecution.env that is not an object of strings is refused, naming the variable and its value\'s kind but not the value', () => { + const envKinds: Array<[unknown, string]> = [[[], 'an array'], ['PYTHONPATH=/opt/lib', 'a string'], [7, 'a number'], [new Map(), 'a Map']]; + for (const [env, kind] of envKinds) { + assert.throws(() => validateCodeExecutionConfig({ env }), (err: Error) => { + assert.equal(err.message, `codeExecution.env must be an object of variable names and string values, not ${kind}`); + return true; + }, String(kind)); } - for (const value of [8080, true, null, ['x'], { nested: 'y' }]) { + const valueKinds: Array<[unknown, string]> = [[8080, 'a number'], [true, 'a boolean'], [null, 'null'], [undefined, 'undefined'], [['x'], 'an array'], [{ nested: 'y' }, 'an object']]; + for (const [value, kind] of valueKinds) { assert.throws(() => validateCodeExecutionConfig({ env: { PYTHONPATH: '/opt/lib', BAD: value } }), (err: Error) => { - assert.match(err.message, /^codeExecution\.env\.BAD must be a string/); - assert.ok(!err.message.includes('8080'), err.message); + assert.equal(err.message, `codeExecution.env.BAD must be a string, not ${kind}: give its value as a string, or leave BAD out`); return true; - }, JSON.stringify(value)); + }, String(kind)); } validateCodeExecutionConfig({ env: {} }); + validateCodeExecutionConfig({ env: null }); validateCodeExecutionConfig({ env: { PYTHONPATH: '/opt/lib', EMPTY: '' } }); }); +test('a codeExecution.env name spawn could not pass as written, or a value holding NUL, is refused at create', () => { + for (const name of ['', 'PYTHONPATH=/opt/lib', 'NU\0L']) { + assert.throws(() => validateCodeExecutionConfig({ env: { [name]: 'x' } }), (err: Error) => { + assert.equal(err.message, `codeExecution.env has a variable name ${JSON.stringify(name)}, and a name can't be empty or hold '=' or NUL`); + return true; + }, JSON.stringify(name)); + } + assert.throws(() => validateCodeExecutionConfig({ env: { SECRET: 'sk-\0live' } }), (err: Error) => { + assert.equal(err.message, 'codeExecution.env.SECRET holds a NUL character, which no environment variable can carry'); + return true; + }); +}); + test('model-authored python does not inherit a host variable outside the allowlist', async () => { const key = 'AF_CODE_EXEC_TEST_HOST_SECRET'; process.env[key] = 'must-not-leak'; @@ -120,8 +146,33 @@ test("the interpreter runs in its own runtime directory, which goes at teardown, assert.match(basename(cwd), /^af-pytc-/, cwd); assert.ok(existsSync(join(cwd, 'scratch.txt')), 'a relative path lands in the runtime directory'); runner.dispose(); + // It goes once the killed interpreter has exited, which dispose() doesn't wait for. + const deadline = Date.now() + 10_000; + while (existsSync(cwd) && Date.now() < deadline) await new Promise((r) => setTimeout(r, 10)); assert.ok(!existsSync(cwd), 'the runtime directory goes at teardown, with what the script wrote there'); } finally { runner.dispose(); } }); + +test("a relative pythonPath resolves from the host's working directory, not the interpreter's", async () => { + const python = execFileSync('python3', ['-c', 'import sys; print(sys.executable)'], { encoding: 'utf8' }).trim(); + // bin/python under a host directory of its own: a relative path that + // doesn't climb to the root, so it finds the interpreter from that + // directory and from no other. + const hostDir = mkdtempSync(join(tmpdir(), 'pytc-host-')); + mkdirSync(join(hostDir, 'bin')); + symlinkSync(python, join(hostDir, 'bin', 'python')); + const previousCwd = process.cwd(); + process.chdir(hostDir); + const runner = new PyRunner({ onToolCall: async () => '', pythonPath: join('bin', 'python') }); + try { + const r = await runner.exec('print("started")', []); + assert.equal(r.returnCode, 0, r.stderr); + assert.match(r.stdout, /started/); + } finally { + runner.dispose(); + process.chdir(previousCwd); + rmSync(hostDir, { recursive: true, force: true }); + } +}); diff --git a/test/code-execution.test.ts b/test/code-execution.test.ts index a02640978..d5923bcdd 100644 --- a/test/code-execution.test.ts +++ b/test/code-execution.test.ts @@ -465,6 +465,40 @@ describe('framework code_execution integration (real python3)', () => { } }); + it("with codeExecution.inheritEnv: true, a script gets the host's variables beside codeExecution.env", async () => { + const { tempDir, storePath } = tempStorePath('pytc-envinherit-'); + const membrane = new MockMembrane(); + const code = `import os; print("declared:", os.environ.get("${DECLARED}"), "host:", repr(os.environ.get("${HOST_ONLY}")))`; + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'call_env_2', name: 'code_execution', input: { code } }, + ], 'tool_use')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Done.' }])); + process.env[HOST_ONLY] = 'host-value'; + const framework = await AgentFramework.create({ + storePath, + membrane: membrane.asMembrane(), + agents: [], + modules: [new ScriptToolModule()], + syncIntervalMs: 0, + codeExecution: { enabled: true, env: { [DECLARED]: 'from-config' }, inheritEnv: true }, + }); + try { + const created = await framework.createEphemeralAgent({ + name: 'worker', model: 'test-model', systemPrompt: 'Do the task.', allowedTools: 'all', + }); + created.contextManager.addMessage('user', [{ type: 'text', text: 'Go.' }]); + const promise = framework.runEphemeralToCompletion(created.agent, created.contextManager); + framework.start(); + await promise; + const wireResult = membrane.lastStream!.receivedToolResults[0][0] as { content: string }; + assert.match(wireResult.content, /declared: from-config host: 'host-value'/, wireResult.content); + } finally { + await framework.stop(); + delete process.env[HOST_ONLY]; + rmSync(tempDir, { recursive: true, force: true }); + } + }); + it('create() refuses a codeExecution.inheritEnv that is not a boolean', async () => { const { tempDir, storePath } = tempStorePath('pytc-inherit-'); try { @@ -475,7 +509,7 @@ describe('framework code_execution integration (real python3)', () => { modules: [new ScriptToolModule()], syncIntervalMs: 0, codeExecution: { enabled: true, inheritEnv: 'false' as unknown as boolean }, - }), /codeExecution\.inheritEnv must be true or false, not "false" \(a string\)/); + }), /codeExecution\.inheritEnv must be true or false, unquoted, not a string: /); } finally { rmSync(tempDir, { recursive: true, force: true }); } @@ -892,6 +926,39 @@ describe('framework background scripts + spill (real python3)', () => { } }); + it("with codeExecution.inheritEnv: true, a background script gets the host's variables beside codeExecution.env", async () => { + const { tempDir, storePath } = tempStorePath('pytc-bgenvinherit-'); + const membrane = new MockMembrane(); + const bgCode = `import os\nawait wake_agent({"declared": os.environ.get("${DECLARED}"), "host": os.environ.get("${HOST_ONLY}")})`; + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'call_bgenv_inherit', name: 'code_execution', input: { code: bgCode, background: true } }, + ], 'tool_use')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Armed.' }])); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Woke.' }])); + process.env[HOST_ONLY] = 'host-value'; + const framework = await createBgFramework(storePath, membrane, { codeExecution: { env: { [DECLARED]: 'from-config' }, inheritEnv: true } }); + try { + framework.start(); + framework.pushEvent({ + type: 'external-message', source: 'test', content: [{ type: 'text', text: 'arm it' }], metadata: {}, triggerInference: true, + } as unknown as ProcessEvent); + const deadline = Date.now() + 20_000; + let wakeText = ''; + while (Date.now() < deadline && !wakeText) { + await new Promise((r) => setTimeout(r, 100)); + const msgs = (framework.getAgent('prime')?.getContextManager().queryMessages({}).messages ?? []) as unknown as Array<{ content: ContentBlockLike[]; metadata?: { source?: string } }>; + const wake = msgs.find((m) => m.metadata?.source === 'background-script'); + wakeText = (wake?.content[0] as { text?: string } | undefined)?.text ?? ''; + } + assert.match(wakeText, /"declared": "from-config"/, wakeText); + assert.match(wakeText, /"host": "host-value"/, wakeText); + } finally { + await framework.stop(); + delete process.env[HOST_ONLY]; + rmSync(tempDir, { recursive: true, force: true }); + } + }); + it('list and cancel manage the daemon fleet; cap enforced', async () => { const { tempDir, storePath } = tempStorePath('pytc-bglist-'); const membrane = new MockMembrane(); From 82ce028411d71d4fcf91117133888dbb07083ec7 Mon Sep 17 00:00:00 2001 From: Effie-2756 Date: Sat, 17 Oct 2026 08:26:43 -0400 Subject: [PATCH 14/18] fix(code-execution): answer the fix round's review: removal timing, an absolute runtime directory, names and direct runners Our internal review of the previous commit found that waiting for the interpreter's exit before removing its runtime directory left it behind for a host that exits at once, that a relative TMPDIR stopped the interpreter from starting, that the env-name refusal could print a value pasted into a name, and that a PyRunner built directly reads inheritEnv unchecked (R1-2, R1-5). - The runtime directory goes at teardown again, except on Windows, where the interpreter's exit is waited for, since Windows won't remove a live process's working directory. A host that exited right after dispose() left it behind everywhere. - The runtime directory is made absolute. From a relative TMPDIR, the interpreter, which now runs in that directory, resolved runtime.py's relative path against the directory itself and couldn't start. - The env-name refusal shows only what comes before an '=' or NUL, since what follows can be a value pasted into the name: `codeExecution.env has a variable name starting "TOKEN" that holds '=' or NUL`. - PyRunner reads inheritEnv only as `=== true`, so a runner built directly, whose options create() never checks, fails closed. - Text: the tool description says the scratch directory goes whenever the interpreter stops or is reclaimed; the inheritEnv doc says create() refuses only with code execution enabled, and that null counts as not set; the breaking entry names a relative PATH entry that a bare pythonPath is looked up on (spawn resolves it against the cwd it is given, probed on Node 24), drops "or the workspace", and says how a direct PyRunner reads its options. Since PyRunner now reads only true, the validator's comment and the breaking entry no longer say a quoted "false" would hand over the whole environment: the refusal's reason is a quoted "true", which the runner would read as false, quietly keeping the allowlist. The refusal's test is titled for what it checks. Tests: an interpreter that never starts leaves no runtime directory behind; the interpreter starts from a relative TMPDIR; a PyRunner built directly with inheritEnv "false" gets the allowlist; the dispose test checks the directory is gone at once off Windows; the inheritEnv and name refusals are pinned whole; the kindOf fallback for an object whose prototype has no constructor name; and the tests that change the cwd or set a host variable do so inside their try. Co-Authored-By: Claude Opus 5.5 --- .../fix-code-execution-child-env.breaking.md | 8 +- src/code-execution/py-runner.ts | 16 ++- src/code-execution/tool-definition.ts | 15 ++- src/types/framework.ts | 5 +- test/code-execution-env.test.ts | 111 +++++++++++++++--- test/code-execution.test.ts | 8 +- 6 files changed, 128 insertions(+), 35 deletions(-) diff --git a/changelog.d/fix-code-execution-child-env.breaking.md b/changelog.d/fix-code-execution-child-env.breaking.md index e40d597b2..1ed5b7110 100644 --- a/changelog.d/fix-code-execution-child-env.breaking.md +++ b/changelog.d/fix-code-execution-child-env.breaking.md @@ -1,6 +1,6 @@ - **Operators with `codeExecution` enabled:** the python interpreter that runs model-authored scripts no longer inherits the host's environment, and it starts in its own runtime directory instead of the host's working directory. It gets the allowlist stdio MCPL children get (#175): `PATH`, `HOME`, the locale, temp directories, CA bundles and proxy variables, whose URLs can carry credentials. Then come `codeExecution.env`, and last `PYTHONUNBUFFERED=1` and `PYTHONIOENCODING=utf-8`, which the runner sets over any declared value. - **What stops arriving:** every host variable outside the allowlist, such as `PYTHONPATH`, `VIRTUAL_ENV`, `PYTHONHOME`, `LD_LIBRARY_PATH`, `PYTHONUTF8` and `ALL_PROXY`, and any credential a script read from `os.environ`. To get one back, list it in `codeExecution.env`, which a recipe can `${VAR}`-substitute from `.env`. `codeExecution.inheritEnv: true` restores the whole environment, and with it hands every host variable, provider keys included, to model code and to every subprocess a script starts. - - **Relative paths** now resolve in the runtime directory, which goes when the interpreter is reclaimed: a script's relative reads and writes, and a relative path in `codeExecution.env`, such as a `PYTHONPATH` entry. A file a script reads from the host, or writes to keep, needs an absolute path, or the workspace, and so does a path in `codeExecution.env`. - - **Direct `PyRunner` users:** `PyRunnerOptions` gains `env` and `inheritEnv`, and a runner built without them gets the allowlist. - - **A misread configuration is refused at create:** an `inheritEnv` that isn't `true` or `false`, such as `"false"`, which would have handed over the whole environment; an `env` that isn't an object of strings; and a variable name that is empty or holds `=` or NUL, or a value that holds NUL. A `null` `inheritEnv` or `env` counts as left out. - - **Unchanged:** `pythonPath`, which a relative path still resolves from the host's working directory, and which a venv's interpreter needs no `VIRTUAL_ENV` for, since it finds its prefix and site-packages itself. Everything the allowlist passes is unchanged too. The allowlist limits what model code inherits, not what it can read: on Linux a process running as the same user can still read the host's environment through `/proc//environ`, and any process can open the host's files by absolute path. + - **Relative paths** now resolve in the runtime directory, which goes whenever the interpreter stops or is reclaimed: a script's relative reads and writes, a relative path in `codeExecution.env`, such as a `PYTHONPATH` entry, and a relative `PATH` entry that a bare `pythonPath` is looked up on. A file a script reads from the host, or writes to keep, needs an absolute path, as does a path in `codeExecution.env`. + - **Direct `PyRunner` users:** `PyRunnerOptions` gains `env` and `inheritEnv`, and a runner built without them gets the allowlist. Only `inheritEnv: true` passes the whole environment, and these options aren't checked as `create()` checks `codeExecution`'s. + - **A misread configuration is refused at create:** an `inheritEnv` that isn't `true` or `false`, such as a quoted `"true"`, which would quietly keep the allowlist; an `env` that isn't an object of strings; and a variable name that is empty or holds `=` or NUL, or a value that holds NUL. A `null` `inheritEnv` or `env` counts as left out. + - **Unchanged:** a `pythonPath` with a separator, which still resolves from the host's working directory, and which a venv's interpreter needs no `VIRTUAL_ENV` for, since it finds its prefix and site-packages itself. Everything the allowlist passes is unchanged too. The allowlist limits what model code inherits, not what it can read: on Linux a process running as the same user can still read the host's environment through `/proc//environ`, and any process can open the host's files by absolute path. diff --git a/src/code-execution/py-runner.ts b/src/code-execution/py-runner.ts index 235661e94..5bb049d96 100644 --- a/src/code-execution/py-runner.ts +++ b/src/code-execution/py-runner.ts @@ -163,7 +163,9 @@ export class PyRunner { // as options, not a snapshot — the env is rebuilt at every spawn so a // respawn after idle reclaim sees current host values, as the old // per-spawn `{ ...process.env }` did. - this.envOptions = { env: options.env, inheritEnv: options.inheritEnv }; + // Only true inherits: a direct caller's options aren't checked as + // create() checks codeExecution's. + this.envOptions = { env: options.env, inheritEnv: options.inheritEnv === true }; } get busy(): boolean { @@ -317,7 +319,9 @@ export class PyRunner { } this.teardownChild(); - this.runtimeDir = mkdtempSync(join(tmpdir(), 'af-pytc-')); + // Absolute, since the interpreter runs in it: from a relative TMPDIR, + // runtime.py's path would otherwise resolve against the directory itself. + this.runtimeDir = resolve(mkdtempSync(join(tmpdir(), 'af-pytc-'))); const runtimePath = join(this.runtimeDir, 'runtime.py'); writeFileSync(runtimePath, PYTHON_RUNTIME_SOURCE, 'utf8'); @@ -567,10 +571,12 @@ export class PyRunner { } }; // The directory is the interpreter's working directory, which Windows - // won't remove while the process lives, so it goes once the process - // has. A child that never started has no exit to wait for. + // won't remove while the process lives, so there it goes once the + // process has. Elsewhere it goes now, so a host that exits right after + // this doesn't leave it behind. A child that never started has no exit + // to wait for. const running = child !== null && child.pid !== undefined && child.exitCode === null && child.signalCode === null; - if (running) child.once('exit', remove); + if (running && process.platform === 'win32') child.once('exit', remove); else remove(); } } diff --git a/src/code-execution/tool-definition.ts b/src/code-execution/tool-definition.ts index 3463f1fd5..d0ab53717 100644 --- a/src/code-execution/tool-definition.ts +++ b/src/code-execution/tool-definition.ts @@ -40,7 +40,7 @@ export function buildCodeExecutionToolDefinition(opts?: { 'tool results stay in the script, so filter or aggregate large data there and print only ' + 'what you need. Interpreter state (variables, imports) persists across code_execution ' + `calls but is reclaimed after ~${idleMinutes} minutes idle. Relative paths resolve in a ` + - 'scratch directory of the interpreter\'s, which goes when it is reclaimed, so a file meant to ' + + 'scratch directory of the interpreter\'s, which goes whenever it stops or is reclaimed, so a file meant to ' + 'outlast the interpreter needs an absolute path. A tool call that receives no ' + `response within ~${callTimeoutSeconds}s raises TimeoutError inside the script. ` + `A foreground script is stopped after ${formatLimit(limits.defaultMs)}; pass time_limit_ms to set this call's limit ` + @@ -117,9 +117,11 @@ export function scriptTimeLimits(opts?: { /** * Refuse a configuration whose time limits contradict each other, or whose - * interpreter environment would be misread. A recipe's `"inheritEnv": "false"`, - * or a `${VAR}` substituted as a string, arrives untyped, and any truthy value - * would hand the interpreter the host's whole environment. + * interpreter environment would be misread. A recipe's `"inheritEnv": "true"`, + * or a `${VAR}` substituted as a string, arrives untyped, and the runner passes + * the host's environment only for `true`, so such a value would quietly keep + * the allowlist. Anything but true, false or null is refused, a quoted "false" + * included. */ export function validateCodeExecutionConfig(cfg: { scriptTimeoutMs?: number; @@ -151,8 +153,11 @@ export function validateCodeExecutionConfig(cfg: { // spawn writes each variable as name=value: a name that is empty or // holds '=' doesn't reach the interpreter as written, and a NUL in a // name or a value makes spawn throw at every start. + // Only what comes before an '=' or NUL is shown: what follows can be a + // value pasted into the name. if (name === '' || name.includes('=') || name.includes('\0')) { - throw new Error(`codeExecution.env has a variable name ${JSON.stringify(name)}, and a name can't be empty or hold '=' or NUL`); + const shown = name === '' ? 'that is empty' : `starting ${JSON.stringify(name.split(/[=\0]/, 1)[0])} that holds '=' or NUL`; + throw new Error(`codeExecution.env has a variable name ${shown}, and a name can't be empty or hold '=' or NUL`); } if (typeof value !== 'string') { throw new Error(`codeExecution.env.${name} must be a string, not ${kindOf(value)}: give its value as a string, or leave ${name} out`); diff --git a/src/types/framework.ts b/src/types/framework.ts index 34eb3cdea..7b3fbc67a 100644 --- a/src/types/framework.ts +++ b/src/types/framework.ts @@ -87,8 +87,9 @@ export interface CodeExecutionConfig { /** The interpreter inherits the host's whole environment instead of the * allowlist: every host variable, provider keys included, reaches * model-authored code and every subprocess a script starts. Listing what - * scripts need in `env` is the safer route. null counts as not set, and - * `create()` refuses any value but true or false. */ + * scripts need in `env` is the safer route. With code execution enabled, + * `create()` refuses any value but true, false or null, which counts as + * not set. */ inheritEnv?: boolean; } diff --git a/test/code-execution-env.test.ts b/test/code-execution-env.test.ts index 2f69bbf41..018de1e07 100644 --- a/test/code-execution-env.test.ts +++ b/test/code-execution-env.test.ts @@ -9,14 +9,14 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; -import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, symlinkSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { basename, join } from 'node:path'; import { PyRunner } from '../src/index.js'; import { validateCodeExecutionConfig } from '../src/code-execution/tool-definition.js'; -test('a codeExecution.inheritEnv that is not a boolean is refused by its kind, so "false" never hands over the host env', () => { +test('a codeExecution.inheritEnv set to anything but true, false or null is refused, naming its kind but not its value', () => { const kinds: Array<[unknown, string]> = [ ['false', 'true or false, unquoted, not a string'], ['true', 'true or false, unquoted, not a string'], [0, 'true or false, not a number'], [1, 'true or false, not a number'], @@ -24,7 +24,8 @@ test('a codeExecution.inheritEnv that is not a boolean is refused by its kind, s ]; for (const [inheritEnv, wanted] of kinds) { assert.throws(() => validateCodeExecutionConfig({ inheritEnv }), (err: Error) => { - assert.ok(err.message.startsWith(`codeExecution.inheritEnv must be ${wanted}: `), err.message); + assert.equal(err.message, `codeExecution.inheritEnv must be ${wanted}: ` + + "true hands the interpreter the host's whole environment, and false or leaving it out keeps the allowlist"); return true; }, JSON.stringify(inheritEnv)); } @@ -33,7 +34,10 @@ test('a codeExecution.inheritEnv that is not a boolean is refused by its kind, s }); test('a codeExecution.env that is not an object of strings is refused, naming the variable and its value\'s kind but not the value', () => { - const envKinds: Array<[unknown, string]> = [[[], 'an array'], ['PYTHONPATH=/opt/lib', 'a string'], [7, 'a number'], [new Map(), 'a Map']]; + const envKinds: Array<[unknown, string]> = [ + [[], 'an array'], ['PYTHONPATH=/opt/lib', 'a string'], [7, 'a number'], [new Map(), 'a Map'], + [Object.create(Object.create(null)), "an object that isn't plain"], + ]; for (const [env, kind] of envKinds) { assert.throws(() => validateCodeExecutionConfig({ env }), (err: Error) => { assert.equal(err.message, `codeExecution.env must be an object of variable names and string values, not ${kind}`); @@ -52,10 +56,16 @@ test('a codeExecution.env that is not an object of strings is refused, naming th validateCodeExecutionConfig({ env: { PYTHONPATH: '/opt/lib', EMPTY: '' } }); }); -test('a codeExecution.env name spawn could not pass as written, or a value holding NUL, is refused at create', () => { - for (const name of ['', 'PYTHONPATH=/opt/lib', 'NU\0L']) { +test('a codeExecution.env name spawn could not pass as written, or a value holding NUL, is refused, showing nothing after an = or NUL', () => { + const names: Array<[string, string]> = [ + ['', 'that is empty'], + ['PYTHONPATH=/opt/lib', `starting "PYTHONPATH" that holds '=' or NUL`], + ['TOKEN=sk-live-secret', `starting "TOKEN" that holds '=' or NUL`], + ['NU\0L', `starting "NU" that holds '=' or NUL`], + ]; + for (const [name, shown] of names) { assert.throws(() => validateCodeExecutionConfig({ env: { [name]: 'x' } }), (err: Error) => { - assert.equal(err.message, `codeExecution.env has a variable name ${JSON.stringify(name)}, and a name can't be empty or hold '=' or NUL`); + assert.equal(err.message, `codeExecution.env has a variable name ${shown}, and a name can't be empty or hold '=' or NUL`); return true; }, JSON.stringify(name)); } @@ -146,9 +156,11 @@ test("the interpreter runs in its own runtime directory, which goes at teardown, assert.match(basename(cwd), /^af-pytc-/, cwd); assert.ok(existsSync(join(cwd, 'scratch.txt')), 'a relative path lands in the runtime directory'); runner.dispose(); - // It goes once the killed interpreter has exited, which dispose() doesn't wait for. - const deadline = Date.now() + 10_000; - while (existsSync(cwd) && Date.now() < deadline) await new Promise((r) => setTimeout(r, 10)); + if (process.platform === 'win32') { + // There it goes once the killed interpreter has exited, which dispose() doesn't wait for. + const deadline = Date.now() + 10_000; + while (existsSync(cwd) && Date.now() < deadline) await new Promise((r) => setTimeout(r, 10)); + } assert.ok(!existsSync(cwd), 'the runtime directory goes at teardown, with what the script wrote there'); } finally { runner.dispose(); @@ -161,18 +173,87 @@ test("a relative pythonPath resolves from the host's working directory, not the // doesn't climb to the root, so it finds the interpreter from that // directory and from no other. const hostDir = mkdtempSync(join(tmpdir(), 'pytc-host-')); - mkdirSync(join(hostDir, 'bin')); - symlinkSync(python, join(hostDir, 'bin', 'python')); const previousCwd = process.cwd(); - process.chdir(hostDir); - const runner = new PyRunner({ onToolCall: async () => '', pythonPath: join('bin', 'python') }); + let runner: PyRunner | undefined; try { + mkdirSync(join(hostDir, 'bin')); + symlinkSync(python, join(hostDir, 'bin', 'python')); + process.chdir(hostDir); + runner = new PyRunner({ onToolCall: async () => '', pythonPath: join('bin', 'python') }); const r = await runner.exec('print("started")', []); assert.equal(r.returnCode, 0, r.stderr); assert.match(r.stdout, /started/); } finally { - runner.dispose(); + runner?.dispose(); process.chdir(previousCwd); rmSync(hostDir, { recursive: true, force: true }); } }); + +/** Run `fn` with os.tmpdir() reading `dir`, as TMPDIR sets it on POSIX. */ +async function withTmpdir(dir: string, fn: () => Promise): Promise { + const previous = process.env.TMPDIR; + process.env.TMPDIR = dir; + try { + return await fn(); + } finally { + if (previous === undefined) delete process.env.TMPDIR; + else process.env.TMPDIR = previous; + } +} + +test('an interpreter that never starts leaves no runtime directory behind', { skip: process.platform === 'win32' }, async () => { + const tmp = mkdtempSync(join(tmpdir(), 'pytc-tmp-')); + try { + await withTmpdir(tmp, async () => { + const runner = new PyRunner({ onToolCall: async () => '', pythonPath: join(tmp, 'no-such-python') }); + try { + const r = await runner.exec('print(1)', []); + assert.equal(r.returnCode, 1); + assert.match(r.stderr, /Failed to start python runtime/, r.stderr); + assert.deepEqual(readdirSync(tmp), [], 'the runtime directory went with the failed start'); + } finally { + runner.dispose(); + } + }); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test('the interpreter starts from a relative TMPDIR, since its runtime directory is made absolute', { skip: process.platform === 'win32' }, async () => { + const hostDir = mkdtempSync(join(tmpdir(), 'pytc-host-')); + const previousCwd = process.cwd(); + try { + mkdirSync(join(hostDir, 'tmp')); + process.chdir(hostDir); + await withTmpdir('tmp', async () => { + const runner = new PyRunner({ onToolCall: async () => '' }); + try { + const r = await runner.exec('import os; print(os.getcwd())', []); + assert.equal(r.returnCode, 0, r.stderr); + assert.match(basename(r.stdout.trim()), /^af-pytc-/, r.stdout); + } finally { + runner.dispose(); + } + }); + } finally { + process.chdir(previousCwd); + rmSync(hostDir, { recursive: true, force: true }); + } +}); + +test('a PyRunner built directly passes the host env only for inheritEnv: true, whatever else its options hold', async () => { + const key = 'AF_CODE_EXEC_TEST_DIRECT_SECRET'; + let runner: PyRunner | undefined; + try { + process.env[key] = 'must-not-leak'; + runner = new PyRunner({ onToolCall: async () => '', inheritEnv: 'false' as unknown as boolean }); + const r = await runner.exec(`import os; print(repr(os.environ.get('${key}')))`, []); + assert.equal(r.returnCode, 0, r.stderr); + assert.match(r.stdout, /^None$/m, r.stdout); + } finally { + runner?.dispose(); + delete process.env[key]; + } +}); diff --git a/test/code-execution.test.ts b/test/code-execution.test.ts index d5923bcdd..291a896ac 100644 --- a/test/code-execution.test.ts +++ b/test/code-execution.test.ts @@ -439,7 +439,6 @@ describe('framework code_execution integration (real python3)', () => { { type: 'tool_use', id: 'call_env_1', name: 'code_execution', input: { code } }, ], 'tool_use')); membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Done.' }])); - process.env[HOST_ONLY] = 'host-value'; const framework = await AgentFramework.create({ storePath, membrane: membrane.asMembrane(), @@ -449,6 +448,7 @@ describe('framework code_execution integration (real python3)', () => { codeExecution: { enabled: true, env: { [DECLARED]: 'from-config' } }, }); try { + process.env[HOST_ONLY] = 'host-value'; const created = await framework.createEphemeralAgent({ name: 'worker', model: 'test-model', systemPrompt: 'Do the task.', allowedTools: 'all', }); @@ -473,7 +473,6 @@ describe('framework code_execution integration (real python3)', () => { { type: 'tool_use', id: 'call_env_2', name: 'code_execution', input: { code } }, ], 'tool_use')); membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Done.' }])); - process.env[HOST_ONLY] = 'host-value'; const framework = await AgentFramework.create({ storePath, membrane: membrane.asMembrane(), @@ -483,6 +482,7 @@ describe('framework code_execution integration (real python3)', () => { codeExecution: { enabled: true, env: { [DECLARED]: 'from-config' }, inheritEnv: true }, }); try { + process.env[HOST_ONLY] = 'host-value'; const created = await framework.createEphemeralAgent({ name: 'worker', model: 'test-model', systemPrompt: 'Do the task.', allowedTools: 'all', }); @@ -902,9 +902,9 @@ describe('framework background scripts + spill (real python3)', () => { ], 'tool_use')); membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Armed.' }])); membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Woke.' }])); - process.env[HOST_ONLY] = 'host-value'; const framework = await createBgFramework(storePath, membrane, { codeExecution: { env: { [DECLARED]: 'from-config' } } }); try { + process.env[HOST_ONLY] = 'host-value'; framework.start(); framework.pushEvent({ type: 'external-message', source: 'test', content: [{ type: 'text', text: 'arm it' }], metadata: {}, triggerInference: true, @@ -935,9 +935,9 @@ describe('framework background scripts + spill (real python3)', () => { ], 'tool_use')); membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Armed.' }])); membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Woke.' }])); - process.env[HOST_ONLY] = 'host-value'; const framework = await createBgFramework(storePath, membrane, { codeExecution: { env: { [DECLARED]: 'from-config' }, inheritEnv: true } }); try { + process.env[HOST_ONLY] = 'host-value'; framework.start(); framework.pushEvent({ type: 'external-message', source: 'test', content: [{ type: 'text', text: 'arm it' }], metadata: {}, triggerInference: true, From 80d3828d23831045e7d155b5f6cbc99f84058e13 Mon Sep 17 00:00:00 2001 From: Effie-2756 Date: Sat, 17 Oct 2026 09:09:03 -0400 Subject: [PATCH 15/18] docs(code-execution): narrow the refusals' comment and the /proc sentence to what holds (R1-1) The comment above the environment's refusals said no refusal shows the value it refuses. The time-limit refusal above it, which predates this change, prints both limits as given, so the comment now speaks for the environment's refusals only. The breaking entry's last bullet said a process running as the same user can read the host's environment through /proc//environ. That file holds the environment a process started with, not variables it set or loaded afterwards. A python child given only PATH read a variable its Node 24.19.0 parent had at exec, but not one the parent set in process.env afterwards, nor one Bun 1.4.3 loaded from a .env in its working directory; it did read a variable a sibling was spawned with (Linux 6.18, Yama ptrace_scope 1). So the bullet now names the environment the host and its MCPL servers started with, and the host's files, .env included, by absolute path, for a process running as the same user. Co-Authored-By: Claude Opus 5.5 --- changelog.d/fix-code-execution-child-env.breaking.md | 2 +- src/code-execution/tool-definition.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/changelog.d/fix-code-execution-child-env.breaking.md b/changelog.d/fix-code-execution-child-env.breaking.md index 1ed5b7110..521777299 100644 --- a/changelog.d/fix-code-execution-child-env.breaking.md +++ b/changelog.d/fix-code-execution-child-env.breaking.md @@ -3,4 +3,4 @@ - **Relative paths** now resolve in the runtime directory, which goes whenever the interpreter stops or is reclaimed: a script's relative reads and writes, a relative path in `codeExecution.env`, such as a `PYTHONPATH` entry, and a relative `PATH` entry that a bare `pythonPath` is looked up on. A file a script reads from the host, or writes to keep, needs an absolute path, as does a path in `codeExecution.env`. - **Direct `PyRunner` users:** `PyRunnerOptions` gains `env` and `inheritEnv`, and a runner built without them gets the allowlist. Only `inheritEnv: true` passes the whole environment, and these options aren't checked as `create()` checks `codeExecution`'s. - **A misread configuration is refused at create:** an `inheritEnv` that isn't `true` or `false`, such as a quoted `"true"`, which would quietly keep the allowlist; an `env` that isn't an object of strings; and a variable name that is empty or holds `=` or NUL, or a value that holds NUL. A `null` `inheritEnv` or `env` counts as left out. - - **Unchanged:** a `pythonPath` with a separator, which still resolves from the host's working directory, and which a venv's interpreter needs no `VIRTUAL_ENV` for, since it finds its prefix and site-packages itself. Everything the allowlist passes is unchanged too. The allowlist limits what model code inherits, not what it can read: on Linux a process running as the same user can still read the host's environment through `/proc//environ`, and any process can open the host's files by absolute path. + - **Unchanged:** a `pythonPath` with a separator, which still resolves from the host's working directory, and which a venv's interpreter needs no `VIRTUAL_ENV` for, since it finds its prefix and site-packages itself. Everything the allowlist passes is unchanged too. The allowlist limits what model code inherits, not what it can read: on Linux a process running as the same user can still read the environment the host and its MCPL servers started with, through `/proc//environ`, and can open the host's files, `.env` included, by absolute path. diff --git a/src/code-execution/tool-definition.ts b/src/code-execution/tool-definition.ts index d0ab53717..c3af39515 100644 --- a/src/code-execution/tool-definition.ts +++ b/src/code-execution/tool-definition.ts @@ -136,8 +136,9 @@ export function validateCodeExecutionConfig(cfg: { 'it is the most an agent may ask for, and the default is always allowed', ); } - // No refusal shows the value it refuses, only its kind: a substituted - // value can be a credential. null counts as not set, as JSON writes that. + // None of the environment's refusals below shows the value it refuses, + // at most its kind: a substituted value can be a credential. null counts + // as not set, as JSON writes that. if (cfg.inheritEnv !== undefined && cfg.inheritEnv !== null && typeof cfg.inheritEnv !== 'boolean') { const wanted = typeof cfg.inheritEnv === 'string' ? 'true or false, unquoted,' : 'true or false,'; throw new Error( From af4a081b1fb3ccc4ca9ae271fe73d645b4144718 Mon Sep 17 00:00:00 2001 From: Effie-2756 Date: Sat, 17 Oct 2026 13:17:37 -0400 Subject: [PATCH 16/18] fix(code-execution): a lone surrogate in a script's output goes as U+FFFD, so its result still arrives PYTHONIOENCODING=utf-8 (R1-6) makes the interpreter's stdout, the protocol channel, strict UTF-8. A str can hold a lone surrogate, which UTF-8 can't encode: on POSIX, os.fsdecode makes one of each byte in a filename that doesn't decode as UTF-8, and json.loads makes one of a "\ud800" escape. send() writes each message's JSON with ensure_ascii=False, so the write raised UnicodeEncodeError, no exec_result was sent, and the runner waited out the script's deadline before killing the interpreter. Greptile raised it on py-runner.ts:338. send() now replaces each lone surrogate in the line with U+FFFD before writing it, which keeps the channel valid UTF-8 for any str. Before this branch the same failure was there for surrogates outside U+DC80-U+DCFF, which surrogateescape, the handler Python's UTF-8 mode and the C locale give stdout, can't write either. A test prints one of each kind and expects U+FFFD for both, with a short script deadline, so a result that fails to send fails the test by name rather than holding the file open. The fixed fragment says what the host now gets. Co-Authored-By: Claude Opus 5.5 --- .../fix-code-execution-child-env.fixed.md | 1 + src/code-execution/runtime-py.ts | 10 +++++++++- test/code-execution-env.test.ts | 18 ++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/changelog.d/fix-code-execution-child-env.fixed.md b/changelog.d/fix-code-execution-child-env.fixed.md index cd2e20a2d..a19d5b52f 100644 --- a/changelog.d/fix-code-execution-child-env.fixed.md +++ b/changelog.d/fix-code-execution-child-env.fixed.md @@ -1 +1,2 @@ - `USERNAME` joins the child-env allowlist, beside `USER` and `LOGNAME`, so a Windows child gets its user's name, as a POSIX one does. Without it, Python's `getpass.getuser()` raises on Windows, in stdio MCPL children and the code-execution interpreter alike. +- A code-execution script's output that UTF-8 can't encode, a lone surrogate such as Python makes of a filename byte that isn't UTF-8 or of a `\ud800` JSON escape, reaches the host as U+FFFD. Before, the interpreter could fail to send the script's result, which then waited out the script's deadline. diff --git a/src/code-execution/runtime-py.ts b/src/code-execution/runtime-py.ts index e1304f557..5615b0f9b 100644 --- a/src/code-execution/runtime-py.ts +++ b/src/code-execution/runtime-py.ts @@ -53,6 +53,7 @@ import inspect import io import json import os +import re import signal import sys import threading @@ -70,9 +71,16 @@ TAIL_CHARS = 4000 _send_lock = threading.Lock() +# A str can hold a lone surrogate, which UTF-8 can't encode: on POSIX, +# os.fsdecode makes one of each byte in a filename that doesn't decode as +# UTF-8, and json.loads makes one of a "\ud800" escape. PYTHONIOENCODING makes +# the protocol channel strict UTF-8, so writing one would fail the whole +# message, and a script's result would never arrive. Each goes as U+FFFD. +_LONE_SURROGATE = re.compile(r"[\ud800-\udfff]") + def send(obj): - line = json.dumps(obj, ensure_ascii=False, default=str) + line = _LONE_SURROGATE.sub("\ufffd", json.dumps(obj, ensure_ascii=False, default=str)) with _send_lock: PROTO_OUT.write(line + "\n") PROTO_OUT.flush() diff --git a/test/code-execution-env.test.ts b/test/code-execution-env.test.ts index 018de1e07..0c6babe5a 100644 --- a/test/code-execution-env.test.ts +++ b/test/code-execution-env.test.ts @@ -146,6 +146,24 @@ test('PYTHONUNBUFFERED and PYTHONIOENCODING are set last, over a declared env, s } }); +test("a script's output that UTF-8 can't encode still returns, each lone surrogate as U+FFFD", async () => { + // A result the interpreter failed to send would wait out the script's + // deadline, so a short one makes that a failure here rather than a hang. + const runner = new PyRunner({ onToolCall: async () => '', scriptTimeoutMs: 15_000 }); + try { + // A filename byte that isn't UTF-8, as os.fsdecode gives it, and a lone + // surrogate from a JSON escape, as a tool's result can carry one. + const r = await runner.exec(String.raw`import json, os +print(os.fsdecode(b"\xff")) +print(json.loads('"\\ud800"')) +print("after")`, []); + assert.equal(r.returnCode, 0, r.stderr); + assert.equal(r.stdout, '\ufffd\n\ufffd\nafter\n'); + } finally { + runner.dispose(); + } +}); + test("the interpreter runs in its own runtime directory, which goes at teardown, not the host's working directory", async () => { const runner = new PyRunner({ onToolCall: async () => '' }); try { From beb37e20cf526a717c743670197b01ea89f831e4 Mon Sep 17 00:00:00 2001 From: Effie-2756 Date: Sat, 17 Oct 2026 13:22:26 -0400 Subject: [PATCH 17/18] refactor(code-execution): one kindOf and one inheritEnv read, now that main has #451; text points main's buildChildEnv now takes only inheritEnv === true (#451), so PyRunner's own === true read had nothing left to do: no test would fail without it. It's gone, with its comment. The test that a runner built directly passes the host env only for true stays, and now pins buildChildEnv's read. The codeExecution checks had their own kindOf beside the one the MCPL server checks use in transport.ts. There is one now, exported from transport.ts. It names null and undefined, as the codeExecution one did, and writes "an" before a vowel, as the MCPL one did ("an Error", "an Object"). Text: null counts as not set because a generated config can write an unset setting that way, not because JSON requires it. The fixed fragment gives the order getpass.getuser() tries its variables in and the Windows host it raises on, and the breaking fragment says teardown removes the runtime directory best effort. Co-Authored-By: Claude Opus 5.5 --- .../fix-code-execution-child-env.breaking.md | 2 +- changelog.d/fix-code-execution-child-env.fixed.md | 2 +- src/code-execution/py-runner.ts | 4 +--- src/code-execution/tool-definition.ts | 15 +++------------ src/mcpl/transport.ts | 8 +++++--- test/code-execution-env.test.ts | 3 ++- 6 files changed, 13 insertions(+), 21 deletions(-) diff --git a/changelog.d/fix-code-execution-child-env.breaking.md b/changelog.d/fix-code-execution-child-env.breaking.md index 521777299..43c3e6d40 100644 --- a/changelog.d/fix-code-execution-child-env.breaking.md +++ b/changelog.d/fix-code-execution-child-env.breaking.md @@ -1,6 +1,6 @@ - **Operators with `codeExecution` enabled:** the python interpreter that runs model-authored scripts no longer inherits the host's environment, and it starts in its own runtime directory instead of the host's working directory. It gets the allowlist stdio MCPL children get (#175): `PATH`, `HOME`, the locale, temp directories, CA bundles and proxy variables, whose URLs can carry credentials. Then come `codeExecution.env`, and last `PYTHONUNBUFFERED=1` and `PYTHONIOENCODING=utf-8`, which the runner sets over any declared value. - **What stops arriving:** every host variable outside the allowlist, such as `PYTHONPATH`, `VIRTUAL_ENV`, `PYTHONHOME`, `LD_LIBRARY_PATH`, `PYTHONUTF8` and `ALL_PROXY`, and any credential a script read from `os.environ`. To get one back, list it in `codeExecution.env`, which a recipe can `${VAR}`-substitute from `.env`. `codeExecution.inheritEnv: true` restores the whole environment, and with it hands every host variable, provider keys included, to model code and to every subprocess a script starts. - - **Relative paths** now resolve in the runtime directory, which goes whenever the interpreter stops or is reclaimed: a script's relative reads and writes, a relative path in `codeExecution.env`, such as a `PYTHONPATH` entry, and a relative `PATH` entry that a bare `pythonPath` is looked up on. A file a script reads from the host, or writes to keep, needs an absolute path, as does a path in `codeExecution.env`. + - **Relative paths** now resolve in the runtime directory, which teardown removes, best effort, whenever the interpreter stops or is reclaimed: a script's relative reads and writes, a relative path in `codeExecution.env`, such as a `PYTHONPATH` entry, and a relative `PATH` entry that a bare `pythonPath` is looked up on. A file a script reads from the host, or writes to keep, needs an absolute path, as does a path in `codeExecution.env`. - **Direct `PyRunner` users:** `PyRunnerOptions` gains `env` and `inheritEnv`, and a runner built without them gets the allowlist. Only `inheritEnv: true` passes the whole environment, and these options aren't checked as `create()` checks `codeExecution`'s. - **A misread configuration is refused at create:** an `inheritEnv` that isn't `true` or `false`, such as a quoted `"true"`, which would quietly keep the allowlist; an `env` that isn't an object of strings; and a variable name that is empty or holds `=` or NUL, or a value that holds NUL. A `null` `inheritEnv` or `env` counts as left out. - **Unchanged:** a `pythonPath` with a separator, which still resolves from the host's working directory, and which a venv's interpreter needs no `VIRTUAL_ENV` for, since it finds its prefix and site-packages itself. Everything the allowlist passes is unchanged too. The allowlist limits what model code inherits, not what it can read: on Linux a process running as the same user can still read the environment the host and its MCPL servers started with, through `/proc//environ`, and can open the host's files, `.env` included, by absolute path. diff --git a/changelog.d/fix-code-execution-child-env.fixed.md b/changelog.d/fix-code-execution-child-env.fixed.md index a19d5b52f..cd3193841 100644 --- a/changelog.d/fix-code-execution-child-env.fixed.md +++ b/changelog.d/fix-code-execution-child-env.fixed.md @@ -1,2 +1,2 @@ -- `USERNAME` joins the child-env allowlist, beside `USER` and `LOGNAME`, so a Windows child gets its user's name, as a POSIX one does. Without it, Python's `getpass.getuser()` raises on Windows, in stdio MCPL children and the code-execution interpreter alike. +- `USERNAME` joins the child-env allowlist, beside `USER` and `LOGNAME`, so a Windows child gets its user's name, as a POSIX one does. Python's `getpass.getuser()` tries `LOGNAME`, `USER` and `LNAME` before `USERNAME`, so without it, it raises on a Windows host that supplies the name only through `USERNAME`, in stdio MCPL children and the code-execution interpreter alike. - A code-execution script's output that UTF-8 can't encode, a lone surrogate such as Python makes of a filename byte that isn't UTF-8 or of a `\ud800` JSON escape, reaches the host as U+FFFD. Before, the interpreter could fail to send the script's result, which then waited out the script's deadline. diff --git a/src/code-execution/py-runner.ts b/src/code-execution/py-runner.ts index 5bb049d96..85aa3525b 100644 --- a/src/code-execution/py-runner.ts +++ b/src/code-execution/py-runner.ts @@ -163,9 +163,7 @@ export class PyRunner { // as options, not a snapshot — the env is rebuilt at every spawn so a // respawn after idle reclaim sees current host values, as the old // per-spawn `{ ...process.env }` did. - // Only true inherits: a direct caller's options aren't checked as - // create() checks codeExecution's. - this.envOptions = { env: options.env, inheritEnv: options.inheritEnv === true }; + this.envOptions = { env: options.env, inheritEnv: options.inheritEnv }; } get busy(): boolean { diff --git a/src/code-execution/tool-definition.ts b/src/code-execution/tool-definition.ts index c3af39515..f8a8e934d 100644 --- a/src/code-execution/tool-definition.ts +++ b/src/code-execution/tool-definition.ts @@ -9,6 +9,7 @@ */ import type { ToolDefinition } from '../types/index.js'; +import { kindOf } from '../mcpl/transport.js'; import { formatLimit, MAX_TIMER_MS } from './py-runner.js'; export const CODE_EXECUTION_TOOL_NAME = 'code_execution'; @@ -138,7 +139,8 @@ export function validateCodeExecutionConfig(cfg: { } // None of the environment's refusals below shows the value it refuses, // at most its kind: a substituted value can be a credential. null counts - // as not set, as JSON writes that. + // as not set, so a generated config that writes an unset setting as null + // keeps working. if (cfg.inheritEnv !== undefined && cfg.inheritEnv !== null && typeof cfg.inheritEnv !== 'boolean') { const wanted = typeof cfg.inheritEnv === 'string' ? 'true or false, unquoted,' : 'true or false,'; throw new Error( @@ -175,14 +177,3 @@ function isPlainObject(value: unknown): value is Record { const proto = Object.getPrototypeOf(value); return proto === Object.prototype || proto === null; } - -/** What kind of value a configuration holds, for an error that names it without showing it. */ -function kindOf(value: unknown): string { - if (value === null) return 'null'; - if (value === undefined) return 'undefined'; - if (Array.isArray(value)) return 'an array'; - if (typeof value !== 'object') return `a ${typeof value}`; - if (isPlainObject(value)) return 'an object'; - const name = (Object.getPrototypeOf(value) as { constructor?: { name?: string } }).constructor?.name; - return name ? `a ${name}` : 'an object that isn\'t plain'; -} diff --git a/src/mcpl/transport.ts b/src/mcpl/transport.ts index 217e65c8b..f52a3e557 100644 --- a/src/mcpl/transport.ts +++ b/src/mcpl/transport.ts @@ -519,9 +519,11 @@ export function assertServerConfigBooleans(config: McplServerConfig): void { } } -/** The kind of a value that isn't undefined, null or a boolean, as a refusal - * names it in place of the value. */ -function kindOf(value: unknown): string { +/** The kind of a value, as a refusal names it in place of the value, which + * can be a credential. Shared by the MCPL server and codeExecution checks. */ +export function kindOf(value: unknown): string { + if (value === null) return 'null'; + if (value === undefined) return 'undefined'; if (Array.isArray(value)) return 'an array'; if (typeof value !== 'object') return `a ${typeof value}`; const prototype = Object.getPrototypeOf(value) as { constructor?: { name?: string } } | null; diff --git a/test/code-execution-env.test.ts b/test/code-execution-env.test.ts index 0c6babe5a..7732f57a5 100644 --- a/test/code-execution-env.test.ts +++ b/test/code-execution-env.test.ts @@ -29,7 +29,8 @@ test('a codeExecution.inheritEnv set to anything but true, false or null is refu return true; }, JSON.stringify(inheritEnv)); } - // null is how JSON writes "not set". + // null counts as not set, so a generated config that writes an unset + // setting as null keeps working. for (const inheritEnv of [undefined, null, true, false]) validateCodeExecutionConfig({ inheritEnv }); }); From 9a27fa724b1e8176d69efeccebd2b654c183e95c Mon Sep 17 00:00:00 2001 From: Effie-2756 Date: Sat, 17 Oct 2026 21:51:12 -0400 Subject: [PATCH 18/18] docs(changelog): the breaking entry's allowlist, relative allowlisted paths, and a durable path (R2-1, R2-2) R2-1: the breaking entry listed what the interpreter gets as if that were the whole allowlist. It now names CHILD_ENV_ALLOWLIST in src/mcpl/transport.ts and writes "among them" before the examples. Its "unchanged" sentence held only for absolute values: a relative value of an allowlisted path variable, such as TMPDIR or SSL_CERT_FILE, now resolves in the runtime directory, as a script's relative paths do. That case joins the "Relative paths" bullet, and the "unchanged" sentence says the values are kept, so an absolute path names what it did. R2-2: the migration said a durable file needs an absolute path, but not how a script gets one. It now says to pass that path through codeExecution.env and name the variable in the agent's prompt. Only the changelog fragment changes. --- changelog.d/fix-code-execution-child-env.breaking.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/changelog.d/fix-code-execution-child-env.breaking.md b/changelog.d/fix-code-execution-child-env.breaking.md index 43c3e6d40..3c7daa03e 100644 --- a/changelog.d/fix-code-execution-child-env.breaking.md +++ b/changelog.d/fix-code-execution-child-env.breaking.md @@ -1,6 +1,6 @@ -- **Operators with `codeExecution` enabled:** the python interpreter that runs model-authored scripts no longer inherits the host's environment, and it starts in its own runtime directory instead of the host's working directory. It gets the allowlist stdio MCPL children get (#175): `PATH`, `HOME`, the locale, temp directories, CA bundles and proxy variables, whose URLs can carry credentials. Then come `codeExecution.env`, and last `PYTHONUNBUFFERED=1` and `PYTHONIOENCODING=utf-8`, which the runner sets over any declared value. +- **Operators with `codeExecution` enabled:** the python interpreter that runs model-authored scripts no longer inherits the host's environment, and it starts in its own runtime directory instead of the host's working directory. It gets the allowlist stdio MCPL children get (#175), `CHILD_ENV_ALLOWLIST` in `src/mcpl/transport.ts`, among them `PATH`, `HOME`, the locale, temp directories, CA bundles and proxy variables, whose URLs can carry credentials. Then come `codeExecution.env`, and last `PYTHONUNBUFFERED=1` and `PYTHONIOENCODING=utf-8`, which the runner sets over any declared value. - **What stops arriving:** every host variable outside the allowlist, such as `PYTHONPATH`, `VIRTUAL_ENV`, `PYTHONHOME`, `LD_LIBRARY_PATH`, `PYTHONUTF8` and `ALL_PROXY`, and any credential a script read from `os.environ`. To get one back, list it in `codeExecution.env`, which a recipe can `${VAR}`-substitute from `.env`. `codeExecution.inheritEnv: true` restores the whole environment, and with it hands every host variable, provider keys included, to model code and to every subprocess a script starts. - - **Relative paths** now resolve in the runtime directory, which teardown removes, best effort, whenever the interpreter stops or is reclaimed: a script's relative reads and writes, a relative path in `codeExecution.env`, such as a `PYTHONPATH` entry, and a relative `PATH` entry that a bare `pythonPath` is looked up on. A file a script reads from the host, or writes to keep, needs an absolute path, as does a path in `codeExecution.env`. + - **Relative paths** now resolve in the runtime directory, which teardown removes, best effort, whenever the interpreter stops or is reclaimed: a script's relative reads and writes; a relative path in `codeExecution.env`, such as a `PYTHONPATH` entry; a relative value of an allowlisted path variable, such as `TMPDIR` or `SSL_CERT_FILE`; and a relative `PATH` entry that a bare `pythonPath` is looked up on. A file a script reads from the host, or writes to keep, needs an absolute path, as does a path in `codeExecution.env` or in an allowlisted variable. To give scripts somewhere durable, pass its absolute path through `codeExecution.env`, such as `"AGENT_FILES": "/srv/agent/files"`, and name the variable in the agent's prompt. - **Direct `PyRunner` users:** `PyRunnerOptions` gains `env` and `inheritEnv`, and a runner built without them gets the allowlist. Only `inheritEnv: true` passes the whole environment, and these options aren't checked as `create()` checks `codeExecution`'s. - **A misread configuration is refused at create:** an `inheritEnv` that isn't `true` or `false`, such as a quoted `"true"`, which would quietly keep the allowlist; an `env` that isn't an object of strings; and a variable name that is empty or holds `=` or NUL, or a value that holds NUL. A `null` `inheritEnv` or `env` counts as left out. - - **Unchanged:** a `pythonPath` with a separator, which still resolves from the host's working directory, and which a venv's interpreter needs no `VIRTUAL_ENV` for, since it finds its prefix and site-packages itself. Everything the allowlist passes is unchanged too. The allowlist limits what model code inherits, not what it can read: on Linux a process running as the same user can still read the environment the host and its MCPL servers started with, through `/proc//environ`, and can open the host's files, `.env` included, by absolute path. + - **Unchanged:** a `pythonPath` with a separator, which still resolves from the host's working directory, and which a venv's interpreter needs no `VIRTUAL_ENV` for, since it finds its prefix and site-packages itself. The allowlisted variables keep their values too, so an absolute path among them names what it did, and a relative one resolves as above. The allowlist limits what model code inherits, not what it can read: on Linux a process running as the same user can still read the environment the host and its MCPL servers started with, through `/proc//environ`, and can open the host's files, `.env` included, by absolute path.