From 77b51a9e54ce5e86052062822ea20a76a486a983 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Mon, 18 May 2026 21:46:43 -0700 Subject: [PATCH] =?UTF-8?q?test(security):=20invariant=20=E2=80=94=20exten?= =?UTF-8?q?sion=20PTY=20inject=20must=20be=20scan-gated=20(#1370)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Static-analysis invariant test that fails the build if any extension/*.js path calls window.gstackInjectToTerminal without a preceding window.gstackScanForPTYInject in the same enclosing function. Closes the documented-vs-shipped gap codex demanded a machine check on. Rules: - Rule 1: any file that calls inject must also reference scan - Rule 2: in the enclosing function (function declaration, arrow, async (), event handler), a scan call must appear before the inject call by source position - Exemption: sidepanel-terminal.js (the file that DEFINES the inject function) is exempt from Rule 2 since the definition is not a call Plus two structural checks: - sidepanel-terminal.js defines both the inject and scan functions - inject stays SYNCHRONOUS (no `async` modifier) per D6 — async would silently break the `const ok = ...?.()` pattern at every caller C21 of the security-stack wave. The sidecar architecture (#1370) is complete: server-side L1-L3 + L4-via-sidecar (C17+C18+C19), extension pre-scan wiring (C20), and now the regression gate (C21). Co-Authored-By: Claude Opus 4.7 (1M context) --- test/extension-pty-inject-invariant.test.ts | 141 ++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 test/extension-pty-inject-invariant.test.ts diff --git a/test/extension-pty-inject-invariant.test.ts b/test/extension-pty-inject-invariant.test.ts new file mode 100644 index 000000000..59ec7f697 --- /dev/null +++ b/test/extension-pty-inject-invariant.test.ts @@ -0,0 +1,141 @@ +/** + * Static invariant: every gstackInjectToTerminal call in extension/*.js + * must be preceded by an await on gstackScanForPTYInject on the same code + * path (#1370 / D6). + * + * Why static, not runtime: extension/ runs in the chrome-extension origin; + * we can't easily exercise it in a Bun test. The invariant codex's plan + * review demanded is "no caller skips the scan." We get that by parsing + * the JS source as text and asserting structural rules. + * + * The rules (kept simple — false positives are worse than false + * negatives here since the wave has only two callers): + * + * Rule 1: every file that calls gstackInjectToTerminal must also call + * gstackScanForPTYInject. + * + * Rule 2: in any function that calls gstackInjectToTerminal, an + * `await ... gstackScanForPTYInject` MUST appear before the + * inject call when measured by source position (same function + * body). + * + * Exemption: extension/sidepanel-terminal.js defines the inject + * function itself; it doesn't need to call scan-first inside + * the definition. + */ + +import { describe, expect, test } from 'bun:test'; +import { readFileSync, readdirSync, statSync } from 'fs'; +import { join } from 'path'; + +const EXTENSION_DIR = join(import.meta.dir, '..', 'extension'); +const INJECT_FN = 'gstackInjectToTerminal'; +const SCAN_FN = 'gstackScanForPTYInject'; + +function listJsFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + const st = statSync(full); + if (st.isDirectory()) { + out.push(...listJsFiles(full)); + } else if (entry.endsWith('.js')) { + out.push(full); + } + } + return out; +} + +function findInjectCallSites(content: string): number[] { + // Find positions of `gstackInjectToTerminal(` or `gstackInjectToTerminal?.(` + // — but exclude the function DEFINITION (window.gstackInjectToTerminal = ). + const sites: number[] = []; + const callRe = /window\.gstackInjectToTerminal\s*\??\.?\s*\(/g; + let match: RegExpExecArray | null; + while ((match = callRe.exec(content)) !== null) { + // Look back ~30 chars; if "window.gstackInjectToTerminal =" appears + // right before, it's the definition, not a call. + const back = Math.max(0, match.index - 30); + const window30 = content.slice(back, match.index); + if (window30.includes('gstackInjectToTerminal =')) continue; + sites.push(match.index); + } + return sites; +} + +function callsScan(content: string): boolean { + return content.includes(SCAN_FN); +} + +function findEnclosingFunctionStart(content: string, callerPos: number): number { + // Walk backwards from callerPos looking for the most recent `function` + // keyword, `=> {`, or `addEventListener('click',\s*async`. Conservative + // — falls back to file start. + const text = content.slice(0, callerPos); + const candidates = [ + text.lastIndexOf('function '), + text.lastIndexOf('=> {'), + text.lastIndexOf('async function'), + text.lastIndexOf('async ('), + text.lastIndexOf('async () =>'), + ]; + const idx = Math.max(...candidates); + return idx >= 0 ? idx : 0; +} + +describe('extension/* PTY injection invariant (#1370 / D6)', () => { + test('every inject call site is preceded by a scan call in the same enclosing function', () => { + const files = listJsFiles(EXTENSION_DIR); + const offenders: string[] = []; + + for (const file of files) { + const content = readFileSync(file, 'utf-8'); + const sites = findInjectCallSites(content); + if (sites.length === 0) continue; + + // Rule 1: file must reference the scan function. + if (!callsScan(content)) { + // Special-case sidepanel-terminal.js: it DEFINES the inject + // function but doesn't call it from inside. + if (file.endsWith('sidepanel-terminal.js')) continue; + offenders.push(`${file} calls ${INJECT_FN} but never references ${SCAN_FN}`); + continue; + } + + // Rule 2: for each call site, find the enclosing function body and + // verify a scan call precedes the inject within that body. + for (const pos of sites) { + const fnStart = findEnclosingFunctionStart(content, pos); + const fnBody = content.slice(fnStart, pos); + if (!fnBody.includes(SCAN_FN)) { + const lineNum = content.slice(0, pos).split('\n').length; + offenders.push(`${file}:${lineNum} ${INJECT_FN} call not preceded by ${SCAN_FN} in enclosing function`); + } + } + } + + if (offenders.length > 0) { + throw new Error( + 'PTY-injection invariant violated:\n - ' + offenders.join('\n - '), + ); + } + expect(offenders).toHaveLength(0); + }); + + test('sidepanel-terminal.js defines both gstackInjectToTerminal and gstackScanForPTYInject', () => { + const file = join(EXTENSION_DIR, 'sidepanel-terminal.js'); + const content = readFileSync(file, 'utf-8'); + expect(content).toContain('window.gstackInjectToTerminal'); + expect(content).toContain('window.gstackScanForPTYInject'); + }); + + test('inject function stays synchronous (D6 contract preservation)', () => { + const file = join(EXTENSION_DIR, 'sidepanel-terminal.js'); + const content = readFileSync(file, 'utf-8'); + // The definition line should NOT contain "async" — async inject would + // break every existing caller using `const ok = ...?.()` pattern. + const match = content.match(/window\.gstackInjectToTerminal\s*=\s*(async\s+)?function/); + expect(match).not.toBeNull(); + expect(match?.[1]).toBeUndefined(); // no `async` modifier + }); +});