mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
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>
This commit is contained in:
1 parent
cb9ccf2e80
commit
4c6fa89cdc
273 files changed
+1029
-669
No files matched your search
@@ -11,6 +11,7 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { bunIsolationArgs } from './bun-isolation';
|
||||
import { readScopeMarker, scopeMarker, scopeTokens, type ScopeFlags, type UiEvidence } from './autoplan-scope';
|
||||
import { COORDINATOR_CONTRACT } from './coordinator-contract';
|
||||
import { capyBootstrapCommand } from './doctor-components';
|
||||
@@ -41,7 +42,7 @@ export interface Scope {
|
||||
}
|
||||
|
||||
export function snapshotTool(args: string[], cwd: string): any {
|
||||
const r = spawnSync(process.execPath, [SNAPSHOT_TOOL, ...args], { cwd, encoding: 'utf8', timeout: 120_000, env: process.env });
|
||||
const r = spawnSync(process.execPath, [...bunIsolationArgs(), SNAPSHOT_TOOL, ...args], { cwd, encoding: 'utf8', timeout: 120_000, env: process.env });
|
||||
if (r.status !== 0) throw new Error(`gstack-autoplan-snapshot ${args[0]}: ${(r.stderr || r.stdout).trim()}`);
|
||||
return JSON.parse(r.stdout);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* How gstack starts Bun (GSTA-56). Bun configures itself from the directory it
|
||||
* starts in: `.env`, `.env.local` and `.env.<NODE_ENV>` become environment
|
||||
* variables, and a `bunfig.toml` preload runs code before the entry point.
|
||||
* gstack runs inside the user's repository, so that directory belongs to the
|
||||
* code under review. Every Bun gstack starts passes --no-env-file and an empty
|
||||
* --config: shebangs and shell callers spell it
|
||||
* `bun --no-env-file --config=/dev/null`, TypeScript spawns use
|
||||
* bunIsolationArgs(). test/bun-entry-isolation.test.ts holds every entry point
|
||||
* to that form.
|
||||
*
|
||||
* POSIX passes /dev/null. Native Windows has no /dev/null and `--config=NUL`
|
||||
* panicked Bun 1.3.10 while parsing the flag (#3085), so a TypeScript spawn on
|
||||
* Windows gets an empty bunfig in a fixed directory under the per-user temp
|
||||
* root, re-checked to be a zero-byte regular file before each spawn; anything
|
||||
* else is replaced, so the file cannot become a preload vector. The file is
|
||||
* never removed: a detached child (the timeline hook's repair) reads it after
|
||||
* the parent that named it has exited.
|
||||
* browse/src/bun-child-config.ts keeps its own copy of emptyBunConfig(): the
|
||||
* Windows native cookie qualification binds that file's bytes and bundles it
|
||||
* without lib/.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
function isEmptyRegularFile(file: string): boolean {
|
||||
try {
|
||||
const stat = fs.lstatSync(file);
|
||||
return stat.isFile() && stat.size === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function emptyBunConfig(platform: NodeJS.Platform = process.platform): string {
|
||||
if (platform !== 'win32') return '/dev/null';
|
||||
const dir = path.join(os.tmpdir(), 'gstack-empty-bunfig');
|
||||
const file = path.join(dir, 'bunfig.toml');
|
||||
if (isEmptyRegularFile(file)) return file;
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
if (!fs.lstatSync(dir).isDirectory()) throw new Error(`empty Bun config directory ${dir} is not a directory`);
|
||||
fs.rmSync(file, { recursive: true, force: true });
|
||||
try {
|
||||
fs.closeSync(fs.openSync(file, 'wx', 0o600));
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err;
|
||||
}
|
||||
if (!isEmptyRegularFile(file)) throw new Error(`empty Bun config ${file} is not a zero-byte file`);
|
||||
return file;
|
||||
}
|
||||
|
||||
/** The flags that keep a spawned Bun from reading the working directory's .env and bunfig.toml. */
|
||||
export function bunIsolationArgs(platform: NodeJS.Platform = process.platform): string[] {
|
||||
return ['--no-env-file', `--config=${emptyBunConfig(platform)}`];
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { bunIsolationArgs } from './bun-isolation';
|
||||
|
||||
const OLD = 'gstack-claude';
|
||||
const NEXT = 'gstack-claude-code';
|
||||
@@ -152,11 +153,11 @@ export function migrateClaudeCodeSkills(opts: RenameOptions): { migrated: number
|
||||
const oldSources = targets.map(t => path.join(root, t.subdir, 'skills', OLD, 'SKILL.md'));
|
||||
const result = { migrated: 0, pending: [] as string[] };
|
||||
const render = opts.render ?? ((host: string, output: string) => {
|
||||
const args = ['run', 'scripts/gen-skill-docs.ts', '--host', host, '--out-dir', output];
|
||||
const args = [...bunIsolationArgs(), 'run', 'scripts/gen-skill-docs.ts', '--host', host, '--out-dir', output];
|
||||
if (host === 'codex') {
|
||||
let model = env.GSTACK_CODEX_GENERATION_MODEL;
|
||||
if (!model) {
|
||||
const probe = spawnSync(process.execPath, ['run', 'scripts/resolve-codex-generation-model.ts'], { cwd: root, env, encoding: 'utf8', timeout: 30_000 });
|
||||
const probe = spawnSync(process.execPath, [...bunIsolationArgs(), 'run', 'scripts/resolve-codex-generation-model.ts'], { cwd: root, env, encoding: 'utf8', timeout: 30_000 });
|
||||
if (probe.status !== 0) throw new Error('could not resolve the Codex model profile');
|
||||
model = probe.stdout.trim().split('\t')[0];
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env bun
|
||||
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env bun
|
||||
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
|
||||
/**
|
||||
* Runtime-image half of the CSO preparation contract. This executable runs
|
||||
* only inside a qualified, network-none container. Acquisition egress is a
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env bun
|
||||
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
|
||||
import * as fs from 'node:fs';
|
||||
import { connect } from 'node:net';
|
||||
import {
|
||||
|
||||
+2
-1
@@ -2,6 +2,7 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { bunIsolationArgs } from './bun-isolation';
|
||||
import { constants as osConstants } from 'node:os';
|
||||
import { initializeWindowsReviewJob } from './claude-code-windows-job';
|
||||
|
||||
@@ -215,7 +216,7 @@ export async function runQaWindowsWorker(args: string[], emit: Emit, entrypoint:
|
||||
captureFiles?: { stdout: number; stderr: number }): Promise<number> {
|
||||
try { await initializeWindowsReviewJob(); } catch { throw new QaDeadlineError('Windows process containment is unavailable; no command was started'); }
|
||||
return new Promise<number>(resolve => {
|
||||
const worker = spawn(process.execPath, [...process.execArgv, entrypoint, '--receipt-worker', ...args], {
|
||||
const worker = spawn(process.execPath, [...bunIsolationArgs(), ...process.execArgv, entrypoint, '--receipt-worker', ...args], {
|
||||
stdio: ['inherit', captureFiles?.stdout ?? 'inherit', captureFiles?.stderr ?? 'inherit', 'ipc'], windowsHide: true,
|
||||
});
|
||||
const kill = () => { worker.kill('SIGKILL'); };
|
||||
|
||||
+3
-2
@@ -24,6 +24,7 @@ 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 { bunIsolationArgs } from './bun-isolation';
|
||||
import { checkChangelog, mergeChangelogs, parseChangelog, reheadChangelog } from './changelog-check';
|
||||
import { type ResultCodeName } from './result-codes';
|
||||
import { runScriptSync, wtreeFingerprint } from './script-invocation';
|
||||
@@ -178,7 +179,7 @@ function planVersions(o: RestampOptions, loaded: LoadedPolicy, policyLabel: stri
|
||||
newVersion = bumped(atBase.version, level);
|
||||
claims = 'ignored (stamp-at-merge)';
|
||||
} else {
|
||||
const r = spawnSync(process.execPath, [path.join(BIN_DIR, 'gstack-next-version'), '--base', o.base, '--bump', level, '--current-version', atBase.version], { cwd: o.repoRoot, encoding: 'utf8', timeout: 120_000, env: o.env ?? process.env });
|
||||
const r = spawnSync(process.execPath, [...bunIsolationArgs(), path.join(BIN_DIR, 'gstack-next-version'), '--base', o.base, '--bump', level, '--current-version', atBase.version], { cwd: o.repoRoot, encoding: 'utf8', timeout: 120_000, env: o.env ?? process.env });
|
||||
let q: any = null;
|
||||
try { q = JSON.parse(r.stdout ?? ''); } catch {}
|
||||
const usable = r.status === 0 && q && typeof q.version === 'string' && parseVersion(q.version) && (q.offline === false || q.fallback === 'git');
|
||||
@@ -354,7 +355,7 @@ function runStages(o: RestampOptions, plan: Plan, staging: string, since: string
|
||||
const v = stage('version');
|
||||
if (command) { v.status = 'skipped'; v.detail = 'restamp_command owns the version'; }
|
||||
else {
|
||||
const w = spawnSync(process.execPath, [path.join(BIN_DIR, 'gstack-version-bump'), 'write', '--version', plan.newVersion], { cwd: staging, encoding: 'utf8', timeout: 120_000, env });
|
||||
const w = spawnSync(process.execPath, [...bunIsolationArgs(), path.join(BIN_DIR, 'gstack-version-bump'), 'write', '--version', plan.newVersion], { cwd: staging, encoding: 'utf8', timeout: 120_000, env });
|
||||
if (w.status !== 0) { v.status = 'failed'; v.detail = (w.stderr ?? '').trim().slice(0, 300); throw new Refusal('RESTAMP_VERSION_SOURCE', `gstack-version-bump write failed: ${v.detail}; nothing written`); }
|
||||
v.status = 'done'; v.detail = `wrote ${plan.newVersion}`;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import { spawnSync, type SpawnSyncOptions, type SpawnSyncOptionsWithStringEncodi
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { bunIsolationArgs } from './bun-isolation';
|
||||
import { resultLine } from './result-codes';
|
||||
|
||||
export const BIN_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'bin');
|
||||
@@ -205,7 +206,7 @@ function windowsBash(o: InvocationOptions): { ok: true; bash: string } | { ok: f
|
||||
|
||||
/** How to run a gstack shebang bin on this platform. */
|
||||
export function scriptInvocation(script: string, args: readonly string[], o: InvocationOptions = {}): Invocation {
|
||||
if (/^#!.*\bbun\b/.test(readShebang(script))) return { ok: true, cmd: o.bunExe ?? bunExecutable(), argv: [script, ...args] };
|
||||
if (/^#!.*\bbun\b/.test(readShebang(script))) return { ok: true, cmd: o.bunExe ?? bunExecutable(), argv: [...bunIsolationArgs((o.platform ?? process.platform) as NodeJS.Platform), script, ...args] };
|
||||
if ((o.platform ?? process.platform) !== 'win32') return { ok: true, cmd: script, argv: [...args] };
|
||||
const bash = windowsBash(o);
|
||||
return bash.ok ? { ok: true, cmd: bash.bash, argv: [slash(script), ...args] } : bash;
|
||||
|
||||
Reference in new issue
Block a user