mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
* 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>
162 lines
7.8 KiB
TypeScript
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);
|
|
});
|
|
});
|