mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-21 20:30:47 +02:00
add gstack 2 parity and lifecycle gates
This commit is contained in:
@@ -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'"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user