From 7372769dcee563653cdfd95c370626cf6d2922c7 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 3 Oct 2026 20:34:05 +0000 Subject: [PATCH] D0: keep a project's .env and bunfig.toml out of compiled binaries and daemon children Compiled binaries are built with --no-compile-autoload-dotenv and --no-compile-autoload-bunfig; the browse server and terminal agent are spawned with --no-env-file and an empty config, so a repo's .env cannot set GSTACK_CHROMIUM_NO_SANDBOX and a bunfig preload cannot run code in them. Verified on Bun 1.4.0. --- browse/src/cli.ts | 4 +- browse/src/terminal-agent-control.ts | 9 +- browse/test/dotenv-isolation.test.ts | 120 +++++++++++++++++++++++++++ scripts/build.sh | 13 +-- 4 files changed, 138 insertions(+), 8 deletions(-) create mode 100644 browse/test/dotenv-isolation.test.ts diff --git a/browse/src/cli.ts b/browse/src/cli.ts index fe44feaf7..54a8c35c9 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -18,7 +18,7 @@ import { writeSecureFile, mkdirSecure } from './file-permissions'; import { resolveConfig, ensureStateDir, readVersionHash, isPairAgentEnabled, resolveChromiumProfile } from './config'; import { parseProxyConfig, computeConfigHash, ProxyConfigError } from './proxy-config'; import { redactProxyUrl } from './proxy-redact'; -import { spawnTerminalAgent } from './terminal-agent-control'; +import { spawnTerminalAgent, BUN_CHILD_FLAGS } from './terminal-agent-control'; // Zero side effects on import (documented invariant in token-registry.ts) — // safe to pull the shared pairing default into the CLI. import { DEFAULT_PAIR_SCOPES } from './token-registry'; @@ -658,7 +658,7 @@ async function startServer(extraEnv?: Record): Promise { + const deadline = Date.now() + 20_000; + while (!fs.existsSync(file) && Date.now() < deadline) await Bun.sleep(50); + return fs.readFileSync(file, 'utf-8'); +} + +beforeEach(() => { + const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-dotenv-')); + project = path.join(scratch, 'project'); + marker = path.join(scratch, 'preload-ran'); + seedHostileProject(project); +}); + +afterEach(() => { + fs.rmSync(path.dirname(project), { recursive: true, force: true }); +}); + +describe('D0: project .env and bunfig.toml are ignored', () => { + test('every compiled binary in scripts/build.sh turns dotenv and bunfig autoload off, and the flags work', () => { + const lines = fs.readFileSync(path.join(ROOT, 'scripts', 'build.sh'), 'utf-8').split('\n').filter(l => /build --compile/.test(l)); + expect(lines.length).toBeGreaterThanOrEqual(5); + for (const line of lines) { + expect(line).toContain('--no-compile-autoload-dotenv'); + expect(line).toContain('--no-compile-autoload-bunfig'); + } + const out = path.join(path.dirname(project), 'probe.json'); + const src = path.join(path.dirname(project), 'probe.ts'); + const bin = path.join(path.dirname(project), 'probe-bin'); + fs.writeFileSync(src, probeSource(out)); + const build = Bun.spawnSync([process.execPath, 'build', '--compile', '--no-compile-autoload-dotenv', '--no-compile-autoload-bunfig', src, '--outfile', bin], { stdout: 'pipe', stderr: 'pipe', timeout: 60_000 }); + expect(build.exitCode, build.stderr.toString()).toBe(0); + const run = Bun.spawnSync([bin], { cwd: project, env: { PATH: process.env.PATH ?? '' }, stdout: 'pipe', stderr: 'pipe', timeout: 30_000 }); + expect(run.exitCode, run.stderr.toString()).toBe(0); + expect(JSON.parse(fs.readFileSync(out, 'utf-8'))).toEqual({ sandbox: null }); + expect(fs.existsSync(marker)).toBe(false); + }, 90_000); + + test('the terminal agent spawned in the project sees neither the .env value nor the bunfig preload', async () => { + const stateDir = path.join(path.dirname(project), 'state'); + fs.mkdirSync(stateDir, { recursive: true }); + const out = path.join(stateDir, 'agent-env.json'); + const script = path.join(stateDir, 'agent-probe.ts'); + fs.writeFileSync(script, probeSource(out)); + const saved = process.env.GSTACK_CHROMIUM_NO_SANDBOX; + delete process.env.GSTACK_CHROMIUM_NO_SANDBOX; + try { + const pid = spawnTerminalAgent({ stateFile: path.join(stateDir, 'browse.json'), serverPort: 1, ownerPid: process.pid, cwd: project, scriptPath: script }); + expect(pid).toBeGreaterThan(0); + expect(JSON.parse(await waitForFile(out))).toEqual({ sandbox: null }); + expect(fs.existsSync(marker)).toBe(false); + } finally { + if (saved !== undefined) process.env.GSTACK_CHROMIUM_NO_SANDBOX = saved; + } + }, 30_000); + + test.skipIf(process.platform === 'win32')('the browse server spawned from the project sees neither the .env value nor the bunfig preload', async () => { + const stateFile = path.join(project, '.gstack', 'browse.json'); + const stub = path.join(path.dirname(project), 'stub-server.ts'); + fs.writeFileSync(stub, ` + import * as fs from 'node:fs'; + const server = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: () => Response.json({ status: 'healthy' }) }); + fs.writeFileSync(process.env.BROWSE_STATE_FILE!, JSON.stringify({ + pid: process.pid, port: server.port, token: 'dotenv-test', mode: 'launched', + sandbox: process.env.GSTACK_CHROMIUM_NO_SANDBOX ?? null, + })); + setTimeout(() => process.exit(0), 5000); + `); + const env: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined && !/^(BROWSE_|GSTACK_|CHROMIUM_PROFILE$|CLAUDE_PLUGIN_DATA$)/.test(key)) env[key] = value; + } + Object.assign(env, { + HOME: path.dirname(project), + GSTACK_HOME: path.join(path.dirname(project), '.gstack'), + BROWSE_STATE_FILE: stateFile, + BROWSE_SERVER_SCRIPT: stub, + BROWSE_PARENT_PID: '0', + BROWSE_START_TIMEOUT: '20000', + }); + // The shipped CLI is compiled without dotenv autoload; --no-env-file gives + // the source CLI the same starting environment. + const cli = Bun.spawn([process.execPath, '--no-env-file', '--config=/dev/null', CLI, 'status'], { cwd: project, env, stdout: 'pipe', stderr: 'pipe' }); + const timer = setTimeout(() => cli.kill('SIGKILL'), 40_000); + const [code, stderr] = await Promise.all([cli.exited, new Response(cli.stderr).text()]); + clearTimeout(timer); + const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8')); + try { process.kill(state.pid, 'SIGKILL'); } catch {} + expect(code, stderr).toBe(0); + expect(state.sandbox).toBeNull(); + expect(fs.existsSync(marker)).toBe(false); + }, 60_000); +}); diff --git a/scripts/build.sh b/scripts/build.sh index 559e5d807..bd34f55d3 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -30,13 +30,16 @@ case "$(uname -s)" in ;; esac +# A project's .env or bunfig.toml must never reach the shipped binaries: they run +# inside untrusted repos, and dotenv could set security switches such as +# GSTACK_CHROMIUM_NO_SANDBOX while a bunfig preload runs arbitrary code (D0). "$BUN_CMD" run vendor:xterm "$BUN_CMD" run gen:skill-docs --host all -"$BUN_CMD" build --compile browse/src/cli.ts --outfile browse/dist/browse -"$BUN_CMD" build --compile browse/src/find-browse.ts --outfile browse/dist/find-browse -"$BUN_CMD" build --compile design/src/cli.ts --outfile design/dist/design -"$BUN_CMD" build --compile make-pdf/src/cli.ts --outfile make-pdf/dist/pdf -"$BUN_CMD" build --compile bin/gstack-global-discover.ts --outfile bin/gstack-global-discover +"$BUN_CMD" build --compile --no-compile-autoload-dotenv --no-compile-autoload-bunfig browse/src/cli.ts --outfile browse/dist/browse +"$BUN_CMD" build --compile --no-compile-autoload-dotenv --no-compile-autoload-bunfig browse/src/find-browse.ts --outfile browse/dist/find-browse +"$BUN_CMD" build --compile --no-compile-autoload-dotenv --no-compile-autoload-bunfig design/src/cli.ts --outfile design/dist/design +"$BUN_CMD" build --compile --no-compile-autoload-dotenv --no-compile-autoload-bunfig make-pdf/src/cli.ts --outfile make-pdf/dist/pdf +"$BUN_CMD" build --compile --no-compile-autoload-dotenv --no-compile-autoload-bunfig bin/gstack-global-discover.ts --outfile bin/gstack-global-discover if [ "${GSTACK_SETUP_RUNNING:-0}" = "1" ] && [ "${GSTACK_SETUP_SKIP_CSO_BUILD:-0}" = "1" ]; then # Setup removes these before invoking us too. Repeat here so the setup-private # escape hatch can never publish a completion stamp beside stale trusted code.