diff --git a/browse/src/config.ts b/browse/src/config.ts index dd6197cf6..8f23b1570 100644 --- a/browse/src/config.ts +++ b/browse/src/config.ts @@ -114,6 +114,20 @@ export function ensureStateDir(config: BrowseConfig): void { throw err; } + // Load-bearing guard: a self-contained ignore INSIDE the state dir so its + // contents can NEVER be `git add`-ed, regardless of the project's own + // .gitignore (which may be absent, or the append below may silently fail). + // The state dir holds session-state.json (live cookies + localStorage/ + // sessionStorage tokens) and browse-network.log / browse-audit.jsonl + // (captured request headers can carry bearer tokens). Written unconditionally, + // synchronously, before return — the project-.gitignore dance below is now + // redundant safety, kept so `.gstack/` still reads as ignored in git status. + try { + fs.writeFileSync(path.join(config.stateDir, '.gitignore'), '*\n'); + } catch { + // Best-effort; the project-.gitignore path below is the fallback. + } + // Ensure .gstack/ is in the project's .gitignore // First, check if git already ignores .gstack/ (via global excludes, .git/info/exclude, or parent .gitignore) if (isIgnoredByGit(config.projectDir, '.gstack/')) return; diff --git a/browse/test/config.test.ts b/browse/test/config.test.ts index 5f8cd5535..8d24c3f00 100644 --- a/browse/test/config.test.ts +++ b/browse/test/config.test.ts @@ -61,6 +61,21 @@ describe('config', () => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); + test('writes a self-contained .gstack/.gitignore with * unconditionally', () => { + // Even with NO project .gitignore, the state dir must carry its own + // ignore so persisted cookies / network+audit logs can never be git-added. + const tmpDir = path.join(os.tmpdir(), `browse-selfignore-test-${Date.now()}`); + fs.mkdirSync(tmpDir, { recursive: true }); + const config = resolveConfig({ BROWSE_STATE_FILE: path.join(tmpDir, '.gstack', 'browse.json') }); + ensureStateDir(config); + const selfIgnore = path.join(config.stateDir, '.gitignore'); + expect(fs.existsSync(selfIgnore)).toBe(true); + expect(fs.readFileSync(selfIgnore, 'utf-8')).toBe('*\n'); + // No nesting: the ignore is directly inside the state dir, not .gstack/.gstack/. + expect(fs.existsSync(path.join(config.stateDir, '.gstack'))).toBe(false); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + test('adds .gstack/ to .gitignore if not present', () => { const tmpDir = path.join(os.tmpdir(), `browse-gitignore-test-${Date.now()}`); fs.mkdirSync(tmpDir, { recursive: true }); diff --git a/browse/test/server-no-import-side-effects.test.ts b/browse/test/server-no-import-side-effects.test.ts index 2dceec131..8f592356f 100644 --- a/browse/test/server-no-import-side-effects.test.ts +++ b/browse/test/server-no-import-side-effects.test.ts @@ -42,9 +42,13 @@ const sigtermAfter = process.listenerCount('SIGTERM'); const uncaughtAfter = process.listenerCount('uncaughtException'); // Check that the gstack home directory wasn't populated as a side effect. +// A lone \`.gitignore\` (the state-dir ignore guard, contents "*") is expected +// and is NOT leaked state — ensureStateDir writes it so persisted cookies/logs +// can never be git-committed. Any OTHER entry (browse.json, session-state.json, +// logs) would be a real auto-start write and must still fail the guard. let gstackPopulated = false; try { - const entries = fs.readdirSync(${JSON.stringify(tmpGstack)}); + const entries = fs.readdirSync(${JSON.stringify(tmpGstack)}).filter(e => e !== '.gitignore'); gstackPopulated = entries.length > 0; } catch { // Doesn't exist — that's the win we want.