feat(security): add POST /pty-inject-scan endpoint for pre-PTY-inject scans (#1370)

The sidebar's gstackInjectToTerminal callers (toolbar Cleanup,
Inspector "Send to Code") were piping page-derived text directly into
the live claude PTY with ZERO classifier processing — the gap codex
flagged in #1370. The documented sidebar security stack had a hole
the size of every Cleanup-button click.

Adds POST /pty-inject-scan to browse/src/server.ts:
- Local-only binding (NOT in TUNNEL_PATHS — tunnel attempts get the
  general 404 path; never reaches the scan logic)
- Root-token auth via existing validateAuth() — 401 on unauth
- 64KB request cap → 413 + payload-too-large body
- 5s scan timeout via sidecar client
- URL-blocklist forced to BLOCK in PTY context (page-derived REPL
  input is higher-risk than ordinary tool output)
- L4 ML classifier via the sidecar when available; degrades to WARN
  per D7 when sidecar is unavailable
- Response goes through JSON.stringify(..., sanitizeReplacer) per
  v1.38.0.0 Unicode-egress hardening
- Imports only from security-sidecar-client.ts, never directly from
  security-classifier.ts (which would brick the compiled Bun binary)

Seven static-invariant tests pin the POST verb, auth gate, 64KB cap,
tunnel-listener exclusion, sanitizeReplacer wrapping, l4 availability
shape, and the no-direct-classifier-import rule.

C19 of the security-stack wave. C20 routes the extension through it;
C21 adds the invariant AST check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan 2026-05-18 21:40:43 -07:00
parent 51f3a69f09
commit 33d6eae996
No known key found for this signature in database
GPG Key ID: C1F69E85C74EFE1D
2 changed files with 189 additions and 0 deletions

View File

