fix(security): delete dead exports the ripped chat path left behind

Three-way split by importer class:

(a) Zero importers, deleted: the whole attack-attempt logging cluster in
security.ts (logAttempt, AttemptRecord, salted hashPayload + device-salt,
attempts.jsonl rotation, telemetry spawn plumbing incl.
buildTelemetrySpawnCommand/resolveBashBinary — the LIVE attempts.jsonl writer
is tunnel-denial-log.ts with its own rotation); the decision-file handshake
(writeDecision/readDecision/clearDecision/excerptForReview — written for
sidebar-agent's poll loop, which no longer exists); sidebar-utils.ts (whole
module — its sanitizeExtensionUrl 'sanitized before embedding in a prompt'
for the deleted prompt builder); 8 dead server.ts imports (sanitizeExtensionUrl,
generateCanary, injectCanary, writeDecision, rotateRoot, serializeRegistry,
restoreRegistry, clearAgentRecord); buildPtyClearCookie + buildSseClearCookie;
WEBDRIVER_MASK_SCRIPT (orphaned by the D7 stealth narrowing — applyStealth
never used it).

(b) Dead-pin tests edited with their exports: the 'still exported' pin in
stealth-layer-c, the string-content describe in stealth-webdriver (its live
applyStealth behavioral coverage untouched), the clear-cookie assertions,
security-review-flow.test.ts deleted whole (all 4 describes exercised the
dead decision mechanism, incl. a 'simulated sidebar-agent poll loop').

(c) KEPT deliberately: leaseCount (live behavioral coverage),
extractPtyCookie + validatePtySessionToken (extractPtyCookie is adopted by
the terminal-agent cookie-parse unification later in this wave),
resetSessionMarker + clearContentFilters (test-support API for the live
content-security layer).

