diff --git a/browse/src/security-classifier.ts b/browse/src/security-classifier.ts index 5cd852bb2..a74844cbc 100644 --- a/browse/src/security-classifier.ts +++ b/browse/src/security-classifier.ts @@ -500,6 +500,17 @@ export async function checkTranscript(params: { // timeout rate in the v1.5.2.0 ensemble bench because of this, plus // ~44k cache_creation tokens per call (massive cost inflation). // Using os.tmpdir() gives Haiku a clean context for pure classification. + // TDZ fix: declare `finish` BEFORE `resolveClaudeCommand` so the early + // return at the !claude guard below doesn't ReferenceError. Triggered + // only when claude CLI is missing from PATH (dormant otherwise). + let stdout = ''; + let done = false; + const finish = (signal: LayerSignal) => { + if (done) return; + done = true; + resolve(signal); + }; + const claude = resolveClaudeCommand(); if (!claude) { return finish({ layer: 'transcript_classifier', confidence: 0, meta: { degraded: true, reason: 'claude_cli_not_found' } }); @@ -511,14 +522,6 @@ export async function checkTranscript(params: { '--output-format', 'json', ], { stdio: ['ignore', 'pipe', 'pipe'], cwd: os.tmpdir() }); - let stdout = ''; - let done = false; - const finish = (signal: LayerSignal) => { - if (done) return; - done = true; - resolve(signal); - }; - p.stdout.on('data', (d: Buffer) => (stdout += d.toString())); p.on('exit', (code) => { if (code !== 0) { diff --git a/browse/test/security-classifier-tdz.test.ts b/browse/test/security-classifier-tdz.test.ts new file mode 100644 index 000000000..5da4be939 --- /dev/null +++ b/browse/test/security-classifier-tdz.test.ts @@ -0,0 +1,68 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; + +/** + * Regression test for the TDZ (Temporal Dead Zone) bug at the claude-CLI-missing + * early return inside checkTranscript's Promise executor. + * + * Original bug: + * const claude = resolveClaudeCommand(); + * if (!claude) return finish({...}); // ← TDZ: finish not yet declared + * const p = spawn(...); + * let done = false; + * const finish = (...) => {...}; // ← declared HERE, too late + * + * Fix: hoist `let done` + `const finish` above the resolveClaudeCommand call. + * + * This test exercises the outer guard (checkHaikuAvailable returning false when + * claude CLI is not on PATH), which is the realistic runtime path. The TDZ + * itself was inside the spawn Promise — only reachable in a TOCTOU window if + * claude went missing between checkHaikuAvailable and the spawn call. The fix + * makes that window safe regardless. This test guards against regression by + * proving the missing-CLI flow returns the expected degraded signal without + * throwing. + */ +describe('security-classifier: missing claude CLI degraded path', () => { + let origPath: string | undefined; + let origGstackClaudeBin: string | undefined; + let origClaudeBin: string | undefined; + + beforeEach(() => { + origPath = process.env.PATH; + origGstackClaudeBin = process.env.GSTACK_CLAUDE_BIN; + origClaudeBin = process.env.CLAUDE_BIN; + // Force resolveClaudeCommand() to fail: clear PATH AND override env vars + // (resolveClaudeCommand in browse/src/claude-bin.ts honors GSTACK_CLAUDE_BIN + // and CLAUDE_BIN before falling back to Bun.which(PATH)). + process.env.PATH = '/nonexistent'; + delete process.env.GSTACK_CLAUDE_BIN; + delete process.env.CLAUDE_BIN; + }); + + afterEach(() => { + if (origPath === undefined) delete process.env.PATH; + else process.env.PATH = origPath; + if (origGstackClaudeBin !== undefined) process.env.GSTACK_CLAUDE_BIN = origGstackClaudeBin; + if (origClaudeBin !== undefined) process.env.CLAUDE_BIN = origClaudeBin; + }); + + test('checkTranscript returns degraded signal without throwing when claude CLI is unavailable', async () => { + // Fresh import so haikuAvailableCache isn't already populated from a prior test. + // Bun's module cache is per-test-file; this fresh import path stays clean. + const { checkTranscript } = await import('../src/security-classifier'); + + const result = await checkTranscript({ + user_message: 'hello', + tool_calls: [], + }); + + // Assert via JSON serialization to bypass any TS narrowing quirks on + // result.meta (Record). + const serialized = JSON.stringify(result); + expect(serialized).toContain('"layer":"transcript_classifier"'); + expect(serialized).toContain('"confidence":0'); + expect(serialized).toContain('"degraded":true'); + // Reason must indicate the CLI was missing or the spawn failed — proves the + // early-return / spawn-path returned a structured signal without throwing. + expect(serialized).toMatch(/"reason":"(claude_cli_not_found|spawn_error|exit_)/); + }); +});