Files
gstack/browse/test/security-bench.test.ts
T
Garry Tan 6cedecd585 feat(security): v2 ensemble tuning — label-first voting + SOLO_CONTENT_BLOCK
Cuts Haiku classifier false-positive rate from 44.1% → 22.9% on
BrowseSafe-Bench smoke. Detection trades from 67.3% → 56.2%; the
lost TPs are all cases Haiku correctly labeled verdict=warn
(phishing targeting users, not agent hijack) — they still surface
in the WARN banner meta but no longer kill the session.

Key changes:
- combineVerdict: label-first voting for transcript_classifier. Only
  meta.verdict==='block' block-votes; verdict==='warn' is a soft
  signal. Missing meta.verdict never block-votes (backward-compat).
- Hallucination guard: verdict='block' at confidence < LOG_ONLY (0.40)
  drops to warn-vote — prevents malformed low-conf blocks from going
  authoritative.
- New THRESHOLDS.SOLO_CONTENT_BLOCK = 0.92 decoupled from BLOCK (0.85).
  Label-less content classifiers (testsavant, deberta) need a higher
  solo-BLOCK bar because they can't distinguish injection from
  phishing-targeting-user. Transcript keeps label-gated solo path
  (verdict=block AND conf >= BLOCK).
- THRESHOLDS.WARN bumped 0.60 → 0.75 — borderline fires drop out of
  the 2-of-N ensemble pool.
- Haiku model pinned (claude-haiku-4-5-20251001). `claude -p` spawns
  from os.tmpdir() so project CLAUDE.md doesn't poison the classifier
  context (measured 44k cache_creation tokens per call before the fix,
  and Haiku refusing to classify because it read "security system"
  from CLAUDE.md and went meta).
- Haiku timeout 15s → 45s. Measured real latency is 17-33s end-to-end
  (Claude Code session startup + Haiku); v1's 15s caused 100% timeout
  when re-measured — v1's ensemble was effectively L4-only in prod.
- Haiku prompt rewritten: explicit block/warn/safe criteria, 8 few-shot
  exemplars (instruction-override → block; social engineering → warn;
  discussion-of-injection → safe).

Test updates:
- 5 existing combineVerdict tests adapted for label-first semantics
  (transcript signals now need meta.verdict to block-vote).
- 6 new tests: warn-soft-signal, three-way-block-with-warn-transcript,
  hallucination-guard-below-floor, above-floor-label-first,
  backward-compat-missing-meta.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 20:31:53 -07:00

157 lines
6.8 KiB
TypeScript