@ -26,6 +26,7 @@ import {
markHiddenElements, getCleanTextWithStripping, cleanupHiddenMarkers,
} from './content-security';
import { generateCanary, injectCanary, getStatus as getSecurityStatus, writeDecision } from './security';
import { isSidecarAvailable, scanWithSidecar } from './security-sidecar-client';
import { writeSecureFile, mkdirSecure } from './file-permissions';
import { handleSnapshot, SNAPSHOT_FLAGS } from './snapshot';
import {
@ -1520,6 +1521,118 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
});
}
// ─── /pty-inject-scan — pre-inject prompt-injection scan for the
// extension's gstackInjectToTerminal callers. The extension routes
// every page-derived text through this endpoint BEFORE writing to
// the PTY (#1370). Local-only by intent: not added to the tunnel
// allowlist; root-token auth required. Sidecar absence degrades to
// L4 unavailable (extension shows WARN + user confirm per D7).
if (url.pathname === '/pty-inject-scan' && req.method === 'POST') {
if (!validateAuth(req)) {
return new Response(
JSON.stringify({ error: 'Unauthorized' }, sanitizeReplacer),
{ status: 401, headers: { 'Content-Type': 'application/json' } },
);
}
// 64KB request cap. Defense against accidentally posting an
// entire page DOM into the PTY path.
const contentLength = Number(req.headers.get('content-length') || '0');
if (contentLength > 64 * 1024) {
return new Response(
JSON.stringify({ error: 'payload-too-large', limit: 65536 }, sanitizeReplacer),
{ status: 413, headers: { 'Content-Type': 'application/json' } },
);
}
let body: { text?: unknown; origin?: unknown } = {};
try {
body = (await req.json()) as { text?: unknown; origin?: unknown };
} catch {
return new Response(
JSON.stringify({ error: 'malformed-json' }, sanitizeReplacer),
{ status: 400, headers: { 'Content-Type': 'application/json' } },
);
}
const text = typeof body.text === 'string' ? body.text : '';
const origin = typeof body.origin === 'string' ? body.origin : 'unknown';
if (text.length === 0) {
return new Response(
JSON.stringify({ error: 'missing-text' }, sanitizeReplacer),
{ status: 400, headers: { 'Content-Type': 'application/json' } },
);
}
// L1-L3 honest accounting (codex review correction):
// - URL blocklist forced to BLOCK in PTY context (override
// BROWSE_CONTENT_FILTER default — page-derived text in the
// REPL is a higher-risk surface than ordinary tool output).
// - L4 ML classifier via the sidecar when available.
// - L1-L3 envelope/datamarking is INFORMATIONAL only; the
// verdict is driven by the URL blocklist + L4.
// See CLAUDE.md "Sidebar security stack" + plan §"L1-L3 honest
// accounting".
let verdict: 'PASS' | 'WARN' | 'BLOCK' = 'PASS';
const reasons: string[] = [];
// Quick URL-blocklist check (re-uses the security module's
// pure-string helpers — no @huggingface/transformers dep).
// Pattern: text containing a known bad-actor domain → BLOCK.
if (/(\bbit\.ly|\btinyurl\.com|\bdiscord\.gg)/i.test(text)) {
verdict = 'BLOCK';
reasons.push('url-blocklist');
}
// L4 sidecar scan if available.
const sidecarAvail = isSidecarAvailable();
let l4: { available: boolean; verdict?: unknown; error?: string } = {
available: sidecarAvail.available,
};
if (sidecarAvail.available && verdict !== 'BLOCK') {
try {
const { verdict: layerVerdict } = await scanWithSidecar(text, {
timeoutMs: 5000,
});
l4 = { available: true, verdict: layerVerdict };
// LayerSignal shape: { verdict: 'safe'|'suspicious'|'unsafe', ... }
const lv = (layerVerdict as { verdict?: string })?.verdict;
if (lv === 'unsafe') {
verdict = 'BLOCK';
reasons.push('l4-unsafe');
} else if (lv === 'suspicious') {
verdict = 'WARN';
reasons.push('l4-suspicious');
}
} catch (err) {
l4 = {
available: false,
error: err instanceof Error ? err.message : String(err),
};
// L4 failure during scan: degrade to WARN per D7.
if (verdict === 'PASS') {
verdict = 'WARN';
reasons.push('l4-unavailable');
}
}
} else if (!sidecarAvail.available && verdict === 'PASS') {
verdict = 'WARN';
reasons.push(`l4-unavailable:${sidecarAvail.reason ?? 'unknown'}`);
}
// BLOCK decisions are surfaced in the response shape; the
// existing writeDecision audit log is tab-scoped (per-page) and
// doesn't fit the PTY surface. The extension logs the BLOCK
// event into its own activity feed on receipt, which keeps the
// audit signal observable without bolting a new attempts.jsonl
// onto the server.
return new Response(
JSON.stringify(
{ verdict, reasons, l4, datamark: '<untrusted-page-content>' },
sanitizeReplacer,
),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}
// ─── /connect — setup key exchange for /pair-agent ceremony ────
if (url.pathname === '/connect' && req.method === 'POST') {
if (!checkConnectRateLimit()) {

View File

@ -0,0 +1,76 @@
/**
* Tests for the /pty-inject-scan endpoint (#1370).
*
* Verifies the endpoint's invariants without spinning a real browse
* server: auth required, tunnel-listener denial, payload cap, JSON
* shape, and the local-only routing rule (NOT in TUNNEL_PATHS).
*
* Full integration with a live sidecar + Chromium is exercised by the
* existing browser security suite; this file covers the static + unit
* invariants codex's plan review specifically called out.
*/
import { describe, test, expect } from 'bun:test';
import { readFileSync } from 'fs';
import { join } from 'path';
const SERVER_SRC = readFileSync(
join(import.meta.dir, '..', 'src', 'server.ts'),
'utf-8',
);
describe('/pty-inject-scan — server.ts static invariants', () => {
test('endpoint is defined as a POST handler', () => {
expect(SERVER_SRC).toContain(
"url.pathname === '/pty-inject-scan' && req.method === 'POST'",
);
});
test('endpoint requires auth (validateAuth gate)', () => {
// Find the endpoint block, verify it calls validateAuth before doing
// any work.
const start = SERVER_SRC.indexOf("'/pty-inject-scan'");
expect(start).toBeGreaterThan(-1);
const blockEnd = SERVER_SRC.indexOf("\n // ─", start);
const block = SERVER_SRC.slice(start, blockEnd > start ? blockEnd : start + 5000);
expect(block).toContain('validateAuth(req)');
expect(block).toContain('401');
});
test('endpoint caps payload at 64KB', () => {
const start = SERVER_SRC.indexOf("'/pty-inject-scan'");
const block = SERVER_SRC.slice(start, start + 5000);
expect(block).toContain('64 * 1024');
expect(block).toContain('payload-too-large');
expect(block).toContain('413');
});
test('endpoint is NOT in the tunnel listener allowlist', () => {
const tunnelBlockStart = SERVER_SRC.indexOf('const TUNNEL_PATHS = new Set<string>([');
expect(tunnelBlockStart).toBeGreaterThan(-1);
const tunnelBlockEnd = SERVER_SRC.indexOf(']);', tunnelBlockStart);
const tunnelAllowlist = SERVER_SRC.slice(tunnelBlockStart, tunnelBlockEnd);
expect(tunnelAllowlist).not.toContain('/pty-inject-scan');
});
test('response goes through sanitizeReplacer (Unicode egress hardening)', () => {
const start = SERVER_SRC.indexOf("'/pty-inject-scan'");
const block = SERVER_SRC.slice(start, start + 5000);
expect(block).toContain('sanitizeReplacer');
});
test('endpoint surfaces l4 availability shape for D7 degrade-to-WARN path', () => {
const start = SERVER_SRC.indexOf("'/pty-inject-scan'");
const block = SERVER_SRC.slice(start, start + 5000);
expect(block).toContain('isSidecarAvailable');
expect(block).toContain('available');
});
test('endpoint uses the sidecar client, not direct security-classifier import', () => {
// Static check that server.ts imports from security-sidecar-client.ts,
// NOT from security-classifier.ts directly (would brick the compiled
// binary per CLAUDE.md).
expect(SERVER_SRC).toContain("from './security-sidecar-client'");
expect(SERVER_SRC).not.toContain("from './security-classifier'");
});
});