Also fixes two pre-existing red pins found while here, invisible until the
free suite got a CI job: the v1.44 spawnClaude->maybeSpawnPty rename in
terminal-agent.test.ts, and a cross-file test-isolation bug where
content-security.test.ts's clearContentFilters() wiped the auto-registered
url-blocklist filter for every later file in the same bun process
(security-integration.test.ts failed on co-run; afterAll now restores it).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-14 21:00:17 -07:00
co-authored by Claude Fable 5
parent 44d58aa6ee
commit ef186cccb3
14 changed files with 26 additions and 759 deletions
+10
View File
@@ -124,6 +124,16 @@ describe('Content filter hooks', () => {
clearContentFilters();
});
// clearContentFilters() wipes MODULE state shared across every test file in
// the same bun process — without restoring the built-in registration,
// security-integration.test.ts (which asserts the auto-registered blocklist
// pipeline) fails whenever the two files co-run. Pre-existing co-run bug,
// invisible until the free suite got a CI job.
afterAll(() => {
clearContentFilters();
registerContentFilter(urlBlocklistFilter);
});
test('URL blocklist detects requestbin', () => {
const result = urlBlocklistFilter('', 'https://requestbin.com/r/abc', 'text');
expect(result.safe).toBe(false);
-194
View File
@@ -1,194 +0,0 @@
/**
* Review-on-BLOCK regression tests.
*
* Covers the user-in-the-loop path added to resolve false positives on
* benign developer content (e.g., HN comments discussing a prompt injection
* incident getting flagged as prompt injection). Instead of hard-stopping
* the session on a tool-output BLOCK, the agent emits a reviewable
* security_event and polls for the user's decision via a per-tab file.
*
* These tests pin the file-based handshake and the excerpt sanitization.
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
writeDecision,
readDecision,
clearDecision,
decisionFileForTab,
excerptForReview,
type Verdict,
} from '../src/security';
const ORIG_HOME = process.env.HOME;
let tmpHome = '';
beforeEach(() => {
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sec-review-'));
process.env.HOME = tmpHome;
});
afterEach(() => {
process.env.HOME = ORIG_HOME;
try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch {}
});
describe('security decision file handshake', () => {
test('writeDecision + readDecision round-trips', () => {
// SECURITY_DIR is computed at module load time from the original HOME.
// The function writes relative to its own SECURITY_DIR constant, so we
// verify the API shape rather than the exact path. The file lives where
// decisionFileForTab says it does.
const file = decisionFileForTab(42);
expect(file.endsWith('/tab-42.json')).toBe(true);
// Ensure the directory exists (writeDecision creates it).
writeDecision({ tabId: 42, decision: 'allow', ts: new Date().toISOString(), reason: 'user' });
const rec = readDecision(42);
expect(rec).not.toBeNull();
expect(rec?.tabId).toBe(42);
expect(rec?.decision).toBe('allow');
expect(rec?.reason).toBe('user');
});
test('clearDecision removes the file', () => {
writeDecision({ tabId: 7, decision: 'block', ts: new Date().toISOString() });
expect(readDecision(7)).not.toBeNull();
clearDecision(7);
expect(readDecision(7)).toBeNull();
});
test('readDecision returns null for a tab with no decision', () => {
expect(readDecision(99999)).toBeNull();
});
test('writeDecision + readDecision handles both values', () => {
writeDecision({ tabId: 1, decision: 'allow', ts: '2026-04-20T12:00:00Z' });
writeDecision({ tabId: 2, decision: 'block', ts: '2026-04-20T12:00:01Z' });
expect(readDecision(1)?.decision).toBe('allow');
expect(readDecision(2)?.decision).toBe('block');
});
test('atomic write: temp file is cleaned up after rename', () => {
writeDecision({ tabId: 10, decision: 'allow', ts: new Date().toISOString() });
const file = decisionFileForTab(10);
const dir = path.dirname(file);
const leftover = fs.readdirSync(dir).filter((f) => f.startsWith('tab-10.json.tmp'));
expect(leftover.length).toBe(0);
});
test('file perms are 0600 on the decision file', () => {
writeDecision({ tabId: 3, decision: 'allow', ts: new Date().toISOString() });
const stat = fs.statSync(decisionFileForTab(3));
// mode & 0o777 = lower 9 bits of permission
const perms = stat.mode & 0o777;
// On some filesystems the sticky/group bits may vary; we assert the
// owner-only pattern.
expect(perms & 0o077).toBe(0); // no group/other read or write
});
});
describe('excerptForReview sanitization', () => {
test('passes short clean text through', () => {
expect(excerptForReview('hello world')).toBe('hello world');
});
test('truncates at the default max with ellipsis', () => {
const long = 'a'.repeat(800);
const out = excerptForReview(long);
expect(out.length).toBe(501); // 500 chars + ellipsis
expect(out.endsWith('…')).toBe(true);
});
test('strips control chars that would break the UI', () => {
const input = 'before\x00\x01\x02\x1Fafter';
expect(excerptForReview(input)).toBe('beforeafter');
});
test('collapses whitespace for compact display', () => {
expect(excerptForReview('foo \n\n\t bar')).toBe('foo bar');
});
test('returns empty string for empty input', () => {
expect(excerptForReview('')).toBe('');
expect(excerptForReview(null as any)).toBe('');
});
test('custom max parameter', () => {
expect(excerptForReview('abcdefghij', 5)).toBe('abcde…');
});
});
describe('Verdict type includes user_overrode', () => {
test('user_overrode is a valid Verdict value', () => {
// TypeScript compile-time check that the type accepts the value.
// If 'user_overrode' were removed from the Verdict union, this file
// would fail to type-check.
const v: Verdict = 'user_overrode';
expect(v).toBe('user_overrode');
});
});
describe('review-flow smoke — simulated sidebar-agent poll loop', () => {
test('agent-side poll sees user allow decision', async () => {
const tabId = 123;
clearDecision(tabId);
// Simulate the sidepanel POST happening after a short delay.
setTimeout(() => {
writeDecision({ tabId, decision: 'allow', ts: new Date().toISOString(), reason: 'user' });
}, 50);
// Simulate the sidebar-agent poll loop.
const deadline = Date.now() + 2000;
let decision: 'allow' | 'block' | null = null;
while (Date.now() < deadline) {
const rec = readDecision(tabId);
if (rec?.decision) {
decision = rec.decision;
break;
}
await new Promise((r) => setTimeout(r, 20));
}
expect(decision).toBe('allow');
});
test('agent-side poll sees user block decision', async () => {
const tabId = 456;
clearDecision(tabId);
setTimeout(() => {
writeDecision({ tabId, decision: 'block', ts: new Date().toISOString() });
}, 50);
const deadline = Date.now() + 2000;
let decision: 'allow' | 'block' | null = null;
while (Date.now() < deadline) {
const rec = readDecision(tabId);
if (rec?.decision) {
decision = rec.decision;
break;
}
await new Promise((r) => setTimeout(r, 20));
}
expect(decision).toBe('block');
});
test('poll times out when no decision arrives', async () => {
const tabId = 789;
clearDecision(tabId);
const deadline = Date.now() + 200;
let decision: 'allow' | 'block' | null = null;
while (Date.now() < deadline) {
const rec = readDecision(tabId);
if (rec?.decision) {
decision = rec.decision;
break;
}
await new Promise((r) => setTimeout(r, 20));
}
expect(decision).toBeNull();
});
});
-109
View File
@@ -14,14 +14,10 @@ import {
generateCanary,
injectCanary,
checkCanaryInStructure,
hashPayload,
logAttempt,
writeSessionState,
readSessionState,
getStatus,
extractDomain,
buildTelemetrySpawnCommand,
resolveBashBinary,
type LayerSignal,
} from '../src/security';
@@ -239,46 +235,9 @@ describe('canary', () => {
// ─── 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) ───────────────────
@@ -330,74 +289,6 @@ describe('extractDomain', () => {
// ─── Bash binary resolution (Windows shebang-script invocation) ─────
describe('resolveBashBinary', () => {
test('on POSIX, returns the system bash via Bun.which', () => {
if (process.platform === 'win32') return;
const out = resolveBashBinary({ PATH: process.env.PATH ?? '' });
expect(out).toBeTruthy();
expect(out!.endsWith('bash')).toBe(true);
});
test('honors GSTACK_BASH_BIN absolute-path override', () => {
// Construct a synthetic absolute path; the helper short-circuits on
// path.isAbsolute and never touches the filesystem, so this is portable.
const fake = process.platform === 'win32' ? 'C:\\opt\\bash.exe' : '/opt/custom/bash';
const out = resolveBashBinary({ GSTACK_BASH_BIN: fake, PATH: '' });
expect(out).toBe(fake);
});
test('strips wrapping double quotes from override values', () => {
const fake = process.platform === 'win32' ? 'C:\\opt\\bash.exe' : '/opt/custom/bash';
const out = resolveBashBinary({ GSTACK_BASH_BIN: `"${fake}"`, PATH: '' });
expect(out).toBe(fake);
});
test('BASH_BIN works as a fallback when GSTACK_BASH_BIN is unset', () => {
const fake = process.platform === 'win32' ? 'C:\\opt\\bash.exe' : '/opt/custom/bash';
const out = resolveBashBinary({ BASH_BIN: fake, PATH: '' });
expect(out).toBe(fake);
});
test('returns null when nothing resolves (override is unset and PATH is empty)', () => {
// Empty PATH means Bun.which finds nothing.
const out = resolveBashBinary({ PATH: '' });
expect(out).toBeNull();
});
});
// ─── Telemetry spawn command (Windows bash wrapper, v1.24-aligned) ──
describe('buildTelemetrySpawnCommand', () => {
const bin = '/home/user/.claude/skills/gstack/bin/gstack-telemetry-log';
const args = ['--event-type', 'attack_attempt', '--confidence', '0.95'];
test('on POSIX, returns the binary path and args unchanged', () => {
if (process.platform === 'win32') return;
const out = buildTelemetrySpawnCommand(bin, args);
expect(out).not.toBeNull();
expect(out!.cmd).toBe(bin);
expect(out!.cmdArgs).toEqual(args);
});
test('on win32 with bash resolvable, wraps the call in bash with the script as first arg', () => {
if (process.platform !== 'win32') return;
const fakeBash = 'C:\\Program Files\\Git\\bin\\bash.exe';
const out = buildTelemetrySpawnCommand(bin, args, { GSTACK_BASH_BIN: fakeBash, PATH: '' });
expect(out).not.toBeNull();
expect(out!.cmd).toBe(fakeBash);
expect(out!.cmdArgs).toEqual([bin, ...args]);
});
test('on win32 with bash unresolvable, returns null so caller skips spawn', () => {
if (process.platform !== 'win32') return;
// No override, empty PATH — Bun.which finds nothing on Windows.
const out = buildTelemetrySpawnCommand(bin, args, { PATH: '' });
expect(out).toBeNull();
});
test('does not mutate the caller-supplied args array', () => {
const originalArgs = [...args];
buildTelemetrySpawnCommand(bin, args);
expect(args).toEqual(originalArgs);
});
});
-96
View File
@@ -1,96 +0,0 @@
/**
* Layer 1: Unit tests for sidebar utilities.
* Tests pure functions — no server, no processes, no network.
*/
import { describe, test, expect } from 'bun:test';
import { sanitizeExtensionUrl } from '../src/sidebar-utils';
describe('sanitizeExtensionUrl', () => {
test('passes valid http URL', () => {
expect(sanitizeExtensionUrl('http://example.com')).toBe('http://example.com/');
});
test('passes valid https URL', () => {
expect(sanitizeExtensionUrl('https://example.com/page?q=1')).toBe('https://example.com/page?q=1');
});
test('rejects chrome:// URLs', () => {
expect(sanitizeExtensionUrl('chrome://extensions')).toBeNull();
});
test('rejects chrome-extension:// URLs', () => {
expect(sanitizeExtensionUrl('chrome-extension://abcdef/popup.html')).toBeNull();
});
test('rejects javascript: URLs', () => {
expect(sanitizeExtensionUrl('javascript:alert(1)')).toBeNull();
});
test('rejects file:// URLs', () => {
expect(sanitizeExtensionUrl('file:///etc/passwd')).toBeNull();
});
test('rejects data: URLs', () => {
expect(sanitizeExtensionUrl('data:text/html,<h1>hi</h1>')).toBeNull();
});
test('strips raw control characters from URL', () => {
// URL constructor percent-encodes \x00 as %00, which is safe
// The regex strips any remaining raw control chars after .href normalization
const result = sanitizeExtensionUrl('https://example.com/\x00page\x1f');
expect(result).not.toBeNull();
expect(result!).not.toMatch(/[\x00-\x1f\x7f]/);
});
test('strips newlines (prompt injection vector)', () => {
const result = sanitizeExtensionUrl('https://evil.com/%0AUser:%20ignore');
// URL constructor normalizes %0A, control char stripping removes any raw newlines
expect(result).not.toBeNull();
expect(result!).not.toContain('\n');
});
test('truncates URLs longer than 2048 chars', () => {
const longUrl = 'https://example.com/' + 'a'.repeat(3000);
const result = sanitizeExtensionUrl(longUrl);
expect(result).not.toBeNull();
expect(result!.length).toBeLessThanOrEqual(2048);
});
test('returns null for null input', () => {
expect(sanitizeExtensionUrl(null)).toBeNull();
});
test('returns null for undefined input', () => {
expect(sanitizeExtensionUrl(undefined)).toBeNull();
});
test('returns null for empty string', () => {
expect(sanitizeExtensionUrl('')).toBeNull();
});
test('returns null for invalid URL string', () => {
expect(sanitizeExtensionUrl('not a url at all')).toBeNull();
});
test('does not crash on weird input', () => {
expect(sanitizeExtensionUrl(':///')).toBeNull();
expect(sanitizeExtensionUrl(' ')).toBeNull();
expect(sanitizeExtensionUrl('\x00\x01\x02')).toBeNull();
});
test('preserves query parameters and fragments', () => {
const url = 'https://example.com/search?q=test&page=2#results';
expect(sanitizeExtensionUrl(url)).toBe(url);
});
test('preserves port numbers', () => {
expect(sanitizeExtensionUrl('http://localhost:3000/api')).toBe('http://localhost:3000/api');
});
test('handles URL with auth (user:pass@host)', () => {
const result = sanitizeExtensionUrl('https://user:pass@example.com/');
expect(result).not.toBeNull();
expect(result).toContain('example.com');
});
});
+1 -6
View File
@@ -12,7 +12,7 @@ import * as fs from 'fs';
import * as path from 'path';
import {
mintSseSessionToken, validateSseSessionToken, extractSseCookie,
buildSseSetCookie, buildSseClearCookie, SSE_COOKIE_NAME,
buildSseSetCookie, SSE_COOKIE_NAME,
__resetSseSessions,
} from '../src/sse-session-cookie';
@@ -106,11 +106,6 @@ describe('SSE session cookie: cookie flag invariants', () => {
// add Secure then.
expect(buildSseSetCookie(token)).not.toContain('Secure');
});
test('Clear-Cookie has Max-Age=0', () => {
expect(buildSseClearCookie()).toContain('Max-Age=0');
expect(buildSseClearCookie()).toContain('HttpOnly');
});
});
describe('SSE session cookie: extract from request', () => {
-5
View File
@@ -14,7 +14,6 @@ import {
buildGStackLaunchArgs,
readHostProfile,
AUTOMATION_ARTIFACT_CLEANUP_SCRIPT,
WEBDRIVER_MASK_SCRIPT,
STEALTH_LAUNCH_ARGS,
STEALTH_IGNORE_DEFAULT_ARGS,
} from '../src/stealth';
@@ -235,10 +234,6 @@ describe('buildGStackLaunchArgs — Pack 1 cmdline-switch construction', () => {
});
describe('backwards-compat exports', () => {
test('WEBDRIVER_MASK_SCRIPT still exported', () => {
expect(WEBDRIVER_MASK_SCRIPT).toContain("'webdriver'");
expect(WEBDRIVER_MASK_SCRIPT).toContain('false');
});
test('STEALTH_LAUNCH_ARGS still includes blink-features=AutomationControlled', () => {
expect(STEALTH_LAUNCH_ARGS).toContain('--disable-blink-features=AutomationControlled');
});
+1 -15
View File
@@ -1,6 +1,6 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { chromium, type Browser, type BrowserContext } from 'playwright';
import { applyStealth, WEBDRIVER_MASK_SCRIPT, STEALTH_LAUNCH_ARGS } from '../src/stealth';
import { applyStealth, STEALTH_LAUNCH_ARGS } from '../src/stealth';
let browser: Browser;
@@ -18,20 +18,6 @@ describe('STEALTH_LAUNCH_ARGS', () => {
});
});
describe('WEBDRIVER_MASK_SCRIPT', () => {
test('contains a single Object.defineProperty for navigator.webdriver', () => {
expect(WEBDRIVER_MASK_SCRIPT).toContain('navigator');
expect(WEBDRIVER_MASK_SCRIPT).toContain('webdriver');
expect(WEBDRIVER_MASK_SCRIPT).toContain('false');
});
test('does NOT touch plugins, languages, or window.chrome (D7 narrowing)', () => {
expect(WEBDRIVER_MASK_SCRIPT).not.toMatch(/plugins/i);
expect(WEBDRIVER_MASK_SCRIPT).not.toMatch(/languages/i);
expect(WEBDRIVER_MASK_SCRIPT).not.toMatch(/window\.chrome/);
});
});
describe('applyStealth — context level', () => {
let context: BrowserContext;
+6 -7
View File
@@ -20,7 +20,7 @@ import * as fs from 'fs';
import * as path from 'path';
import {
mintPtySessionToken, validatePtySessionToken, revokePtySessionToken,
extractPtyCookie, buildPtySetCookie, buildPtyClearCookie,
extractPtyCookie, buildPtySetCookie,
PTY_COOKIE_NAME, __resetPtySessions,
} from '../src/pty-session-cookie';
@@ -61,10 +61,6 @@ describe('pty-session-cookie: mint/validate/revoke', () => {
expect(cookie).not.toContain('Secure');
});
test('clear-cookie has Max-Age=0', () => {
expect(buildPtyClearCookie()).toContain('Max-Age=0');
});
test('extractPtyCookie reads gstack_pty from a Cookie header', () => {
const { token } = mintPtySessionToken();
const req = new Request('http://127.0.0.1/ws', {
@@ -150,10 +146,13 @@ describe('Source-level guard: terminal-agent', () => {
AGENT_SRC.indexOf("if (url.pathname === '/ws')"),
AGENT_SRC.indexOf("websocket: {"),
);
expect(upgradeBlock).not.toContain('spawnClaude(');
// v1.44 renamed spawnClaude -> maybeSpawnPty (explicit `start` frame +
// lazy first-byte spawn share one helper). Pin was stale from then until
// the free suite got a CI job.
expect(upgradeBlock).not.toContain('maybeSpawnPty(');
// Spawn must be invoked from the message handler (lazy on first byte).
const messageHandler = AGENT_SRC.slice(AGENT_SRC.indexOf('message(ws, raw)'));
expect(messageHandler).toContain('spawnClaude(');
expect(messageHandler).toContain('maybeSpawnPty(');
expect(messageHandler).toContain('!session.spawned');
});