Files
gstack/test/gstack-slug-sanitize.test.ts
T
Garry TanandClaude Fable 5 5af4a03b13 fix(slug): env-override runs never persist to the cwd cache; cache is GSTACK_HOME-aware
Found while closing the wave's eval gate: a test exporting
GSTACK_PROJECT_SLUG from the repo root persisted the override into the cwd
slug cache, silently rebinding the ENTIRE repo's session state (evals,
decisions, timelines) to the test's slug for every later env-less run. The
escape hatch is per-invocation by contract — it no longer writes the cache.
The cache dir also hardcoded $HOME while lib/bin-context.ts's native port
(#2561) reads it GSTACK_HOME-aware, so temp-home test runs littered the real
~/.gstack (observed: 2,528 stale temp-cwd entries, swept). Writer and reader
now key the same GSTACK_HOME-aware cache; regression tests pin both
behaviors.

Also raises the cso --diff eval budget (240s/25t → 360s/40t):
transcript-verified, the wave's legitimately-grown audit session completes
the report and dies in closing telemetry at ~215s under the old budget; the
full-audit sibling already runs at 300s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 12:25:08 -07:00

103 lines
4.0 KiB
TypeScript

/**
* gstack-slug cache-read sanitization.
*
* `eval "$(gstack-slug)"` is how callers load SLUG/BRANCH. The compute and
* fallback paths filter to [a-zA-Z0-9._-], but a value read straight from the
* cache file used to be echoed unsanitized — a planted cache file could inject
* shell. This pins the fix: a poisoned cache must never produce shell
* metacharacters in the SLUG= output line.
*/
import { describe, test, expect } from 'bun:test';
import { spawnSync } from 'bun';
import fs from 'fs';
import os from 'os';
import path from 'path';
const ROOT = path.resolve(__dirname, '..');
const SLUG_BIN = path.join(ROOT, 'bin', 'gstack-slug');
/** Reproduce the script's cache-key derivation: absolute path with / -> _. */
function cacheKeyFor(dir: string): string {
return dir.replace(/\//g, '_');
}
function runSlug(cwd: string, home: string) {
return spawnSync([SLUG_BIN], {
cwd,
env: { ...process.env, HOME: home },
});
}
describe('gstack-slug cache-read sanitization', () => {
test('a poisoned cache file cannot inject shell metacharacters into output', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gslug-home-'));
const proj = fs.mkdtempSync(path.join(os.tmpdir(), 'gslug-proj-'));
try {
const cacheDir = path.join(home, '.gstack', 'slug-cache');
fs.mkdirSync(cacheDir, { recursive: true });
// realpath: macOS tmpdir is a symlink (/var -> /private/var); the script
// runs in the resolved cwd, so key off the resolved path.
const realProj = fs.realpathSync(proj);
const payload = 'evil"; touch ' + path.join(home, 'pwned') + '; echo "x';
fs.writeFileSync(path.join(cacheDir, cacheKeyFor(realProj)), payload);
const out = runSlug(realProj, home);
const stdout = out.stdout.toString();
const slugLine = stdout.split('\n').find((l) => l.startsWith('SLUG='));
expect(slugLine).toBeDefined();
const slugValue = slugLine!.slice('SLUG='.length);
// The value must be sanitized: only [a-zA-Z0-9._-], no quotes/semicolons/spaces.
expect(slugValue).toMatch(/^[a-zA-Z0-9._-]*$/);
expect(slugLine).not.toContain('"');
expect(slugLine).not.toContain(';');
expect(slugLine).not.toContain(' ');
// And the injection must not have fired during the script's own run.
expect(fs.existsSync(path.join(home, 'pwned'))).toBe(false);
} finally {
fs.rmSync(home, { recursive: true, force: true });
fs.rmSync(proj, { recursive: true, force: true });
}
});
});
// The GSTACK_PROJECT_SLUG escape hatch is per-invocation, never durable: a
// test exporting it from the repo root once rebound the ENTIRE repo's session
// state (evals, decisions, timelines) to the test's slug via the cwd cache.
// The cache is also GSTACK_HOME-aware now, matching lib/bin-context.ts's
// native port — temp-home runs must not litter the real ~/.gstack (observed:
// 2,528 stale temp-cwd entries).
describe('slug cache hygiene', () => {
test('an env-override run never writes the cwd cache', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'slug-env-'));
try {
const r = spawnSync(['bash', SLUG_BIN], {
cwd: os.tmpdir(),
env: { ...process.env, GSTACK_HOME: home, GSTACK_PROJECT_SLUG: 'override-slug' },
});
expect(r.stdout.toString()).toContain('SLUG=override-slug');
expect(fs.existsSync(path.join(home, 'slug-cache'))).toBe(false);
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
});
test('an env-less run caches under GSTACK_HOME, not $HOME', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'slug-home-'));
try {
const r = spawnSync(['bash', SLUG_BIN], {
cwd: os.tmpdir(),
env: { ...process.env, GSTACK_HOME: home },
});
expect(r.exitCode).toBe(0);
const entries = fs.readdirSync(path.join(home, 'slug-cache'));
expect(entries.length).toBe(1);
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
});
});