mirror of https://github.com/garrytan/gstack.git
test(security): invariant — extension PTY inject must be scan-gated (#1370)
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) <noreply@anthropic.com>
This commit is contained in:
parent
9a643dc17d
commit
77b51a9e54
|
|
@ -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
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue