add gstack 2 parity and lifecycle gates

This commit is contained in:
Sinabina
2026-07-17 11:08:32 -07:00
parent b6572ebbb7
commit 9919c4cdd3
212 changed files with 29098 additions and 3845 deletions
+176 -108
View File
@@ -1,135 +1,203 @@
/**
* Source-level contract tests for security code paths that are not exported
* and therefore not reachable from unit tests. Follows the same convention
* as sidebar-security.test.ts — asserts specific invariants by grep'ing the
* source tree.
* Source-level security contracts for the terminal-first sidebar.
*
* These tests fail fast if a future refactor silently drops:
* * A canary-leak check on one of the known outbound channels
* * The SCANNED_TOOLS set for post-tool-result ML scans
* * The security_event relay in server.ts processAgentEvent
* * The canary field on the queue entry (server → sidebar-agent)
* These checks intentionally cover unexported routing and lifecycle code. The
* retired one-shot sidebar-agent/chat pipeline is not a fallback architecture:
* terminal-agent.ts owns shell transport, while server.ts only brokers local
* PTY sessions and pre-injection scans.
*/
import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
import { describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
const AGENT_SRC = fs.readFileSync(
path.join(import.meta.dir, '../src/sidebar-agent.ts'),
'utf-8',
);
const SERVER_SRC = fs.readFileSync(
path.join(import.meta.dir, '../src/server.ts'),
'utf-8',
);
const SRC_DIR = path.join(import.meta.dir, '../src');
const TERMINAL_SRC = fs.readFileSync(path.join(SRC_DIR, 'terminal-agent.ts'), 'utf8');
const SERVER_SRC = fs.readFileSync(path.join(SRC_DIR, 'server.ts'), 'utf8');
describe('detectCanaryLeak — channel coverage (source)', () => {
test('covers assistant_text channel', () => {
expect(AGENT_SRC).toContain("'assistant_text'");
function section(source: string, start: string, end: string): string {
const startIndex = source.indexOf(start);
if (startIndex < 0) throw new Error(`Missing source contract start: ${start}`);
const endIndex = source.indexOf(end, startIndex + start.length);
if (endIndex < 0) throw new Error(`Missing source contract end: ${end}`);
return source.slice(startIndex, endIndex);
}
describe('retired sidebar-agent/chat surface', () => {
test('deleted agent source and dedicated tests stay absent', () => {
for (const relativePath of [
'sidebar-agent.ts',
'../test/sidebar-agent.test.ts',
'../test/sidebar-agent-roundtrip.test.ts',
]) {
expect(fs.existsSync(path.join(SRC_DIR, relativePath))).toBe(false);
}
});
test('covers tool_use arguments via checkCanaryInStructure', () => {
expect(AGENT_SRC).toMatch(/checkCanaryInStructure\(block\.input, canary\)/);
expect(AGENT_SRC).toMatch(/checkCanaryInStructure\(event\.content_block\.input, canary\)/);
test('server has no retired chat or agent route handlers', () => {
expect(SERVER_SRC).not.toMatch(/url\.pathname === ['"]\/sidebar-(?:chat|command)['"]/);
expect(SERVER_SRC).not.toMatch(/url\.pathname\.startsWith\(['"]\/sidebar-agent\//);
expect(SERVER_SRC).not.toMatch(/url\.pathname === ['"]\/sidebar-agent\/(?:event|kill|stop)['"]/);
expect(SERVER_SRC).toContain('chatEnabled: false');
});
test('covers text_delta streaming channel', () => {
expect(AGENT_SRC).toContain("'text_delta'");
expect(AGENT_SRC).toContain("event.delta?.type === 'text_delta'");
});
test('covers input_json_delta (streaming tool args)', () => {
expect(AGENT_SRC).toContain("'tool_input_delta'");
expect(AGENT_SRC).toContain("event.delta?.type === 'input_json_delta'");
});
test('covers result channel (final claude event)', () => {
expect(AGENT_SRC).toContain("event.type === 'result'");
expect(AGENT_SRC).toContain('event.result.includes(canary)');
test('server does not recreate processAgentEvent or spawnClaude', () => {
expect(SERVER_SRC).not.toMatch(/^\s*(?:async\s+)?function\s+processAgentEvent\s*\(/m);
expect(SERVER_SRC).not.toMatch(/^\s*(?:async\s+)?function\s+spawnClaude\s*\(/m);
});
});
describe('SCANNED_TOOLS — ML scan coverage for tool outputs', () => {
test('Read, Grep, Glob, Bash, WebFetch all included', () => {
const match = AGENT_SRC.match(/const SCANNED_TOOLS = new Set\(\[([^\]]+)\]\);/);
expect(match).toBeTruthy();
const list = match![1];
expect(list).toContain("'Read'");
expect(list).toContain("'Grep'");
expect(list).toContain("'Glob'");
expect(list).toContain("'Bash'");
expect(list).toContain("'WebFetch'");
describe('terminal-agent transport boundary', () => {
test('PTY listener is ephemeral and loopback-only', () => {
const buildServer = section(TERMINAL_SRC, 'function buildServer()', '/internal/grant');
expect(buildServer).toContain("hostname: '127.0.0.1'");
expect(buildServer).toContain('port: 0');
expect(buildServer).not.toContain("hostname: '0.0.0.0'");
});
test('tool-result scanner only fires when text.length >= 32', () => {
// Tiny tool outputs (e.g. empty directory listings) should not trigger
// the expensive ML path.
expect(AGENT_SRC).toMatch(/text\.length >= 32/);
test('internal grants require the per-boot bearer and reject stale generations', () => {
const auth = section(TERMINAL_SRC, 'function checkInternalAuth', 'async function internalHandler');
expect(auth).toContain("req.headers.get('authorization')");
expect(auth).toContain('`Bearer ${INTERNAL_TOKEN}`');
expect(auth).toContain("req.headers.get('x-browse-gen')");
expect(auth).toContain('headerGen !== CURRENT_GEN');
expect(auth).toContain("status: 403");
expect(auth).toContain("status: 409");
const grant = section(
TERMINAL_SRC,
"if (url.pathname === '/internal/grant'",
"if (url.pathname === '/internal/revoke'",
);
expect(grant).toContain('return internalHandler(req');
expect(grant).toContain('body.token.length > 16');
expect(grant).toContain('validTokens.set(body.token, sid)');
});
test('WebSocket upgrade enforces extension origin and a granted attach token', () => {
const wsRoute = section(
TERMINAL_SRC,
"if (url.pathname === '/ws')",
"return new Response('not found'",
);
expect(wsRoute).toContain("origin.startsWith('chrome-extension://')");
expect(wsRoute).toContain('origin !== `chrome-extension://${EXTENSION_ID}`');
expect(wsRoute).toContain("new Response('forbidden origin', { status: 403 })");
expect(wsRoute).toContain("req.headers.get('sec-websocket-protocol')");
expect(wsRoute).toContain("raw.startsWith('gstack-pty.')");
expect(wsRoute).toContain('validTokens.has(candidate)');
expect(wsRoute).toContain("name === 'gstack_pty'");
expect(wsRoute).toContain("new Response('unauthorized', { status: 401 })");
expect(wsRoute).toContain("'Sec-WebSocket-Protocol': acceptedProtocol");
expect(wsRoute.indexOf('forbidden origin')).toBeLessThan(wsRoute.indexOf('server.upgrade(req'));
expect(wsRoute.indexOf("new Response('unauthorized'")).toBeLessThan(wsRoute.indexOf('server.upgrade(req'));
});
test('PTY spawn stays lazy and has one production owner', () => {
const openHandler = section(TERMINAL_SRC, ' open(ws) {', ' message(ws, raw) {');
const messageHandler = section(TERMINAL_SRC, ' message(ws, raw) {', ' close(ws, code');
const spawnOwner = section(TERMINAL_SRC, 'function maybeSpawnPty', 'function buildServer');
expect(openHandler).not.toContain('spawnClaude(');
expect(messageHandler).toContain("msg?.type === 'start'");
expect(messageHandler).toContain('maybeSpawnPty(ws, session)');
expect(messageHandler).toMatch(/if \(!session\.spawned\)[\s\S]*maybeSpawnPty\(ws, session\)/);
expect(spawnOwner).toContain('if (session.spawned) return true');
expect(spawnOwner).toContain('spawnClaude(session.cols, session.rows');
expect(TERMINAL_SRC.match(/\bspawnClaude\s*\(/g)).toHaveLength(2);
});
test('session and process cleanup revoke grants and terminate owned PTYs', () => {
const dispose = section(TERMINAL_SRC, 'function disposeSession', 'function checkInternalAuth');
expect(dispose).toContain('session.proc?.terminal?.close?.()');
expect(dispose).toContain("session.proc.kill?.('SIGINT')");
expect(dispose).toContain("session.proc.kill?.('SIGKILL')");
expect(dispose).toContain('}, 3000)');
const closeHandler = section(TERMINAL_SRC, ' close(ws, code', ' },\n });');
expect(closeHandler).toContain('sessions.delete(ws)');
expect(closeHandler).toContain('validTokens.delete(session.cookie)');
expect(closeHandler).toContain('clearInterval(session.pingInterval)');
expect(closeHandler).toContain('disposeSession(session)');
expect(closeHandler).toContain('sessionsById.delete(session.sessionId)');
const processCleanup = section(TERMINAL_SRC, ' const cleanup = () => {', '// Export the internal token');
expect(processCleanup).toContain('safeUnlink(PORT_FILE)');
expect(processCleanup).toContain('clearAgentRecord(dir)');
expect(processCleanup).toContain("process.on('SIGTERM', cleanup)");
expect(processCleanup).toContain("process.on('SIGINT', cleanup)");
});
});
describe('processAgentEvent — security_event relay (server.ts)', () => {
test('relays verdict, reason, layer, confidence, domain, channel, tool, signals', () => {
// Block: addChatEntry call inside the security_event branch
const branch = SERVER_SRC.split("event.type === 'security_event'")[1] ?? '';
expect(branch).toContain('addChatEntry');
expect(branch).toContain('verdict: event.verdict');
expect(branch).toContain('reason: event.reason');
expect(branch).toContain('layer: event.layer');
expect(branch).toContain('confidence: event.confidence');
expect(branch).toContain('domain: event.domain');
expect(branch).toContain('channel: event.channel');
expect(branch).toContain('signals: event.signals');
describe('server PTY broker boundary', () => {
test('session mint is root-authenticated and rolls back failed grants', () => {
const route = section(
SERVER_SRC,
"if (url.pathname === '/pty-session'",
"if (url.pathname === '/pty-session/reattach'",
);
expect(route).toMatch(/if \(!validateAuth\(req\)\)[\s\S]*status: 401/);
expect(route).toContain('const lease = mintLease()');
expect(route).toContain('const minted = mintPtySessionToken()');
expect(route).toContain('grantPtyToken(minted.token, lease.sessionId)');
expect(route).toContain('revokePtySessionToken(minted.token)');
expect(route).toContain('revokeLease(lease.sessionId)');
expect(route).toContain("'Set-Cookie': buildPtySetCookie(minted.token)");
});
});
describe('spawnClaude — canary lifecycle (server.ts)', () => {
test('generates a fresh canary per message', () => {
expect(SERVER_SRC).toMatch(/const canary = generateCanary\(\);/);
test('dispose accepts only matching root auth and targets one session', () => {
const route = section(
SERVER_SRC,
"if (url.pathname === '/pty-dispose'",
"if (url.pathname === '/internal/lease-refresh'",
);
expect(route).toContain('headerToken === authToken');
expect(route).toContain('authTokenFromBody === authToken');
expect(route).toContain('if (!authedByHeader && !authedByBody)');
expect(route).toContain('status: 401');
expect(route).toContain('await restartPtySession(sessionId)');
expect(route).toContain('revokeLease(sessionId)');
});
test('injects canary into the system prompt before embedding user message', () => {
expect(SERVER_SRC).toMatch(/injectCanary\(systemPrompt, canary\)/);
// Order matters: canary-augmented system prompt comes before <user-message>
expect(SERVER_SRC).toMatch(/systemPromptWithCanary.*<user-message>/s);
test('pre-inject scan is root-authenticated, bounded, and fail-warns without L4', () => {
const route = section(
SERVER_SRC,
"if (url.pathname === '/pty-inject-scan'",
"if (url.pathname === '/connect' && req.method === 'POST')",
);
expect(route).toMatch(/if \(!validateAuth\(req\)\)[\s\S]*status: 401/);
expect(route).toContain("req.headers.get('content-length')");
expect(route).toContain('contentLength > 64 * 1024');
expect(route).toContain('status: 413');
expect(route).toContain('await scanWithSidecar(text');
expect(route).toContain("lv === 'unsafe'");
expect(route).toContain("verdict = 'BLOCK'");
expect(route).toContain("verdict = 'WARN'");
expect(route).toContain("datamark: '<untrusted-page-content>'");
});
test('canary is written into the queue entry for sidebar-agent pickup', () => {
// Queue entry JSON includes `canary` field so sidebar-agent can scan
// outbound channels for it.
expect(SERVER_SRC).toMatch(/canary,.*sidebar-agent/s);
});
});
describe('askClaude — pre-spawn + tool-result defense wiring', () => {
test('preSpawnSecurityCheck runs BEFORE claude subprocess spawn', () => {
// The pre-spawn check must be `await`ed and short-circuit spawning when
// it returns true.
expect(AGENT_SRC).toMatch(/await preSpawnSecurityCheck\(queueEntry\)/);
});
test('canaryCtx onLeak kills proc with SIGTERM then SIGKILL after 2s', () => {
expect(AGENT_SRC).toContain("proc.kill('SIGTERM')");
expect(AGENT_SRC).toContain("proc.kill('SIGKILL')");
// 2000ms fallback appears near both onLeak and tool-result-block handlers
expect(AGENT_SRC).toContain('}, 2000);');
});
test('tool-result scan runs all three classifiers in parallel (no L4 gate)', () => {
// Regression guard for the Haiku-always change. Previously the scan
// short-circuited when L4/L4c both returned below WARN, which meant
// Haiku (our best signal per BrowseSafe-Bench) rarely ran. Now we run
// all three in parallel and let combineVerdict decide.
expect(AGENT_SRC).toMatch(/scanPageContent\(text\),[\s\S]*scanPageContentDeberta\(text\),[\s\S]*checkTranscript\(/);
// The old short-circuit must be gone.
expect(AGENT_SRC).not.toMatch(/if \(maxContent < THRESHOLDS\.WARN\) return;/);
});
test('tunnel filter default-denies all PTY routes before dispatch', () => {
const tunnelPaths = section(SERVER_SRC, 'const TUNNEL_PATHS', 'export const TUNNEL_COMMANDS');
for (const route of [
'/pty-session',
'/pty-session/reattach',
'/pty-restart',
'/pty-dispose',
'/pty-inject-scan',
'/internal/lease-refresh',
]) {
expect(tunnelPaths).not.toContain(`'${route}'`);
}
test('onCanaryLeaked fires both security_event and agent_error for legacy clients', () => {
const fn = AGENT_SRC.split('async function onCanaryLeaked')[1]?.split('async function ')[0] ?? '';
expect(fn).toContain("type: 'security_event'");
expect(fn).toContain("type: 'agent_error'");
expect(fn).toContain('Session terminated');
const handler = section(SERVER_SRC, "if (surface === 'tunnel')", '// beforeRoute overlay hook');
expect(handler).toContain("logTunnelDenial(req, url, 'path_not_on_tunnel')");
expect(handler).toContain("logTunnelDenial(req, url, 'root_token_on_tunnel')");
expect(handler).toContain("logTunnelDenial(req, url, 'missing_scoped_token')");
expect(handler).toContain('status: 404');
expect(handler).toContain('status: 403');
expect(handler).toContain('status: 401');
expect(SERVER_SRC.indexOf("if (surface === 'tunnel')")).toBeLessThan(
SERVER_SRC.indexOf("if (url.pathname === '/pty-session'"),
);
});
});