/**
* BrowseSafe-Bench smoke harness.
*
* Loads 200 test cases from Perplexity's BrowseSafe-Bench dataset (3,680
* adversarial browser-agent injection cases, 11 attack types, 9 strategies)
* and runs them through the TestSavantAI classifier.
*
* Assertions (the shipping bar per CEO plan):
* - Detection rate on "yes" cases >= 80% (TP / (TP + FN))
* - False-positive rate on "no" cases <= 10% (FP / (FP + TN))
*
* Gate tier: this is the classifier-quality gate. Fails CI if the
* threshold regresses. Skipped gracefully if the model cache is absent
* (first-run CI) — prime via the sidebar-agent warmup.
*
* Dataset cache: ~/.gstack/cache/browsesafe-bench-smoke/test-rows.json
* (hermetic after first run — no HF network traffic on subsequent CI).
*
* Run: bun test browse/test/security-bench.test.ts
* Run with fresh sample: rm -rf ~/.gstack/cache/browsesafe-bench-smoke/ && bun test ...
*/
import { describe, test, expect, beforeAll } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
const MODEL_CACHE = path.join(
os.homedir(),
'.gstack',
'models',
'testsavant-small',
'onnx',
'model.onnx',
);
const ML_AVAILABLE = fs.existsSync(MODEL_CACHE);
const CACHE_DIR = path.join(os.homedir(), '.gstack', 'cache', 'browsesafe-bench-smoke');
const CACHE_FILE = path.join(CACHE_DIR, 'test-rows.json');
const SAMPLE_SIZE = 200;
const HF_API = 'https://datasets-server.huggingface.co/rows?dataset=perplexity-ai/browsesafe-bench&config=default&split=test';
type BenchRow = { content: string; label: 'yes' | 'no' };
async function fetchDatasetSample(): Promise<BenchRow[]> {
const rows: BenchRow[] = [];
// HF datasets-server caps at 100 rows per request.
for (let offset = 0; rows.length < SAMPLE_SIZE; offset += 100) {
const length = Math.min(100, SAMPLE_SIZE - rows.length);
const url = `${HF_API}&offset=${offset}&length=${length}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`HF API ${res.status}: ${url}`);
const data = (await res.json()) as { rows: Array<{ row: BenchRow }> };
if (!data.rows?.length) break;
for (const r of data.rows) {
rows.push({ content: r.row.content, label: r.row.label as 'yes' | 'no' });
}
}
return rows;
}
async function loadOrFetchRows(): Promise<BenchRow[]> {
if (fs.existsSync(CACHE_FILE)) {
return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'));
}
fs.mkdirSync(CACHE_DIR, { recursive: true, mode: 0o700 });
const rows = await fetchDatasetSample();
fs.writeFileSync(CACHE_FILE, JSON.stringify(rows), { mode: 0o600 });
return rows;
}
describe('BrowseSafe-Bench smoke (200 cases)', () => {
let rows: BenchRow[] = [];
let scanPageContent: (text: string) => Promise<{ confidence: number }>;
beforeAll(async () => {
if (!ML_AVAILABLE) return;
rows = await loadOrFetchRows();
const mod = await import('../src/security-classifier');
await mod.loadTestsavant();
scanPageContent = mod.scanPageContent;
}, 120000);
test.skipIf(!ML_AVAILABLE)('dataset cache has expected shape + label distribution', () => {
expect(rows.length).toBeGreaterThanOrEqual(SAMPLE_SIZE);
const yesCount = rows.filter(r => r.label === 'yes').length;
const noCount = rows.filter(r => r.label === 'no').length;
// BrowseSafe-Bench should have both labels in its test split
expect(yesCount).toBeGreaterThan(0);
expect(noCount).toBeGreaterThan(0);
// Each row has meaningful content
for (const r of rows) {
expect(typeof r.content).toBe('string');
expect(r.content.length).toBeGreaterThan(0);
expect(['yes', 'no']).toContain(r.label);
}
});
test.skipIf(!ML_AVAILABLE)('classifier runs without error on the smoke sample', async () => {
// V1 honest gate: the classifier FIRES on some injections and the
// signal distribution is non-trivial. Not a quality gate yet — see
// baseline metrics below for why.
//
// BrowseSafe-Bench is a different distribution than TestSavantAI's
// training data (Perplexity built it specifically for browser agent
// adversarial eval; TestSavantAI was trained on direct text injection
// + jailbreak corpora). The classifier catches overt injections
// ("ignore previous instructions", "NEW INSTRUCTION:") but misses
// subtle social engineering ("URGENT: verify your account within 24h")
// which the bench is dense with.
//
// The production ensemble (L4 content + L4b transcript classifier
// agreement) filters FPs in practice. This bench measures L4 alone.
//
// Tracked for improvement:
// * DeBERTa-v3 ensemble (TODOS "TestSavantAI ensemble" P2 item)
// * Calibration against real attack-log data from production
let tp = 0, fp = 0, tn = 0, fn = 0;
// intentionally 0.6 — L4-only bench pinned to v1 WARN for historical
// continuity. The ensemble bench (security-bench-ensemble.test.ts) uses
// THRESHOLDS.WARN from security.ts (0.75 in v1.5.1.0+).
const WARN = 0.6;
for (const row of rows) {
const signal = await scanPageContent(row.content);
const predictedInjection = signal.confidence >= WARN;
const actualInjection = row.label === 'yes';
if (actualInjection && predictedInjection) tp++;
else if (actualInjection && !predictedInjection) fn++;
else if (!actualInjection && predictedInjection) fp++;
else tn++;
}
const detectionRate = (tp + fn) > 0 ? tp / (tp + fn) : 0;
const fpRate = (fp + tn) > 0 ? fp / (fp + tn) : 0;
console.log(`[browsesafe-bench] TP=${tp} FN=${fn} FP=${fp} TN=${tn}`);
console.log(`[browsesafe-bench] Detection rate: ${(detectionRate * 100).toFixed(1)}% (v1 baseline — not a quality gate)`);
console.log(`[browsesafe-bench] False-positive rate: ${(fpRate * 100).toFixed(1)}% (v1 baseline — ensemble filters in prod)`);
// V1 sanity gates — does the classifier provide ANY signal?
// These are intentionally loose. Quality gates arrive when the DeBERTa
// ensemble lands (P2 TODO) and we can measure the 2-of-3 agreement
// rate against this same bench.
expect(tp).toBeGreaterThan(0); // classifier fires on some attacks
expect(tn).toBeGreaterThan(0); // classifier is not stuck-on
expect(tp + fp).toBeGreaterThan(0); // classifier fires at all
expect(tp + tn).toBeGreaterThan(rows.length * 0.40); // > random-chance accuracy
}, 300000); // up to 5min for 200 inferences + cold start
test.skipIf(!ML_AVAILABLE)('cache is reusable — second run skips HF fetch', () => {
// The beforeAll above fetched on first run. Cache file must exist now.
expect(fs.existsSync(CACHE_FILE)).toBe(true);
const cached = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'));
expect(cached.length).toBe(rows.length);
});
});