Files
gstack/test/bun-dotenv-isolation.test.ts
Garry Tanandcapy-ai[bot] 4c6fa89cdc v1.91.159.0 security: gstack's Bun entry points ignore the audited repo's .env and bunfig.toml (S1) (#3312)
* security: isolate every gstack Bun entry point from the audited checkout's .env and bunfig.toml

Bun auto-loads .env, .env.local and .env.<NODE_ENV> from its cwd and runs a
cwd bunfig.toml preload before the program starts. gstack runs inside the repo
it audits, so a committed .env could steer state roots, the claude binary or
spend caps, and a bunfig preload ran arbitrary code in every bin and hook.

Every bun shebang, shell and skill caller, TypeScript spawn and compiled build
now disables both (--no-env-file --config=/dev/null, bunIsolationArgs(),
--no-compile-autoload-dotenv/bunfig), with a ratchet and hostile-fixture
regressions.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: name the directly executed scripts in the isolation ratchet

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: follow the isolation flags in touchfiles, copy lists, recipe assertions and the harness pin

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: keep browse/src/bun-child-config.ts unchanged so the native cookie qualification still binds

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(windows): the empty bunfig outlives its process so the timeline hook's detached repair child can read it

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* v1.91.159.0 security: gstack's Bun entry points ignore the audited repo's .env and bunfig.toml (S1)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: plan-mode render pattern accepts the isolation flags

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-20 00:24:28 +00:00

162 lines
7.8 KiB
TypeScript

/**
* GSTA-56: a repository's own .env, .env.local and bunfig.toml cannot
* configure gstack's Bun programs. Bun loads all three from the directory it
* starts in, and gstack runs inside the repository it audits, so before the
* isolated shebang (lib/bun-isolation.ts) a committed .env could move the
* state root, pin the project slug, swap the claude binary or raise the
* pregate spend cap, and a bunfig.toml preload ran code first. Each case runs
* the real bin in a hostile fixture repository; the shell-environment cases
* are the negative controls proving the same variables still work when the
* user sets them.
*/
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { runScriptSync } from '../lib/script-invocation';
const ROOT = path.resolve(import.meta.dir, '..');
const BIN = path.join(ROOT, 'bin');
let tmp = '';
let repo = '';
let realRoot = '';
let evilRoot = '';
let preloadMarker = '';
let claudeMarks = '';
function cleanEnv(extra: Record<string, string> = {}): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {};
for (const [k, v] of Object.entries(process.env)) {
if (/^(GSTACK_|CLAUDE_BIN|NODE_ENV$)/.test(k)) continue;
env[k] = v;
}
return {
...env,
PATH: [path.join(tmp, 'path'), path.dirname(process.execPath), '/usr/bin', '/bin'].join(path.delimiter),
HOME: path.join(tmp, 'home'),
GSTACK_STATE_ROOT: realRoot,
...extra,
};
}
function run(cmd: string, args: string[], extra: Record<string, string> = {}, input?: string) {
return spawnSync(cmd, args, { cwd: repo, env: cleanEnv(extra), encoding: 'utf8', timeout: 60_000, input });
}
function resetMarks(): void {
for (const f of fs.readdirSync(claudeMarks)) fs.rmSync(path.join(claudeMarks, f));
fs.rmSync(preloadMarker, { force: true });
}
function fakeClaude(name: string): string {
const file = path.join(tmp, name);
fs.writeFileSync(file, `#!/bin/sh\necho ran > "${path.join(claudeMarks, name)}"\n`);
fs.chmodSync(file, 0o755);
return file;
}
beforeAll(() => {
tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-dotenv-')));
repo = path.join(tmp, 'repo');
realRoot = path.join(tmp, 'real-root');
evilRoot = path.join(tmp, 'evil-root');
preloadMarker = path.join(tmp, 'preload-ran');
claudeMarks = path.join(tmp, 'claude-marks');
for (const d of [repo, realRoot, claudeMarks, path.join(tmp, 'home'), path.join(tmp, 'path')]) fs.mkdirSync(d, { recursive: true });
for (const args of [['init', '-q'], ['remote', 'add', 'origin', 'https://github.com/acme/real-project.git']]) {
expect(spawnSync('git', args, { cwd: repo, timeout: 30_000 }).status).toBe(0);
}
fs.renameSync(fakeClaude('claude'), path.join(tmp, 'path', 'claude'));
const evilClaude = fakeClaude('evil-claude');
const hostile = [
`GSTACK_STATE_ROOT=${evilRoot}`,
`GSTACK_HOME=${evilRoot}`,
'GSTACK_PROJECT_SLUG=evil-slug',
`GSTACK_CLAUDE_BIN=${evilClaude}`,
`GSTACK_CLAUDE_BIN_ARGS=["--evil"]`,
'GSTACK_SPEND_CAP_USD=999',
].join('\n') + '\n';
fs.writeFileSync(path.join(repo, '.env'), hostile);
fs.writeFileSync(path.join(repo, '.env.local'), hostile);
fs.writeFileSync(path.join(repo, 'preload.ts'), `require('fs').writeFileSync(${JSON.stringify(preloadMarker)}, 'ran');\n`);
fs.writeFileSync(path.join(repo, 'bunfig.toml'), 'preload = ["./preload.ts"]\n');
});
afterAll(() => { if (tmp) fs.rmSync(tmp, { recursive: true, force: true }); });
describe('GSTA-56: the audited repository cannot configure gstack bins', () => {
test('fixture control: a plain bun in the fixture loads the hostile .env and runs the preload', () => {
const r = run(process.execPath, ['-e', 'console.log(process.env.GSTACK_PROJECT_SLUG ?? "unset")']);
expect(r.stdout.trim()).toBe('evil-slug');
expect(fs.existsSync(preloadMarker)).toBe(true);
fs.rmSync(preloadMarker);
});
test('state root and project slug come from the user, not the repo .env', () => {
const decision = JSON.stringify({ decision: 'use postgres', rationale: 'fixture', scope: 'repo', source: 'user' });
const r = run(path.join(BIN, 'gstack-decision-log'), [decision]);
expect(r.status).toBe(0);
expect(fs.existsSync(evilRoot)).toBe(false);
const slugs = fs.readdirSync(path.join(realRoot, 'projects'));
expect(slugs).not.toContain('evil-slug');
expect(slugs.some(s => fs.existsSync(path.join(realRoot, 'projects', s, 'decisions.jsonl')))).toBe(true);
expect(fs.existsSync(preloadMarker)).toBe(false);
});
test('a TypeScript caller launching the bin through runScriptSync gets the same isolation', () => {
const decision = JSON.stringify({ decision: 'use redis', rationale: 'fixture', scope: 'repo', source: 'user' });
const r = runScriptSync(path.join(BIN, 'gstack-decision-log'), [decision], { cwd: repo, env: cleanEnv(), encoding: 'utf8', timeout: 60_000 });
expect(r.status).toBe(0);
expect(fs.existsSync(evilRoot)).toBe(false);
expect(fs.readdirSync(path.join(realRoot, 'projects'))).not.toContain('evil-slug');
expect(fs.existsSync(preloadMarker)).toBe(false);
});
test('negative control: GSTACK_PROJECT_SLUG from the shell still pins the slug', () => {
const decision = JSON.stringify({ decision: 'use sqlite', rationale: 'fixture', scope: 'repo', source: 'user' });
const r = run(path.join(BIN, 'gstack-decision-log'), [decision], { GSTACK_PROJECT_SLUG: 'shell-slug' });
expect(r.status).toBe(0);
expect(fs.existsSync(path.join(realRoot, 'projects', 'shell-slug', 'decisions.jsonl'))).toBe(true);
});
test('the claude binary comes from PATH, not GSTACK_CLAUDE_BIN in the repo .env', () => {
resetMarks();
run(path.join(BIN, 'gstack-claude-code'), ['--user-invoked', '--cwd', repo, '--access', 'none', '--timeout-ms', '20000'], {}, 'review this');
expect(fs.existsSync(path.join(claudeMarks, 'evil-claude'))).toBe(false);
expect(fs.existsSync(path.join(claudeMarks, 'claude'))).toBe(true);
expect(fs.existsSync(preloadMarker)).toBe(false);
});
test('negative control: GSTACK_CLAUDE_BIN from the shell still selects the binary', () => {
resetMarks();
const shellClaude = fakeClaude('shell-claude');
run(path.join(BIN, 'gstack-claude-code'), ['--user-invoked', '--cwd', repo, '--access', 'none', '--timeout-ms', '20000'], { GSTACK_CLAUDE_BIN: shellClaude }, 'review this');
expect(fs.existsSync(path.join(claudeMarks, 'shell-claude'))).toBe(true);
expect(fs.existsSync(path.join(claudeMarks, 'evil-claude'))).toBe(false);
});
test('pregate --remote does not take its spend cap from the repo .env', () => {
const r = run(path.join(BIN, 'gstack-pregate'), ['run', '--remote', '--allow-repo-commands', '--policy-from', 'base']);
expect(r.stderr).toContain('--remote spends money; pass --spend-cap');
expect(fs.existsSync(preloadMarker)).toBe(false);
});
test('negative control: GSTACK_SPEND_CAP_USD from the shell still sets the cap', () => {
const r = run(path.join(BIN, 'gstack-pregate'), ['run', '--remote', '--allow-repo-commands', '--policy-from', 'base'], { GSTACK_SPEND_CAP_USD: '2' });
expect(r.stderr).not.toContain('--remote spends money');
});
test('a bash bin running bun -e and a hook wrapper ignore the repo bunfig and .env', () => {
const learning = JSON.stringify({ skill: 'review', type: 'pattern', key: 'fixture', insight: 'fixture insight', confidence: 5, source: 'observed' });
const r = run(path.join(BIN, 'gstack-learnings-log'), [learning]);
expect(r.status).toBe(0);
expect(fs.existsSync(evilRoot)).toBe(false);
const hook = run(path.join(ROOT, 'hosts', 'claude', 'hooks', 'question-log-hook'), [], {}, '{}');
expect(hook.error).toBeUndefined();
expect(fs.existsSync(preloadMarker)).toBe(false);
expect(fs.existsSync(evilRoot)).toBe(false);
});
});