fix(security-classifier): TDZ when claude CLI is missing from PATH

The checkTranscript Promise executor in browse/src/security-classifier.ts
referenced `finish()` at the !claude early-return guard before declaring
it 5 lines later. JavaScript throws ReferenceError: Cannot access 'finish'
before initialization (TDZ) for that path, but the path is only reachable
when resolveClaudeCommand returns null inside the spawn block (a TOCTOU
window vs. the outer checkHaikuAvailable cache).

Fix: hoist `let stdout = ''`, `let done = false`, and `const finish` block
above `const claude = resolveClaudeCommand()` so finish is in scope before
any reference to it. Behavior is identical when claude is on PATH; the
fix only matters for the dormant missing-CLI degraded path.

Adds browse/test/security-classifier-tdz.test.ts as the regression guard:
clears PATH + override env vars, calls checkTranscript, asserts the result
serializes with degraded:true and a meaningful reason field.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-05-11 23:20:07 -07:00
parent bed1a9f5ed
commit 4b3bbed242
2 changed files with 79 additions and 8 deletions
+11 -8
View File
@@ -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) {
@@ -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<string, unknown>).
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_)/);
});
});