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.
This commit is contained in:
garrytan committed 2026-10-03 20:34:05 +00:00
1 parent ff5fbd09ee
commit 7372769dce
4 files changed
+138 -8

No files matched your search

+2 -2
View File
@@ -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<string, string>): Promise<ServerSta
// (PPID=1, STAT=Ss) and survives the spawning shell's exit. Mirrors
// the Windows path's rationale — same root cause, different OS API.
const daemonLogFd = openDaemonLogSink();
const child = nodeSpawn('bun', ['run', SERVER_SCRIPT], {
const child = nodeSpawn('bun', [...BUN_CHILD_FLAGS, 'run', SERVER_SCRIPT], {
detached: true,
windowsHide: true,
stdio: ['ignore', daemonLogFd, daemonLogFd],
+8 -1
View File
@@ -138,6 +138,13 @@ export function acquireAgentStateLock(stateDir: string, waitMs = 5000, publicati
* exposes it relative to process.execPath. Either path must work or
* the agent can't be spawned at all.
*/
/**
* Flags for every `bun run` child the daemon spawns. The child runs in the
* user's project, and Bun would otherwise load that project's .env (which can
* set GSTACK_CHROMIUM_NO_SANDBOX) and bunfig.toml (whose preload runs code).
*/
export const BUN_CHILD_FLAGS = ['--no-env-file', `--config=${process.platform === 'win32' ? 'NUL' : '/dev/null'}`];
export function resolveTerminalAgentScript(searchHints: { metaDir?: string; execPath?: string } = {}): string | null {
const meta = searchHints.metaDir || __dirname;
const exec = searchHints.execPath || process.execPath;
@@ -197,7 +204,7 @@ export function spawnTerminalAgent(opts: {
writeAgentRecord(stateDir, reservation);
let proc: any;
try {
proc = (Bun as any).spawn(['bun', 'run', script, `--agent-gen=${gen}`], {
proc = (Bun as any).spawn(['bun', ...BUN_CHILD_FLAGS, 'run', script, `--agent-gen=${gen}`], {
cwd: opts.cwd || process.cwd(),
env: {
...process.env,
+120
View File
@@ -0,0 +1,120 @@
/**
* D0: a project's .env and bunfig.toml must not reach gstack's binaries or
* the Bun children the browse daemon starts in that project. Dotenv could set
* GSTACK_CHROMIUM_NO_SANDBOX; a bunfig preload runs arbitrary code.
*/
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { spawnTerminalAgent } from '../src/terminal-agent-control';
const ROOT = path.resolve(import.meta.dir, '..', '..');
const CLI = path.join(ROOT, 'browse', 'src', 'cli.ts');
let project: string;
let marker: string;
function seedHostileProject(dir: string): void {
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, '.env'), 'GSTACK_CHROMIUM_NO_SANDBOX=1\n');
fs.writeFileSync(path.join(dir, 'preload.ts'), `require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'ran');\n`);
fs.writeFileSync(path.join(dir, 'bunfig.toml'), 'preload = ["./preload.ts"]\n');
}
function probeSource(out: string): string {
return `require('node:fs').writeFileSync(${JSON.stringify(out)}, JSON.stringify({ sandbox: process.env.GSTACK_CHROMIUM_NO_SANDBOX ?? null }));\n`;
}
async function waitForFile(file: string): Promise<string> {
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<string, string> = {};
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);
});
+8 -5
View File
@@ -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.