diff --git a/tests/cockpit-host/purity.test.ts b/tests/cockpit-host/purity.test.ts index cd5fbe9..ff55ee9 100644 --- a/tests/cockpit-host/purity.test.ts +++ b/tests/cockpit-host/purity.test.ts @@ -337,9 +337,43 @@ const unwrapExpr = (node: ts.Expression): ts.Expression => { return cur; }; -const isGlobalReceiver = (node: ts.Expression): boolean => { +// A global-object SELF-REFERENCE hop: the member NAME read off a receiver when that +// receiver is a member access whose key is a statically present string +// (`globalThis.globalThis`, `window['window']`). The real global exposes itself under +// every GLOBAL_RECEIVER_NAMES key (`globalThis.globalThis === globalThis`, +// `globalThis.window === globalThis`, …), so a chain of such hops off a global base still +// denotes the real global. Returns the hop name or null; it NEVER folds a runtime-built +// key (no alias/value-flow), so a truly computed key stays outside the frozen boundary. +const selfReferenceHopName = ( + node: ts.PropertyAccessExpression | ts.ElementAccessExpression, +): string | null => { + if (ts.isPropertyAccessExpression(node)) return node.name.text; + return ts.isStringLiteralLike(node.argumentExpression) ? node.argumentExpression.text : null; +}; + +// A structural global receiver: a bare global-object identifier, OR a self-reference member +// (name in GLOBAL_RECEIVER_NAMES) read off another structural global receiver — so +// `globalThis.globalThis`, `globalThis.window`, `window.window` are all recognized. An +// ELEMENT-access hop key is folded by the SAME bounded static-key resolver the rest of this +// checker-free detector uses (`staticStringOf`: literal / `+`-concat / `const`-key chains), so +// `globalThis['global' + 'This']` and `const k = 'globalThis'; globalThis[k]` are recognized hops +// on parity with the dotted / string-literal form — the forwarding closure (rule (e′)) then rejects +// a static-key self-hop forwarded as a value, not only a literal one. A runtime-built key folds to +// null and stays outside the boundary; binder/shadowing authority stays with the base identifier +// (this reserves the receiver names structurally — see the RC v3/v4 forwarding suites). Finite: +// each recursion strips one member-access layer off `node.expression`. +const isGlobalReceiver = (node: ts.Expression, constMap: ReadonlyMap): boolean => { const n = unwrapExpr(node); - return ts.isIdentifier(n) && GLOBAL_RECEIVER_NAMES.has(n.text); + if (ts.isIdentifier(n)) return GLOBAL_RECEIVER_NAMES.has(n.text); + if (ts.isPropertyAccessExpression(n)) { + const hop = selfReferenceHopName(n); + return hop !== null && GLOBAL_RECEIVER_NAMES.has(hop) && isGlobalReceiver(n.expression, constMap); + } + if (ts.isElementAccessExpression(n)) { + const hop = staticStringOf(n.argumentExpression, constMap); + return hop !== null && GLOBAL_RECEIVER_NAMES.has(hop) && isGlobalReceiver(n.expression, constMap); + } + return false; }; // The statically-provable string value of an expression: a string literal or @@ -363,6 +397,16 @@ const staticStringOf = (node: ts.Expression, constMap: ReadonlyMap => if (left === null) return null; const right = resolveExpr(n.right); if (right === null) return null; + // TOTALITY: never build a fold longer than any name this resolver is compared against. + if (left.length + right.length > MAX_STATIC_FOLD_LEN) return null; return left + right; } if (ts.isIdentifier(n)) return resolveName(n.text); @@ -632,7 +678,7 @@ const usesRuntimeCodeGeneration = (source: string): boolean => { // (a) `.constructor` on any receiver — the function-constructor chain. if (member === 'constructor') found = true; // (b) `.eval` / `.Function` off a global receiver. - else if (member !== null && RC_PRIMITIVE_NAMES.has(member) && isGlobalReceiver(node.expression)) { + else if (member !== null && RC_PRIMITIVE_NAMES.has(member) && isGlobalReceiver(node.expression, constMap)) { found = true; } } @@ -661,6 +707,29 @@ const usesRuntimeCodeGeneration = (source: string): boolean => { ) { found = true; } + // (e') forwarding a global-object SELF-REFERENCE value (`globalThis.globalThis`, + // `window['window']`, and chains of such hops) as a value — the SAME + // acquisition-site closure as (e), lifted from the bare identifier to the + // structural global receiver that (b)/(f) already recognize via `isGlobalReceiver`. + // `globalThis.globalThis` re-denotes the real global, so `const g = + // globalThis.globalThis; g.eval(...)` (or `g.fetch(...)`) is the same forwarded + // alias as `const g = globalThis`, only spelled through the self-hop that NET's + // `isFreeGlobalReceiver` already proves free — without this, rule (e) caught the + // bare identifier but the self-hop laundered the acquisition into an array + // element / object value / initializer / argument / return. A self-hop that + // DIRECTLY serves as the access object (`globalThis.globalThis.fetch(...)`, itself + // caught by NET; `globalThis.globalThis.console.log(...)`) is a direct member + // operation and preserved — only a self-hop escaping as a VALUE is rejected. No + // checker and no flow tracing: the receiver's identity is RC's existing structural + // name reservation (b)/(f), so a shadowed base is rejected exactly as (e) already + // rejects a shadowed `const g = globalThis`, and the escaped alias is never traced. + if ( + (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) && + isGlobalReceiver(node, constMap) && + !servesAsAccessObject(node) + ) { + found = true; + } // (f) a computed element access on a recognized global receiver whose key is not // statically resolvable — a runtime-built key (`['e','v','a','l'].join('')`, // `String.fromCharCode(...)`) could acquire `eval`/`Function`/`process` off @@ -669,7 +738,7 @@ const usesRuntimeCodeGeneration = (source: string): boolean => { // `globalThis['ev' + 'al']`) is not caught here (the latter is caught by (b)). if ( ts.isElementAccessExpression(node) && - isGlobalReceiver(node.expression) && + isGlobalReceiver(node.expression, constMap) && staticStringOf(node.argumentExpression, constMap) === null ) { found = true; @@ -723,10 +792,10 @@ const HIDDEN_BUILTIN_METHODS: ReadonlySet = new Set(['getBuiltinModule', const isProcessValue = (node: ts.Node, constMap: ReadonlyMap): boolean => { if (ts.isIdentifier(node)) return node.text === 'process' && isValueReference(node); if (ts.isPropertyAccessExpression(node)) { - return node.name.text === 'process' && isGlobalReceiver(node.expression); + return node.name.text === 'process' && isGlobalReceiver(node.expression, constMap); } if (ts.isElementAccessExpression(node)) { - return staticStringOf(node.argumentExpression, constMap) === 'process' && isGlobalReceiver(node.expression); + return staticStringOf(node.argumentExpression, constMap) === 'process' && isGlobalReceiver(node.expression, constMap); } return false; }; @@ -774,2590 +843,8779 @@ const acquiresHiddenBuiltin = (source: string): boolean => { return found; }; -describe('D3 host has no mutation, subprocess, secret, or Git capability', () => { - it('references no subprocess, environment, or Git operation', () => { - const forbidden: readonly RegExp[] = [ - /child_process/, - /node:child_process/, - /process\.env/, - /\bexecSync\b/, - /\bspawn(?:Sync)?\s*\(/, - /\bexecFile\b/, - /octokit/i, - /simple-git/, - /\bgit\s+(?:push|commit|merge|rebase|checkout)\b/, - ]; - for (const { file, text } of hostSources()) { - for (const pattern of forbidden) { - expect(pattern.test(text), `${file} must not match ${String(pattern)}`).toBe(false); - } - } - }); -}); +/** + * NET — reject outbound network egress at its use/acquisition site + * (D3-CX-POLICY-NET). + * + * The D3 contract forbids network egress ("no network egress"; "not a + * collector"; Stage A is "not live"). The import allowlist already blocks + * `node:https` / `node:net` / `node:tls` (they are not on the exact + * `{node:http, node:url}` allowlist — D3-CX-POLICY-3), and with `node:net`/`tls`/ + * `dgram`/`http2` therefore unreachable, the COMPLETE outbound surface that survives + * every other check is finite and closed by construction: + * - importless network GLOBALS — `fetch` and (on the Node target) `WebSocket` are + * built-in globals, so they need no import and the specifier allowlist never sees + * them. This is the whole importless-network-global family on the target runtime, + * not an open-ended blacklist: a raw socket needs `node:net`/`tls`, which the + * import allowlist blocks. + * - any NON-`createServer` value reached through the legitimately-allowed + * `node:http` import. The read-only host needs exactly ONE node:http VALUE export + * — the inbound-server constructor `createServer`; every other value member/name + * (`request`, `get`, `ClientRequest`, `Agent`, `Agent.createConnection`, …) is a + * non-server (outbound/connection) capability. So node:http is decided by a finite + * POSITIVE model — allow `createServer`, reject the rest — and a newly-noticed + * client API needs NO new special case. + * + * This detector closes both families, decided by LEXICAL BINDING IDENTITY — + * the binding visible at each occurrence, never mere identifier text — so + * shadowing neither hides a real capability nor false-positives a same-named + * local (NET-S1/NET-S2): + * - a network GLOBAL (`fetch`/`WebSocket`): a `.` member on a global + * receiver name (globalThis/window/self/global, incl. a statically-resolved + * `['fetch']`) that is itself FREE here — a lexically-shadowing local + * receiver (`function f(globalThis){ globalThis.fetch(...) }`) is an ordinary + * object and is allowed; a destructuring of the name OFF such a free global + * receiver; or a bare reference (`fetch(...)`, `new WebSocket(...)`) that is FREE + * at that occurrence (no lexical binding of the name is visible). An alias + * `const f = fetch` is caught at the `fetch` reference; forwarding a global + * receiver (`const g = globalThis`) is already rejected by RC (e). + * - node:http value capabilities: any member OTHER than `createServer` reached + * through a binding THIS module imported from `node:http` — a default or namespace + * binding (`http.request`, `h.get`, `new http.ClientRequest()`, `new http.Agent()`), + * a named import used bare (`request()`, `new ClientRequest()`, `new Agent()`), an + * aliased named import (`req()` from `{ request as req }`), or an import-equals + * binding — where the receiver/name still LEXICALLY resolves to that import. + * `createServer` is the sole allowed value member, so the real host server is + * preserved; type-only `http.Server`/`IncomingMessage`/`ServerResponse` are + * QualifiedName nodes (never value member accesses) and stay untouched, as do + * `map.get(...)` / `obj.request(...)` / an unrelated local `new ClientRequest()` + * on any non-node:http binding. + * + * Binding identity is resolved by a bounded lexical ENVIRONMENT STACK tied to the + * AST walk: each scope (module, function/arrow/method params, block, for-header, + * catch) pushes a frame naming its own declarations; an occurrence resolves to the + * nearest enclosing frame that binds the name. A NAMED function expression also + * binds its own name inside its body (so `const helper = function request(){ return + * request(); }` resolves the inner call to that self-binding, not the import). A + * module-declared `fetch` (`function fetch(){}`, `const fetch = …`) is legal (unlike + * `eval`) and shadows the global where it is visible; a sibling scope's local `fetch` + * does not, and a shadow disappears once its scope closes — while a FREE-receiver + * `globalThis.fetch` stays a member call. Structural and finite: one parse, one + * traversal with balanced push/pop and Set/Map lookups (no fixpoint, no re-scan, no + * value resolution), reusing `memberNameOf` / `GLOBAL_RECEIVER_NAMES` (resolved + * lexically here) / `isValueReference` / `unwrapExpr` / `bindingPropertyName`. This is + * a development-time SOURCE-POLICY guard, not a + * runtime sandbox. NOT decided (bounded gaps, not sandbox claims): alias-via- + * assignment (`const h = http; h.request()`), method extraction (`const r = + * http.request`), runtime reassignment, computed/dynamic forwarding, and runtime- + * generated code (RC/HA cover codegen). `node:https`/`net`/`tls` stay out of scope + * — the import allowlist already blocks them. + */ +// The ONLY node:http VALUE export the read-only host legitimately needs is the +// inbound-server constructor `createServer`. Every other value member/name reached +// through a node:http binding is a non-server (outbound/connection) capability and is +// rejected — a finite POSITIVE model, so a client API noticed later (Agent, +// ClientRequest, request, get, …) needs no new entry. Type-only references +// (`http.Server`/`IncomingMessage`/`ServerResponse`) are QualifiedName nodes, never +// value member accesses, so they are structurally untouched. +const HTTP_SERVER_VALUE_MEMBERS: ReadonlySet = new Set(['createServer']); +// Importless network-initiating globals present on the Node target. `node:https`/ +// `net`/`tls`/`dgram`/`http2` are import-blocked by the allowlist, so this is the +// COMPLETE importless-egress global surface — a bounded family, not an open blacklist. +const NETWORK_GLOBAL_NAMES: ReadonlySet = new Set(['fetch', 'WebSocket']); + +// A node:http runtime authority capability. `HTTP_NS` is the namespace; `HTTP_CLIENT` +// any non-createServer node:http value; `CREATE_SERVER` the one permitted capability. +type HttpCapability = 'HTTP_NS' | 'HTTP_CLIENT' | 'CREATE_SERVER' | 'NONE'; + +// Build a bounded, in-memory, single-file Program so the compiler BINDER supplies +// lexical binding identity (Option D). `noLib`+`noResolve`: no filesystem, no module +// resolution, no network, deterministic. The only file served is the analyzed source; +// node:http is never loaded — we read the import declaration's specifier TEXT, never its +// types — so binding identity, not module contents, is all this guard depends on. +const buildBinderProgram = (source: string): { readonly checker: ts.TypeChecker; readonly sourceFile: ts.SourceFile } => { + const fileName = 'module.ts'; + const parsed = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const host: ts.CompilerHost = { + getSourceFile: (name) => (name === fileName ? parsed : undefined), + getDefaultLibFileName: () => 'lib.d.ts', + writeFile: () => undefined, + getCurrentDirectory: () => '/', + getDirectories: () => [], + getCanonicalFileName: (f) => f, + useCaseSensitiveFileNames: () => true, + getNewLine: () => '\n', + fileExists: (f) => f === fileName, + readFile: (f) => (f === fileName ? source : undefined), + }; + const program = ts.createProgram( + [fileName], + { noLib: true, noResolve: true, module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.Latest }, + host, + ); + const bound = program.getSourceFile(fileName); + return { checker: program.getTypeChecker(), sourceFile: bound ?? parsed }; +}; -describe('D3 host import discipline', () => { - it('imports only allow-listed node builtins, itself, or the Cockpit boundary', () => { - for (const { file, text } of hostSources()) { - // Fail closed: a dynamic `import(...)` whose target is not a static string - // cannot be confined, so the host must contain none (D3-CX-POLICY-2). The - // extractor necessarily omits such a computed specifier, so this is the - // layer that must reject it. - expect( - hasUnverifiableDynamicImport(text), - `${file} contains an unverifiable (computed) dynamic import`, - ).toBe(false); - for (const specifier of extractModuleSpecifiers(text)) { - // A relative specifier is accepted only when its resolved destination is - // confined to the host tree or the Cockpit boundary (D3-CX-POLICY-1); a - // raw `./`/`../cockpit/` string prefix let a redundant escape like - // `./../index.js` reach `src/index.ts` (the domain re-export barrel). A - // `node:*` builtin is accepted only when on the exact production allowlist - // (D3-CX-POLICY-3), not by a blanket `node:` prefix. - const isRelative = specifier.startsWith('./') || specifier.startsWith('../'); - const allowed = - isAllowedNodeBuiltin(specifier) || - (isRelative && relativeImportStaysInBoundary(file, specifier)); - expect(allowed, `${file} imports forbidden specifier: ${specifier}`).toBe(true); - } - } - }); +const binderUnwrap = (node: ts.Expression): ts.Expression => { + let cur: ts.Expression = node; + while ( + ts.isParenthesizedExpression(cur) || + ts.isAsExpression(cur) || + ts.isSatisfiesExpression(cur) || + ts.isNonNullExpression(cur) || + ts.isTypeAssertionExpression(cur) || + ts.isAwaitExpression(cur) + ) { + cur = cur.expression; + } + return cur; +}; - it('never imports an adapter, transport, or authority module (nor the domain kernel directly)', () => { - for (const { file, text } of hostSources()) { - for (const specifier of extractModuleSpecifiers(text)) { - expect( - /adapter|transport|authorization|repair-job|permit|\.\.\/domain\//i.test(specifier), - `${file} imports forbidden module: ${specifier}`, - ).toBe(false); - } - } - }); -}); +const binderMemberName = (node: ts.PropertyAccessExpression | ts.ElementAccessExpression): string | null => { + if (ts.isPropertyAccessExpression(node)) return node.name.text; + const arg = node.argumentExpression; + return ts.isStringLiteralLike(arg) ? arg.text : null; +}; -describe('D3 host relative-import confinement rejects boundary escapes (D3-CX-POLICY-1)', () => { - // Mirror of the check-#1 acceptance predicate, exercised directly on synthetic - // (importer, specifier) pairs. No production file is created; the bounded policy - // helper is pure path arithmetic, so fixture paths resolve exactly as real ones. - const accepts = (importer: string, specifier: string): boolean => { - const isRelative = specifier.startsWith('./') || specifier.startsWith('../'); - return ( - isAllowedNodeBuiltin(specifier) || - (isRelative && relativeImportStaysInBoundary(importer, specifier)) - ); - }; +// The specifier text of the import a declaration belongs to (or null when not an import). +const declarationImportSpecifier = (decl: ts.Declaration): string | null => { + let p: ts.Node | undefined = decl; + while (p !== undefined) { + if (ts.isImportDeclaration(p)) return ts.isStringLiteral(p.moduleSpecifier) ? p.moduleSpecifier.text : null; + if (ts.isImportEqualsDeclaration(p)) { + const ref = p.moduleReference; + return ts.isExternalModuleReference(ref) && ts.isStringLiteral(ref.expression) ? ref.expression.text : null; + } + p = p.parent as ts.Node | undefined; + } + return null; +}; - // --- Rejections: every relative path whose resolved destination leaves the host --- - it('rejects the redundant `./`-prefixed parent escape to the barrel', () => { - expect(accepts('server.ts', './../index.js')).toBe(false); - }); +// Whether a symbol is DIRECTLY a static node:http namespace binding (`import * as http` +// / `import http` / `import http = require('node:http')`). One-hop, non-recursive — the +// only acquisition relation `HTTP_CLIENT` destructuring is allowed to consult. +const isDirectNodeHttpNamespace = (symbol: ts.Symbol | undefined): boolean => { + if (symbol === undefined || symbol.declarations === undefined) return false; + return symbol.declarations.some( + (d) => + (ts.isNamespaceImport(d) || (ts.isImportClause(d) && d.name !== undefined) || ts.isImportEqualsDeclaration(d)) && + declarationImportSpecifier(d) === 'node:http', + ); +}; - it('rejects a redundant `./`-prefixed escape straight into the domain kernel', () => { - expect(accepts('server.ts', './../domain/index.js')).toBe(false); - }); +// The static property KEY an object-binding element reads from its receiver: an explicit +// `propertyName` (identifier / string-literal / static computed string-literal), or, for the +// shorthand `{ name }`, the bound identifier itself. Null when the key is not statically +// identifiable — fail-closed as HTTP_CLIENT off an HTTP_NS receiver, exactly as the original +// single-hop rule did. +const bindingElementKey = (el: ts.BindingElement): string | null => { + const key = el.propertyName; + if (key === undefined) return ts.isIdentifier(el.name) ? el.name.text : null; + if (ts.isIdentifier(key) || ts.isStringLiteralLike(key)) return key.text; + if (ts.isComputedPropertyName(key) && ts.isStringLiteralLike(key.expression)) return key.expression.text; + return null; +}; - it('rejects a deeper `../../src/index.js` traversal escape', () => { - expect(accepts('server.ts', './../../src/index.js')).toBe(false); - expect(accepts('server.ts', '../../src/index.js')).toBe(false); - }); +// The node:http capability a member named `key` yields off a receiver of capability `recv`, +// mirroring member-access semantics (`classifyHttpExpression`): off the namespace, +// `createServer` is the one permitted server value and every other member (a non-static key +// included) is an outbound HTTP_CLIENT; off an HTTP_CLIENT value every member stays +// HTTP_CLIENT; nothing else propagates. No new capability kind, no member blacklist. +const httpMemberCapability = (recv: HttpCapability, key: string | null): HttpCapability => { + if (recv === 'HTTP_NS') return key !== null && HTTP_SERVER_VALUE_MEMBERS.has(key) ? 'CREATE_SERVER' : 'HTTP_CLIENT'; + if (recv === 'HTTP_CLIENT') return 'HTTP_CLIENT'; + return 'NONE'; +}; - it('rejects redundant dot segments that normalize outside the host', () => { - expect(accepts('server.ts', './cockpit/../../index.js')).toBe(false); - expect(accepts('server.ts', './././../adapters/foo.js')).toBe(false); - }); +// The capability a single OBJECT-destructuring binding element acquires, propagated +// RECURSIVELY through nested object binding patterns from the enclosing variable +// declaration's initializer (still the sole acquisition root, resolved ONE hop to the +// node:http namespace — no alias chains, no value-flow). A top-level element +// `{ request } = http` reduces to the original single-hop rule; a nested +// `{ globalAgent: { createConnection } } = http` propagates HTTP_NS → HTTP_CLIENT → +// HTTP_CLIENT structurally down the pattern until this element's bound name is reached. +// Only object patterns carry named members; an array binding pattern or a non-namespace +// initializer does not propagate. Finite: one step per binding-pattern nesting level. +const bindingElementHttpCapability = (el: ts.BindingElement, checker: ts.TypeChecker): HttpCapability => { + const pattern = el.parent; + if (!ts.isObjectBindingPattern(pattern)) return 'NONE'; + const container = pattern.parent; + let receiver: HttpCapability; + if (ts.isVariableDeclaration(container)) { + const initializer = container.initializer; + if (initializer === undefined) return 'NONE'; + const rhs = binderUnwrap(initializer); + receiver = ts.isIdentifier(rhs) && isDirectNodeHttpNamespace(checker.getSymbolAtLocation(rhs)) ? 'HTTP_NS' : 'NONE'; + } else if (ts.isBindingElement(container)) { + receiver = bindingElementHttpCapability(container, checker); + } else { + return 'NONE'; + } + if (receiver === 'NONE') return 'NONE'; + return httpMemberCapability(receiver, bindingElementKey(el)); +}; - it('rejects an escape from a nested host file', () => { - expect(accepts('fixtures/stage-a.ts', '../../index.js')).toBe(false); - expect(accepts('fixtures/stage-a.ts', './../../domain/index.js')).toBe(false); - }); +// Classify a single DECLARATION. Bounded: a node:http import, or a destructuring whose +// initializer DIRECTLY resolves to the node:http namespace (one hop, no alias chains), +// propagated through nested object binding patterns to the bound name. +const classifyHttpDeclaration = (decl: ts.Declaration, checker: ts.TypeChecker): HttpCapability => { + if (ts.isNamespaceImport(decl)) return declarationImportSpecifier(decl) === 'node:http' ? 'HTTP_NS' : 'NONE'; + if (ts.isImportClause(decl) && decl.name !== undefined) { + return declarationImportSpecifier(decl) === 'node:http' ? 'HTTP_NS' : 'NONE'; + } + if (ts.isImportEqualsDeclaration(decl)) return declarationImportSpecifier(decl) === 'node:http' ? 'HTTP_NS' : 'NONE'; + if (ts.isImportSpecifier(decl)) { + if (declarationImportSpecifier(decl) !== 'node:http') return 'NONE'; + const imported = (decl.propertyName ?? decl.name).text; + return HTTP_SERVER_VALUE_MEMBERS.has(imported) ? 'CREATE_SERVER' : 'HTTP_CLIENT'; + } + if (ts.isBindingElement(decl) && ts.isObjectBindingPattern(decl.parent)) { + return bindingElementHttpCapability(decl, checker); + } + return 'NONE'; +}; - it('rejects a sibling directory whose name merely begins with the host dir name', () => { - // `src/cockpit-host/../cockpit-host-evil/x.js` -> `src/cockpit-host-evil/x.js` - expect(accepts('server.ts', '../cockpit-host-evil/x.js')).toBe(false); - }); +// Classify a SYMBOL by its declaration(s) — the compiler binder resolved the symbol, so +// this is nearest-visible-binding identity (shadowing/restoration/scope all included). +const classifyHttpSymbol = (symbol: ts.Symbol | undefined, checker: ts.TypeChecker): HttpCapability => { + if (symbol === undefined || symbol.declarations === undefined) return 'NONE'; + for (const decl of symbol.declarations) { + const cap = classifyHttpDeclaration(decl, checker); + if (cap !== 'NONE') return cap; + } + return 'NONE'; +}; - it('rejects a sibling `cockpit-*` directory that is not the Cockpit boundary', () => { - // `src/cockpit-host/../cockpit-secrets/x.js` -> `src/cockpit-secrets/x.js`; - // must not be read as inside `src/cockpit`. - expect(accepts('server.ts', '../cockpit-secrets/x.js')).toBe(false); - }); +// Classify an EXPRESSION directly: an identifier (via its symbol) or a member access off +// an HTTP_NS receiver (createServer vs other). Bounded by member-access nesting (finite); +// no binding-element recursion, so no cycles. +const classifyHttpExpression = (expr: ts.Expression, checker: ts.TypeChecker): HttpCapability => { + const e = binderUnwrap(expr); + if (ts.isIdentifier(e)) return classifyHttpSymbol(checker.getSymbolAtLocation(e), checker); + if (ts.isPropertyAccessExpression(e) || ts.isElementAccessExpression(e)) { + if (classifyHttpExpression(e.expression, checker) === 'HTTP_NS') { + const m = binderMemberName(e); + return m !== null && HTTP_SERVER_VALUE_MEMBERS.has(m) ? 'CREATE_SERVER' : 'HTTP_CLIENT'; + } + } + return 'NONE'; +}; - it('rejects a path with misleading allowed text before escaping', () => { - // Threads through `cockpit/` yet resolves to `src/index.js`. - expect(accepts('server.ts', './cockpit/../../index.js')).toBe(false); - // Re-enters a `cockpit-host/`-named segment yet escapes above `src`. - expect(accepts('server.ts', '../../cockpit-host/../index.js')).toBe(false); - }); +// Whether an identifier is a value read (not a declaration/type/member/key position). +const isBinderValueReference = (id: ts.Identifier): boolean => { + const p = id.parent as ts.Node | undefined; + if (p === undefined) return true; + if (ts.isQualifiedName(p) && p.right === id) return false; + if (ts.isTypeReferenceNode(p)) return false; + if (ts.isPropertyAccessExpression(p) && p.name === id) return false; + if (ts.isBindingElement(p) && (p.name === id || p.propertyName === id)) return false; + if (ts.isVariableDeclaration(p) && p.name === id) return false; + if (ts.isParameter(p) && p.name === id) return false; + if (ts.isPropertyAssignment(p) && p.name === id) return false; + if ( + (ts.isFunctionDeclaration(p) || ts.isFunctionExpression(p) || ts.isClassDeclaration(p) || ts.isMethodDeclaration(p)) && + p.name === id + ) { + return false; + } + if (ts.isImportSpecifier(p) || ts.isNamespaceImport(p) || ts.isImportClause(p) || ts.isExportSpecifier(p)) return false; + return true; +}; - it('rejects a backslash-smuggled escape, folded to `/` (POSIX/Windows-consistent)', () => { - expect(accepts('server.ts', '.\\..\\index.js')).toBe(false); - expect(accepts('server.ts', './..\\domain\\index.js')).toBe(false); - }); +// The permitted positions for an HTTP_NS occurrence (else it is a forbidden escape): a +// member-access receiver, an object-binding-pattern destructuring initializer, or a +// type/non-runtime position (through paren/as/await wrappers). +const isHttpNsSafePosition = (node: ts.Node): boolean => { + let cur: ts.Node = node; + for (;;) { + const parent = cur.parent as ts.Node | undefined; + if ( + parent !== undefined && + (ts.isParenthesizedExpression(parent) || + ts.isAsExpression(parent) || + ts.isSatisfiesExpression(parent) || + ts.isNonNullExpression(parent) || + ts.isAwaitExpression(parent)) + ) { + cur = parent; + continue; + } + break; + } + const p = cur.parent as ts.Node | undefined; + if (p === undefined) return false; + if ((ts.isPropertyAccessExpression(p) || ts.isElementAccessExpression(p)) && p.expression === cur) return true; + if (ts.isVariableDeclaration(p) && ts.isObjectBindingPattern(p.name) && p.initializer === cur) return true; + if ( + ts.isTypeQueryNode(p) || + ts.isTypeReferenceNode(p) || + ts.isQualifiedName(p) || + ts.isTypeOfExpression(p) || + ts.isImportTypeNode(p) + ) { + return true; + } + return false; +}; - it('rejects a bare or plain-parent specifier that is not node: and not confined', () => { - expect(accepts('server.ts', '../index.js')).toBe(false); // plain parent to the barrel - expect(accepts('server.ts', '../domain/index.js')).toBe(false); - expect(accepts('server.ts', 'typescript')).toBe(false); - }); +// DDR-CREATE-SERVER-ALIAS-POLICY (Option A — positive-model restriction). The one permitted +// node:http value capability, `createServer`, may itself REMAIN only in the smallest approved +// direct-call forms: as the callee of a call (`http.createServer(...)`, `createServer(...)`, +// `mk(...)`), or in a type / non-runtime position. Every other position — a variable/const +// initializer, an assignment right-hand side, a call ARGUMENT, a return, an array/object +// element or spread value — FORWARDS or STORES the constructor capability itself and is a +// forbidden escape, decided at THIS occurrence (the same shape as `isHttpNsSafePosition`; no +// alias/value-flow tracking — a receiving binding such as `start`/`cs` is never classified). +// The returned Server object is a SEPARATE value (capability NONE), so the CALL RESULT +// (`const server = http.createServer(...)`) sits in none of these positions and is untouched. +// Unwraps only the same finite transparent wrappers (paren / as / satisfies / non-null / await). +const isCreateServerSafePosition = (node: ts.Node): boolean => { + let cur: ts.Node = node; + for (;;) { + const parent = cur.parent as ts.Node | undefined; + if ( + parent !== undefined && + (ts.isParenthesizedExpression(parent) || + ts.isAsExpression(parent) || + ts.isSatisfiesExpression(parent) || + ts.isNonNullExpression(parent) || + ts.isAwaitExpression(parent)) + ) { + cur = parent; + continue; + } + break; + } + const p = cur.parent as ts.Node | undefined; + if (p === undefined) return false; + if ((ts.isCallExpression(p) || ts.isNewExpression(p)) && p.expression === cur) return true; + if ( + ts.isTypeQueryNode(p) || + ts.isTypeReferenceNode(p) || + ts.isQualifiedName(p) || + ts.isTypeOfExpression(p) || + ts.isImportTypeNode(p) + ) { + return true; + } + return false; +}; - // --- Preservations: every legitimate host / Cockpit import still accepted --- - it('accepts a same-directory local import from a top-level host file', () => { - expect(accepts('server.ts', './local.js')).toBe(true); - expect(accepts('render.ts', './escape.js')).toBe(true); - }); +// A declaration EMITS a runtime value binding — and so can shadow a runtime global — only +// when it is neither ambient (`declare …`, which emits nothing) nor a type-only form +// (interface / type alias / type-only import). This is the minimal runtime-emission +// distinction, read from the AST and modifier flags rather than restored as a +// declaration-kind whitelist: it separates a real local shadow (`const fetch = …`, +// `function fetch`, `class`, `enum`, a value namespace, `import x = require(...)`, a runtime +// import binding) from a declaration-only binding (`declare const fetch`, a type-only import) +// that leaves the runtime global reachable at the call site. +// Whether a node sits in an ambient (`declare …`) context — itself or any enclosing +// declaration carries the `declare` modifier (covers `declare const`, `declare function`, +// and a binding nested in `declare global` / `declare namespace`). Public API only. +const isInAmbientContext = (node: ts.Node): boolean => { + let n: ts.Node | undefined = node; + while (n !== undefined) { + if (ts.canHaveModifiers(n)) { + const mods = ts.getModifiers(n); + if (mods !== undefined && mods.some((m) => m.kind === ts.SyntaxKind.DeclareKeyword)) return true; + } + n = n.parent as ts.Node | undefined; + } + return false; +}; - it('accepts a nested local import', () => { - expect(accepts('server.ts', './nested/local.js')).toBe(true); - expect(accepts('server.ts', './fixtures/stage-a.js')).toBe(true); - }); +// Whether an import binding is type-only — the whole clause (`import type { fetch }`, whose +// clause carries the `type` phase modifier) or the inline specifier form (`import { type fetch }`). +const isTypeOnlyImportClause = (clause: ts.ImportClause): boolean => clause.phaseModifier === ts.SyntaxKind.TypeKeyword; + +const declarationEmitsRuntimeValue = (d: ts.Declaration): boolean => { + if (ts.isInterfaceDeclaration(d) || ts.isTypeAliasDeclaration(d)) return false; + if (isInAmbientContext(d)) return false; + if (ts.isImportClause(d) && isTypeOnlyImportClause(d)) return false; + if (ts.isImportSpecifier(d)) { + if (d.isTypeOnly) return false; + const clause = d.parent.parent as ts.Node | undefined; + if (clause !== undefined && ts.isImportClause(clause) && isTypeOnlyImportClause(clause)) return false; + } + return true; +}; - it('accepts legitimate parent navigation that stays inside the host', () => { - expect(accepts('fixtures/stage-a.ts', '../local.js')).toBe(true); - expect(accepts('fixtures/stage-a.ts', '../render.js')).toBe(true); - }); +// Whether the binder-resolved symbol has, in the analyzed file, at least one declaration that +// emits a runtime value — i.e. a genuine RUNTIME shadow of a same-named global. A symbol whose +// only in-file declarations are ambient/type-only is NOT a runtime shadow. +const hasLocalRuntimeShadow = (symbol: ts.Symbol, sourceFile: ts.SourceFile): boolean => + symbol.declarations !== undefined && + symbol.declarations.some((d) => d.getSourceFile() === sourceFile && declarationEmitsRuntimeValue(d)); - it('accepts the explicit sibling Cockpit boundary from a top-level host file', () => { - expect(accepts('server.ts', '../cockpit/index.js')).toBe(true); - }); +// A receiver is the REAL global (globalThis/window/self/global) only when the binder resolves +// it to NO local RUNTIME binding in this file (an intrinsic globalThis has a symbol but no +// local declaration; a param/const shadow does). Identifier text is not identity. +// +// Local runtime-shadow rule (no declaration-kind whitelist): the receiver is free/global only +// when its resolved symbol carries no in-file declaration that EMITS A RUNTIME VALUE. Any +// genuine runtime binding of the name — const/let/var, parameter, binding element, function, +// class, enum, a namespace with a runtime value, import-equals, a runtime import — is a shadow, +// so the receiver is an ordinary object. Two kinds of declaration are correctly NOT shadows: +// a type-only name (`interface global` / `type global`) does not even resolve at a value +// position (the binder returns no value symbol), and an ambient `declare const global` resolves +// but emits nothing at runtime, so the real global is still reached — both leave the receiver +// free. Restoration/nesting/sibling scope are the binder's job as before. +const isFreeGlobalReceiver = (expr: ts.Expression, checker: ts.TypeChecker, sourceFile: ts.SourceFile): boolean => { + const recv = binderUnwrap(expr); + if (ts.isIdentifier(recv)) { + if (!GLOBAL_RECEIVER_NAMES.has(recv.text)) return false; + const symbol = checker.getSymbolAtLocation(recv); + if (symbol === undefined) return true; + return !hasLocalRuntimeShadow(symbol, sourceFile); + } + // Global-object self-reference hop (`globalThis.globalThis`, `window.window`, …): the member + // name is a global-receiver name and its own receiver is (recursively) a free global. Binder/ + // shadowing authority stays at the BASE identifier above — a shadowed base + // (`function f(globalThis){ globalThis.globalThis.fetch() }`) demotes the whole chain to an + // ordinary object. DDR-NET-STATIC-KEY-PARITY (F1): the ELEMENT-access hop key is folded by the + // bounded binder resolver (`netHopName`), so `globalThis['global' + 'This']`, + // `const k = 'globalThis'; globalThis[k]`, and `` globalThis[`glob` + 'alThis'] `` are recognized + // hops, while a genuinely runtime key resolves to null and is NOT a hop. Finite: recursion + // descends `recv.expression`. + if (ts.isPropertyAccessExpression(recv) || ts.isElementAccessExpression(recv)) { + const hop = ts.isPropertyAccessExpression(recv) ? recv.name.text : netHopName(recv.argumentExpression, checker); + return hop !== null && GLOBAL_RECEIVER_NAMES.has(hop) && isFreeGlobalReceiver(recv.expression, checker, sourceFile); + } + return false; +}; - it('accepts a legitimate Cockpit import from a nested host file', () => { - expect(accepts('fixtures/stage-a.ts', '../../cockpit/index.js')).toBe(true); - }); +// Whether a call is a runtime dynamic `import('node:http')` (Option B: prohibited outright). +const isDynamicNodeHttpImport = (node: ts.Node): boolean => { + if (!ts.isCallExpression(node) || node.expression.kind !== ts.SyntaxKind.ImportKeyword) return false; + const specifier = node.arguments[0]; + return specifier !== undefined && ts.isStringLiteralLike(specifier) && specifier.text === 'node:http'; +}; - it('restricts node: builtins to the exact production allowlist (POLICY-3)', () => { - expect(accepts('server.ts', 'node:http')).toBe(true); - expect(accepts('server.ts', 'node:url')).toBe(true); - // Every non-allowlisted builtin is now refused (previously the blanket `node:` - // prefix accepted them all): a "read-only" host cannot reach filesystem- - // mutation or process authority through `node:*`. - expect(accepts('server.ts', 'node:fs')).toBe(false); - }); +// SOCK — reject acquisition of the inbound SERVER SOCKET capability (D3-CX-POLICY-NET-SOCK). +// The permitted `createServer` path yields a listening server whose request/response objects +// and connection-family events expose the underlying duplex socket — a transitive OUTBOUND +// capability (`req.socket.connect(...)`) the createServer allowance is not meant to grant. +// This is the FINAL BOUNDED D3 source policy (commander decision, frozen): the strongest +// finite policy that stays compatible with the actual host. It is NOT a taint/alias/type/ +// whole-program engine and does NOT claim literal no-egress. It is ONE global static-name +// rule (RULE A over a socket-acquisition name FAMILY) plus its req/res computed refinement +// (RULE A2), both receiver- and position-independent: +// +// RULE A — GLOBAL static socket-ACQUISITION NAME ban, regardless of receiver identity and +// regardless of call/read position. Two name families are rejected wherever a statically +// identifiable property/binding KEY names them: +// (i) the SOCKET-VALUE names `socket`/`connection` — the duplex socket itself; and +// (ii) the SOCKET-DELIVERY member names `on`/`once`/`addListener`/`prependListener`/ +// `prependOnceListener`/`setTimeout` — the permitted http.Server callbacks that +// hand a socket to a handler (`connection`/`request`/`upgrade`/`connect`/ +// `clientError`/`dropRequest`/`timeout`, plus `setTimeout`'s one-shot timeout +// socket), covered with NO event-name list. +// The ban fires on every statically identifiable form: dotted `x.on` (optional chaining +// included), static-computed `x['on']`/`` x[`socket`] ``, and object-destructuring +// `{ socket }` / `{ on: h }` in any binding pattern (variable, parameter, nested, +// callback). Because it anchors on the member NAME at ANY position — not on a call +// callee — it closes the indirect-registrar family uniformly: `server.on(...)`, +// `server.on.call/apply/bind(...)`, `Reflect.apply(server.on, …)`, `const m = server.on` +// (F2), and `server.setTimeout(t, socket => …)` (F1) all contain the banned name as a +// property access and are rejected at acquisition, with no witness-specific +// `.call`/`.apply`/`.bind` blacklist. Receiver identity is deliberately irrelevant — an +// unrelated `camera.socket`, `emitter.on('ready', …)`, or `obj.setTimeout(…)` is an +// accepted, intentional policy false positive; real host/cockpit source uses none of +// these names (it calls only `http.createServer` and `server.listen`). +// +// RULE A2 — for the request/response PARAMETERS of a function literal passed DIRECTLY to a +// permitted static `createServer` call (binder identity — these are the sole direct +// entry of IncomingMessage/ServerResponse into user code), an element access whose key +// is not a static string FAILS CLOSED (`req[key]`, `req['sock'+'et']`, `req[c?…:…]`). +// This closes computed recovery on the direct handler param without touching legitimate +// host indexing elsewhere (`array[index]`, `text[character]`, `object[key]` are NOT on a +// createServer handler param, so they are unaffected). No const-folding is used. A2's +// socket/connection semantics are preserved byte-for-byte by the RULE A name-family +// promotion above (A2 keeps consulting `SOCKET_CAPABILITY_NAMES`, not the wider family). +// +// HONEST BOUNDARY (frozen, not a defect): a socket-delivering member acquired WITHOUT its +// name ever appearing statically — a cross-function alias combined with a runtime-computed +// key, `const r = request; r[runtimeKey]` where `runtimeKey` becomes `'socket'` at runtime +// (or a registrar/`setTimeout` reached as `server[k]` with runtime `k`) — is NOT closed; +// closing it would require alias propagation / type resolution / whole-program flow, +// deliberately excluded here. It belongs to a future runtime-isolation enforcement boundary, +// not this source policy. +// The SOCKET-VALUE names (RULE A family i) — the duplex socket itself. Still used verbatim by +// the RULE A2 req/res-bound branches below, whose socket/connection semantics are preserved. +// MECH-2 (Day-7 convergence): `client` is IncomingMessage's third alias of the SAME duplex socket +// (`req.client === req.socket` on the installed Node 24 API), so it joins family (i) — one inventory, +// receiver-independent like `socket`/`connection`, with no parallel name list. +const SOCKET_CAPABILITY_NAMES: ReadonlySet = new Set(['socket', 'connection', 'client']); +// The SOCKET-DELIVERY member names (RULE A family ii) — the permitted http.Server callbacks +// that hand a socket to a handler. `setTimeout` (the one-shot 'timeout' socket — F1) sits +// beside the five event registrars; the whole family is banned by NAME at any position (F2), +// never by call shape, so `.call`/`.apply`/`.bind`/`Reflect.apply`/`const m = server.on` +// cannot launder it, and there is NO event-name list to maintain. +// EXACT-HEAD CODEX P1: `emit` joins the SAME family — http.Server inherits +// `EventEmitter.prototype.emit`, and Node's internal connection delivery invokes the server's OWN +// overridable `emit` (`server.emit('connection', socket)`, verified on Node v24.12.0), so REPLACING +// or reading `server.emit` receives the live socket exactly as the registrars do. Being a receiver- +// independent member NAME, it is closed at every acquisition position — reads AND the replacement +// writes `server.emit = …` / `server['emit'] = …` (the LHS `server.emit` is a member access the RULE +// A (a)/(b) visit sees regardless of the `=`) — with no emit-specific rule and no EventEmitter/event- +// name analysis. Real host source names none of these members. +const SOCKET_DELIVERY_MEMBERS: ReadonlySet = new Set([ + 'on', + 'once', + 'addListener', + 'prependListener', + 'prependOnceListener', + 'setTimeout', + 'emit', +]); +// RULE A family (iii) — CONSTRUCTOR RE-DERIVATION (Day-7 F2). The permitted createServer CALL RESULT +// is an http.Server whose ordinary `constructor` property (inherited from `http.Server.prototype`) IS +// the privileged Server constructor: `new (http.createServer() as any).constructor(listener)` builds a +// second server whose listener/options never pass through the createServer argument boundary, and +// `Object.getPrototypeOf(server.constructor)` reaches `net.Server`. The name joins the SAME +// receiver-independent static-name ban as the delivery members — dotted, static/binder-resolved element, +// declaration destructuring, ASSIGNMENT destructuring, and the structural reflective read — so every +// statically spelled `constructor` acquisition is rejected where the name appears, with no server +// identity tracked and no constructor-specific dataflow. A ConstructorDeclaration (`class X { +// constructor() {} }`), an object-literal DATA key `{ constructor: 1 }`, the string `'constructor'`, and a +// superstring (`constructorName`) are not this name at a member/binding/assignment-key position and are +// untouched. (RC rule (a) already bans the dotted/static spelling by TEXT for code generation; this +// entry gives NET the binder-identity, destructuring, assignment, and reflective parity RC lacks.) +const CONSTRUCTOR_REDERIVATION_NAMES: ReadonlySet = new Set(['constructor']); +// RULE A family (iv) — PROTOTYPE REACH (Day-7 MECH-1 prototype-inheritance closure). A supported +// createServer options literal (inline, or reached through a confined const spine) inherits from +// `Object.prototype`, and Node reads `options.IncomingMessage` / `options.ServerResponse` with an +// ordinary GET that walks the prototype chain — so polluting `Object.prototype` (`Object.prototype.X =`, +// `Object.assign/defineProperty/defineProperties(Object.prototype, …)`, `Reflect.set(Object.prototype, …)`, +// an inherited GETTER) makes an otherwise clean `{}` deliver the live socket (verified on Node 24; the +// no-options form is immune because Node substitutes a frozen null-prototype object). No static rule can +// prove `Object.prototype` pristine, and a boundary-local check (only in files that pass options) is +// UNSOUND across the scanned host tree — the pollution site and the options literal may sit in different +// host files. The closure is therefore the SAME receiver-independent static-name reservation the other +// RULE A families use: every statically spelled path to an object's prototype names `prototype` +// (`Object.prototype`, `Object['proto' + 'type']`, `const { prototype } = Object`), `__proto__` +// (`({}).__proto__`, `const { __proto__: p } = x`, `x.__proto__ = …`), or `getPrototypeOf` +// (`Object.getPrototypeOf({})`, `Reflect.getPrototypeOf(…)`, `const { getPrototypeOf } = Object`), and +// each is rejected where the NAME appears — dotted, binder-resolved element, declaration destructuring, +// assignment destructuring, and the structural reflective read (`Reflect.get(Object, 'prototype')`, +// `Object.getOwnPropertyDescriptor(Object, 'prototype')`). `setPrototypeOf` is NOT needed here: +// `Object.prototype` is an immutable-prototype exotic object (`Object.setPrototypeOf` throws, +// `Reflect.setPrototypeOf` returns false), and re-prototyping the options object itself is already +// rejected by the argument-shape / const-spine confinement rules. No prototype graph is traversed and no +// receiver is tracked. The object-literal `__proto__:` DATA key stays a separate options-literal rule +// (it sets the literal's own prototype); an object-literal data key `{ prototype: 1 }`, the strings +// `'prototype'` / `'__proto__'`, a superstring (`prototypeName`), a class declaration, and a type-position +// member are not this name at a member / binding / assignment-key position and are untouched. The real +// host names none of these (it is an accepted RULE A policy false positive elsewhere, like `camera.socket`). +const PROTOTYPE_REACH_NAMES: ReadonlySet = new Set(['prototype', '__proto__', 'getPrototypeOf']); +// The receiver-INDEPENDENT ASSIGNMENT-destructuring family: the delivery/registrar members (family ii), +// constructor re-derivation (family iii), and prototype reach (family iv). The socket-value names (family i) +// are deliberately NOT here — their assignment extraction stays req/res-bound (see +// `scanSocketAssignmentTarget`). +const RECEIVER_INDEPENDENT_ASSIGNMENT_NAMES: ReadonlySet = new Set([ + ...SOCKET_DELIVERY_MEMBERS, + ...CONSTRUCTOR_REDERIVATION_NAMES, + ...PROTOTYPE_REACH_NAMES, +]); +// The full RULE A static name family (i ∪ ii ∪ iii ∪ iv), tested at every statically identifiable +// property/binding-key position (dotted, static-computed, destructured), receiver-independent. +const STATIC_SOCKET_ACQUISITION_NAMES: ReadonlySet = new Set([ + ...SOCKET_CAPABILITY_NAMES, + ...SOCKET_DELIVERY_MEMBERS, + ...CONSTRUCTOR_REDERIVATION_NAMES, + ...PROTOTYPE_REACH_NAMES, +]); +// RULE A3 — the finite Node http.Server CONSTRUCTOR-INJECTION option family. `createServer`'s options +// object may name a custom request/response constructor class; Node then constructs the supplied +// `IncomingMessage` subclass with the LIVE connection socket (`constructor(socket)`) and the supplied +// `ServerResponse` subclass with the request object. A plain class passed under either key therefore +// receives that capability in its constructor without ever naming a banned socket property or a +// node:http value — a socket/request-delivery entry point the createServer allowance is not meant to +// grant. This is a closed two-name family on the installed Node 24 API (`ServerOptions.IncomingMessage` +// / `ServerOptions.ServerResponse`); reserving the option KEYS at the createServer acquisition site +// closes it with no class-body, constructor, or alias inspection. +const CONSTRUCTOR_INJECTION_OPTIONS: ReadonlySet = new Set(['IncomingMessage', 'ServerResponse']); + +// The static key named by an element-access argument or a binding-element key, or null. +const staticKeyText = (key: ts.Node | undefined): string | null => { + if (key === undefined) return null; + if (ts.isIdentifier(key)) return key.text; + if (ts.isStringLiteralLike(key)) return key.text; + if (ts.isComputedPropertyName(key) && ts.isStringLiteralLike(key.expression)) return key.expression.text; + return null; +}; - // Integration: check #1 now catches the escape, and the forbidden-module check - // (check #2) remains an independent defense whose behavior is unchanged. - it('check #1 rejects `./../index.js` while the forbidden-module defense stays independent', () => { - const spec = './../index.js'; - expect(accepts('server.ts', spec)).toBe(false); // now caught by check #1 - // check #2 independently does NOT match this specifier text, proving check #1 - // is the load-bearing defense here and check #2 is untouched by this repair. - expect(/adapter|transport|authorization|repair-job|permit|\.\.\/domain\//i.test(spec)).toBe( - false, - ); - }); +// MECH-D3 positive-operation allowlists (D3 STRUCTURAL provenance/position model). These are the +// FINITE approved operations for each proven target class — the demonstrated host requirements and +// nothing more. They are POSITIVE allowlists, NOT banned-name tables: `SOCKET_DELIVERY_MEMBERS` and +// `META_MUTATION_APIS` are frozen and untouched. The mechanism denies-by-default, so an unknown +// member (`futureMemberX`) is rejected by ABSENCE from these sets, never by presence in a deny list — +// which is exactly what makes the rule key/name-independent (no dangerous spelling is ever enumerated). +// SERVER — may be CALLED only as `.listen(...)` / `.close(...)`. +// REQUEST — may be READ only as `.method` / `.url`. +// RESPONSE — may be CALLED only as `.setHeader(...)` / `.end(...)`, or WRITTEN `statusCode =` a +// NumericLiteral. +const D3_SERVER_CALL_MEMBERS: ReadonlySet = new Set(['listen', 'close']); +const D3_REQUEST_READ_MEMBERS: ReadonlySet = new Set(['method', 'url']); +const D3_RESPONSE_CALL_MEMBERS: ReadonlySet = new Set(['setHeader', 'end']); +const D3_RESPONSE_WRITE_MEMBERS: ReadonlySet = new Set(['statusCode']); + +const acquiresInboundServerSocket = (checker: ts.TypeChecker, sourceFile: ts.SourceFile): boolean => { + // MECH-1 (Day-7 convergence): the privileged createServer BOUNDARY is a CallExpression OR a + // NewExpression whose callee is binder-proven CREATE_SERVER. `new http.createServer(…)` runs the + // same factory with the same arguments (and the constructor position is already a NET-safe + // position for the capability), so it is the SAME boundary — argument disposition is identical + // for both forms; `arguments ?? []` covers the argument-less `new http.createServer` spelling. + const isCreateServerCall = (node: ts.Node): node is ts.CallExpression | ts.NewExpression => + (ts.isCallExpression(node) || ts.isNewExpression(node)) && + classifyHttpExpression(node.expression, checker) === 'CREATE_SERVER'; + + // DDR-NET-STATIC-KEY-PARITY (SOCK): socket acquisition keys are resolved by TypeScript BINDER + // identity via the shared bounded `netResolveKey` (see `sockResolveKey`) — the SAME resolver NET's + // member/hop/destructuring keys use — never by the whole-file text-keyed `collectStringConsts`/ + // `staticStringOf`. A Resolved socket-acquisition NAME is rejected on any receiver + // (`server['o' + 'n']`, `const k = 'on'; server[k]`), and a shadowing same-text `const` in an + // unrelated scope can no longer make a binder-pinned key unresolved (the scope-insensitive + // fail-open). An Indeterminate (genuinely runtime / resource-abort) key stays outside the proof, + // except on a createServer req/res param where RULE A2 fails closed. One declaration-keyed memo, + // fresh per traversal and scoped to the socket ceiling, is shared by every SOCK key resolution so a + // const chain reused across many accesses is resolved once (bounded work); per-key `seen`/`budget` + // stay local (cycle detection + the per-key hop ceiling). + const sockMemo = new Map(); + + // ROOT-FAMILY-CS-ARGUMENT-RESOLUTION — normalize ONE argument of an already-proven node:http + // createServer call to the finite provable source expression the positive policy must inspect, + // BEFORE Pass 1's handler-parameter collection and RULE A3's option scan look at it. Only unique, + // immutable, binder-proven LOCAL bindings are followed: a transparent wrapper (`binderUnwrap`: + // paren / `as` / satisfies / `!` / type-assertion / await), a unique `const` identifier folded to + // its initializer (the SAME `netUniqueConstDecl` primitive the NET/SOCK key resolvers use — + // binder identity, exactly one declaration, `const` list flag — iterated so a `const b = a; const + // a = {…}` alias spine resolves), and a unique `FunctionDeclaration` identifier folded to its + // declaration node. Resolution STOPS (returns the last node reached, i.e. Indeterminate for the + // caller's type guards) on every mutable / runtime / unsupported form — a `let`/`var` binding + // (identity can change), a parameter, a property, a call result, a spread-built object, a + // duplicate/absent declaration, an alias cycle — so those stay outside the proof exactly as the + // frozen positive model already leaves `createServer(getOptions())` outside it. An explicit hop + // cap plus a visited-declaration set guarantee termination. This is bounded local declaration + // NORMALIZATION scoped to createServer arguments — NOT value flow, alias/taint propagation, a call + // graph, or class-body inspection: the resolved node's VALUE, body, and constructor are never + // read here; the caller's existing guards (`isArrowFunction`/`isFunctionExpression`/ + // `isFunctionDeclaration` for the handler, `isObjectLiteralExpression` for the options) decide + // whether the finite policy applies, and the option KEYS are still resolved by the same bounded + // `staticKeyText`/`sockResolveKey` machinery. A DIRECT inline function / object-literal argument + // is returned unchanged (the loop never runs), so existing direct-form behavior is identical. + const CS_ARG_RESOLVE_HOP_CAP = 64; + // `spine` (optional out-parameter) receives every unique-`const` declaration the resolution passed + // through, boundary-side first (`createServer(b)` with `const b = a; const a = {…}` yields [b, a]). + // The options-literal CONFINEMENT check below needs the spine bindings, not only the final node. + const resolveCreateServerArgument = (arg: ts.Expression, spine?: ts.VariableDeclaration[]): ts.Node => { + let cur: ts.Expression = binderUnwrap(arg); + const seen = new Set(); + for (let hops = 0; ts.isIdentifier(cur) && hops < CS_ARG_RESOLVE_HOP_CAP; hops++) { + const symbol = checker.getSymbolAtLocation(cur); + const decls = symbol?.declarations; + if (decls === undefined || decls.length !== 1) return cur; // ambient / duplicate / undeclared: stop + const decl = decls[0]; + if (decl === undefined || seen.has(decl)) return cur; // alias cycle / re-entry: stop in finite time + seen.add(decl); + if (ts.isFunctionDeclaration(decl)) return decl; // unique named-function handler: the function IS the source + const constDecl = netUniqueConstDecl(cur, checker); + if (constDecl === null || constDecl.initializer === undefined) return cur; // let/var/param/property/no-init: stop + spine?.push(constDecl); + cur = binderUnwrap(constDecl.initializer); // unique `const` hop (const→const spine included) + } + return cur; + }; - // The real host sources still satisfy check #1 under the confinement predicate. - it('accepts every specifier the real host sources actually import', () => { - for (const { file, text } of hostSources()) { - for (const specifier of extractModuleSpecifiers(text)) { - expect(accepts(file, specifier), `${file} -> ${specifier}`).toBe(true); + // Day-7 MECH-1 — CONST-SPINE CONFINEMENT of a resolved OPTIONS LITERAL. A `const` freezes the + // BINDING, not the OBJECT: `const o = {}; o.IncomingMessage = Capture; http.createServer(o, …)` + // resolves `o` to the literal `{}` while Node reads the mutated object at call time. Property / + // element / computed / const-key / runtime-key assignment, `Object.assign` / `defineProperty` / + // `setPrototypeOf` / `Reflect.set`, `o.__proto__ = …`, a mutation through an alias or a bounded alias + // chain, a mutation inside a function or closure, and a mutation textually AFTER the call (execution + // order is not statically provable) all deliver the live socket. Static object immutability cannot + // be proven inside this bounded mechanism, so the disposition is POSITIONAL and fail-closed, the same + // shape as `isCreateServerSafePosition` / `isHttpNsSafePosition`: the literal reached through a + // spine is a supported source ONLY IF every binding on the spine is referenced NOWHERE except + // (1) as the initializer of the previous spine binding (`const b = a` — the spine itself), and + // (2) as an argument of a binder-proven createServer call / `new` (through the transparent + // wrappers the resolver strips), + // and no spine binding is `export`ed (a live import binding elsewhere could mutate the object). + // ANY other occurrence — an assignment target receiver, a benign-looking read, a side alias + // (`const c = a`), a call argument, a container element, an object value (shorthand included), a + // return, an export specifier / default export, a closure capture, a type query — is an escape of + // the object's IDENTITY and DENIES at the boundary. Nothing is claimed about what the escape does: + // the object might be mutated, so it is not a supported source. References are inventoried ONCE + // per traversal by BINDER SYMBOL (a same-text binding in another scope is a different symbol and + // is irrelevant); a shorthand `{ o }` resolves through `getShorthandAssignmentValueSymbol` and an + // `export { o }` through `getExportSpecifierLocalTargetSymbol`; a same-text VALUE identifier the + // binder cannot resolve at all is treated as a reference (fail-closed). A function-like source is + // NOT subject to this rule: a function's code cannot be replaced through its object. Total and + // terminating: one file walk builds the inventory, each spine binding's references are visited + // once, each site is classified by a constant-depth parent walk; no value flow, no alias graph. + let referenceInventory: { readonly refs: ReadonlyMap; readonly unresolved: ReadonlySet } | null = null; + const inventoryValueReferences = (): NonNullable => { + if (referenceInventory !== null) return referenceInventory; + const refs = new Map(); + const unresolved = new Set(); + const record = (symbol: ts.Symbol | undefined, site: ts.Node, text: string): void => { + if (symbol === undefined) { + unresolved.add(text); + return; + } + const list = refs.get(symbol); + if (list === undefined) refs.set(symbol, [site]); + else list.push(site); + }; + const walk = (node: ts.Node): void => { + if (ts.isIdentifier(node) && isBinderValueReference(node)) { + const p = node.parent; + if (ts.isShorthandPropertyAssignment(p) && p.name === node) { + record(checker.getShorthandAssignmentValueSymbol(p), node, node.text); + } else { + record(checker.getSymbolAtLocation(node), node, node.text); + } + } else if (ts.isExportSpecifier(node)) { + record(checker.getExportSpecifierLocalTargetSymbol(node), node, (node.propertyName ?? node.name).text); + } + ts.forEachChild(node, walk); + }; + ts.forEachChild(sourceFile, walk); + referenceInventory = { refs, unresolved }; + return referenceInventory; + }; + // Walk up through the transparent wrappers the resolver strips (paren / as / satisfies / `!` / + // type-assertion / await) and return the outermost wrapped node. + const outermostTransparentWrapper = (site: ts.Node): ts.Node => { + let cur: ts.Node = site; + for (;;) { + const p = cur.parent as ts.Node | undefined; + if ( + p !== undefined && + (ts.isParenthesizedExpression(p) || + ts.isAsExpression(p) || + ts.isSatisfiesExpression(p) || + ts.isNonNullExpression(p) || + ts.isTypeAssertionExpression(p) || + ts.isAwaitExpression(p)) && + p.expression === cur + ) { + cur = p; + continue; + } + return cur; + } + }; + const isCreateServerBoundaryArgument = (site: ts.Node): boolean => { + const cur = outermostTransparentWrapper(site); + const p = cur.parent as ts.Node | undefined; + return p !== undefined && isCreateServerCall(p) && (p.arguments ?? []).some((a) => a === cur); + }; + const isInitializerOf = (site: ts.Node, decl: ts.VariableDeclaration): boolean => { + const cur = outermostTransparentWrapper(site); + return cur.parent === decl && decl.initializer === cur; + }; + const optionsSpineIsConfined = (spine: readonly ts.VariableDeclaration[]): boolean => { + const { refs, unresolved } = inventoryValueReferences(); + for (let i = 0; i < spine.length; i++) { + const decl = spine[i]; + if (decl === undefined || !ts.isIdentifier(decl.name)) return false; + const statement = decl.parent.parent as ts.Node | undefined; + if ( + statement !== undefined && + ts.isVariableStatement(statement) && + ts.getModifiers(statement)?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) === true + ) { + return false; // an exported binding is a live (mutable-object) import elsewhere + } + if (unresolved.has(decl.name.text)) return false; // a same-text reference the binder could not pin + const symbol = checker.getSymbolAtLocation(decl.name); + if (symbol === undefined) return false; + const previous = i > 0 ? spine[i - 1] : undefined; + for (const site of refs.get(symbol) ?? []) { + if (isCreateServerBoundaryArgument(site)) continue; + if (previous !== undefined && isInitializerOf(site, previous)) continue; + return false; // any other occurrence: the object's identity escaped / may be mutated + } + } + return true; + }; + + let found = false; + + // RULE A2 (binding-pattern walk — MECH-2 object-rest + nested fail-close) — walk ONE finite binding + // pattern whose ROOT is a tracked req/res receiver (a createServer listener PARAMETER pattern, or + // the `= req/res` initializer of a DECLARATION pattern). Two things reject, at ANY nesting depth + // of the pattern: (a) an OBJECT-REST element (`{ ...rest }`) — the rest object carries every + // own enumerable property of the receiver, socket included, into an untracked binding, so the + // acquisition is rejected HERE and the rest object is never propagated/followed; (b) an + // INDETERMINATE computed key — the SAME predicate the top-level RULE A2 declaration branch has + // always used (`staticKeyText === null`), now applied at every depth so `{ req: { [k]: s } }` + // off `res` fails closed exactly like `res.req[k]`. Nesting is walked STRUCTURALLY through the + // finite binding AST only (object and array sub-patterns; an array hole is skipped; an ARRAY rest + // `[...r]` is not an object rest and is not rejected), terminating at the pattern leaves — no + // value flow, no alias following. A statically resolvable harmless key (`{ method }`) is + // unaffected; a socket-acquisition NAME is already rejected by RULE A (c). + const scanReqResBindingPattern = (pattern: ts.BindingPattern): void => { + for (const el of pattern.elements) { + if (!ts.isBindingElement(el)) continue; // an array-pattern hole (OmittedExpression) + if (ts.isObjectBindingPattern(pattern)) { + if (el.dotDotDotToken !== undefined) found = true; // object rest of a tracked receiver + if (el.propertyName !== undefined && ts.isComputedPropertyName(el.propertyName) && staticKeyText(el.propertyName) === null) { + found = true; // indeterminate computed key (unchanged A2 predicate, at any depth) + } + } + if (ts.isObjectBindingPattern(el.name) || ts.isArrayBindingPattern(el.name)) scanReqResBindingPattern(el.name); + } + }; + + // RULE A3 (createServer OPTIONS literal — MECH-1 disposition). Node reads `options.IncomingMessage` / + // `options.ServerResponse` with an ordinary property GET and constructs the supplied class with + // the LIVE connection socket / the request, so the options literal is a socket/request-capability + // delivery surface. Each ObjectLiteralElementLike is dispositioned by its FINITE AST KIND and its + // KEY only — the element VALUE / body / RHS is never inspected, and no prototype is traversed: + // - SpreadAssignment `{ ...x }` → DENY: an unsupported (value-flow) shape at the privileged + // boundary; nothing is claimed about `x`. + // - PropertyAssignment whose NON-COMPUTED identifier/string name is `__proto__` → DENY + // regardless of value: this is the one literal form that sets the options PROTOTYPE, so + // Node's ordinary read walks into a caller-controlled object. Computed `['__proto__']`, + // shorthand `{ __proto__ }`, a `__proto__` accessor, and a `__proto__` concise method all + // define an ORDINARY own property (never the prototype) and stay allowed. + // - an INDETERMINATE computed key (runtime / ambient / call-result / resource abort), on ANY + // element kind → DENY: a key that MIGHT be a reserved name at runtime must not pass by being + // unresolvable at a privileged boundary. + // - a RESOLVED `IncomingMessage` / `ServerResponse` key on a data property (PropertyAssignment / + // ShorthandPropertyAssignment) or a GETTER → DENY (the already-supported delivering kinds). + // A SET-only accessor reads as `undefined` (Node falls back to its own default) and a concise + // METHOD is not constructable (`new` throws), so the reserved-key setter/method forms deliver + // no usable constructor and stay allowed; a numeric/bigint/benign key is harmless; an exact-set + // test never matches a superstring key (`IncomingMessageLimit`). + // Keys are resolved by the SAME `staticKeyText`/`sockResolveKey` machinery as every other SOCK key. + const scanCreateServerOptions = (options: ts.ObjectLiteralExpression): void => { + for (const prop of options.properties) { + if (ts.isSpreadAssignment(prop)) { + found = true; // unsupported shape at the privileged boundary + continue; + } + if (ts.isShorthandPropertyAssignment(prop)) { + if (CONSTRUCTOR_INJECTION_OPTIONS.has(prop.name.text)) found = true; + continue; + } + // PropertyAssignment / GetAccessorDeclaration / SetAccessorDeclaration / MethodDeclaration + let name: string | null; + if (ts.isComputedPropertyName(prop.name)) { + const key = sockResolveKey(prop.name.expression, checker, sockMemo); + if (key.kind === 'indeterminate') { + found = true; // fail-closed: an unresolvable key at the privileged boundary + continue; + } + name = key.kind === 'resolved' ? key.value : null; // NotCapability: provably not a reserved name + } else { + name = staticKeyText(prop.name); + if (ts.isPropertyAssignment(prop) && name === '__proto__') found = true; // prototype-setting data key + } + if ( + name !== null && + CONSTRUCTOR_INJECTION_OPTIONS.has(name) && + (ts.isPropertyAssignment(prop) || ts.isGetAccessorDeclaration(prop)) + ) { + found = true; + } + } + }; + + // Pass 1 — MECH-1 createServer ARGUMENT disposition (ROOT-FAMILY-CS-ARGUMENT-RESOLUTION + Day-7 + // fail-closed boundary), and RULE A2 support (collect the request/response parameter symbols). + // Every argument of a proven createServer call/new is first normalized through the EXISTING + // bounded `resolveCreateServerArgument` (transparent wrappers, unique-`const` spines, a unique + // `FunctionDeclaration`) and then classified by FINITE AST SHAPE — nothing else: + // - FUNCTION-LIKE (ArrowFunction / FunctionExpression / FunctionDeclaration) → the request + // listener: a direct identifier parameter is tracked as a req/res symbol; a destructured + // parameter PATTERN is walked by `scanReqResBindingPattern` (object rest / indeterminate key + // fail closed), and its socket-named elements are RULE A (c) binding elements as before. + // - OBJECT LITERAL → the options object: `scanCreateServerOptions`. + // - ANY OTHER SHAPE → DENY. A `let`/`var` binding, a parameter, a default parameter, a call + // result, a member/element access, a `new` expression (class instance, Proxy), a conditional, + // a spread argument, an ambient binding, a primitive literal, … — we make NO claim about what + // the shape EVALUATES to; it is rejected solely because an unsupported shape is being supplied + // to a privileged createServer capability boundary. This replaces the former "stays outside the + // proof" allowance for these shapes (the frozen positive model denied only what it could + // prove; the Day-7 boundary fails closed on what it cannot). No value-flow analysis is + // introduced: the resolver is unchanged, and an unresolvable argument simply never becomes a + // function-like or object-literal node. + const reqResSymbols = new Set(); + const disposeCreateServerArgument = (arg: ts.Expression): void => { + const spine: ts.VariableDeclaration[] = []; + const source = resolveCreateServerArgument(arg, spine); + // Day-7 F1 — an AMBIENT or BODILESS source is NOT a supported shape. A `declare function listener` + // (or `export declare function`, a `declare global`/`declare namespace` member, a `declare const + // h = …` initializer) and a bodiless overload signature emit NO runtime value in this file, so + // the runtime binding the emitted `createServer(listener)` reaches is whatever the global + // environment supplies — its parameters are not the parameters of the code that will run. The + // resolved node is dispositioned by the EXISTING ambient predicate (`isInAmbientContext`, walks + // the `declare` modifier up the ancestors) and the structural `body === undefined` test for a + // FunctionDeclaration (an ArrowFunction / FunctionExpression always carries a body); both are + // purely syntactic — no body analysis, no value flow — and the shape falls to the same + // fail-closed unsupported-shape branch below. + if (isInAmbientContext(source) || (ts.isFunctionDeclaration(source) && source.body === undefined)) { + found = true; // ambient / bodiless source at the privileged createServer boundary + return; + } + if (ts.isArrowFunction(source) || ts.isFunctionExpression(source) || ts.isFunctionDeclaration(source)) { + for (const param of source.parameters) { + if (ts.isIdentifier(param.name)) { + const symbol = checker.getSymbolAtLocation(param.name); + if (symbol !== undefined) reqResSymbols.add(symbol); + } else { + scanReqResBindingPattern(param.name); + } + } + return; + } + if (ts.isObjectLiteralExpression(source)) { + // Day-7 MECH-1 const-spine confinement: a literal reached through one or more `const` hops is a + // supported source only while every spine binding stays confined to the spine and the boundary + // (see `optionsSpineIsConfined`); an inline literal (empty spine) has no binding to escape. + if (spine.length > 0 && !optionsSpineIsConfined(spine)) { + found = true; // the options object may have been mutated / its identity escaped: fail closed + return; + } + scanCreateServerOptions(source); + return; + } + found = true; // unsupported argument shape at the privileged createServer boundary + }; + const collect = (node: ts.Node): void => { + if (isCreateServerCall(node)) { + for (const arg of node.arguments ?? []) disposeCreateServerArgument(arg); + } + ts.forEachChild(node, collect); + }; + ts.forEachChild(sourceFile, collect); + + // Whether an expression is a tracked req/res RECEIVER: the tracked parameter identifier itself + // (unchanged), or — MECH-2 (Day-7 convergence) — a bounded STATIC MEMBER CHAIN rooted at one: + // dotted / optional-chained property hops (`res.req`, `req.headers`) and element hops whose key + // RESOLVES statically through the shared bounded `sockResolveKey` (`res['req']`, `res['r' + 'eq']`, + // `const k = 'req'; res[k]`), through the transparent wrappers `binderUnwrap` strips. This closes + // `res.req[k]` (ServerResponse exposes its IncomingMessage as `.req`) so RULE A2's fail-closed + // disposition follows the static chain. It is purely syntactic and finite (each step descends one + // AST level of ONE expression; no declaration is followed): an alias (`const r = res.req; r[k]`), a + // call in the chain (`res.getHeader(x)[k]`), an array wrapper, or a spread copy is NOT a chain and + // stays at the frozen boundary. An INDETERMINATE element hop stops the chain — that inner access + // is itself rejected by RULE A2 when its own receiver is tracked, so nothing is lost by stopping. + const receiverIsReqRes = (expr: ts.Expression): boolean => { + let e = binderUnwrap(expr); + for (;;) { + if (ts.isPropertyAccessExpression(e)) { + e = binderUnwrap(e.expression); + continue; + } + if (ts.isElementAccessExpression(e)) { + if (sockResolveKey(e.argumentExpression, checker, sockMemo).kind !== 'resolved') return false; + e = binderUnwrap(e.expression); + continue; + } + break; + } + if (!ts.isIdentifier(e)) return false; + const symbol = checker.getSymbolAtLocation(e); + return symbol !== undefined && reqResSymbols.has(symbol); + }; + + // META-MUTATION CIRCUIT BREAKER — whether an expression is a PROVEN PRIVILEGED TARGET, decided ONLY + // through the EXISTING bounded roots (no new alias graph / taint / call graph / value flow): + // (C/D) a tracked req/res root or its bounded static member chain (`receiverIsReqRes`); OR + // (A) a DIRECT `http.createServer(…)` call / `new` result (`isCreateServerCall`); OR + // (B) that result reached through the SAME bounded unique-`const` alias spine the createServer + // argument resolver uses — `binderUnwrap` transparent wrappers plus `netUniqueConstDecl` + // (binder identity, exactly one `const` declaration), iterated so `const b = a; const a = + // http.createServer(…)` resolves, with an explicit hop cap and a visited-declaration set for + // termination. + // Every OTHER target — a `let`/`var`/parameter/property, a call result (`getServer()`), a spread, + // an ambient binding, an alias cycle — is NOT proven privileged and stays OUTSIDE the finite proof + // (allowed here; the receiver-independent NAME bans still see a syntactic `server.emit`). This is + // bounded local NORMALIZATION scoped to the meta-mutation boundary, NOT value flow: the resolved + // node's value/body/constructor is never read. + const targetIsPrivileged = (expr: ts.Expression): boolean => { + if (receiverIsReqRes(expr)) return true; + let cur: ts.Expression = binderUnwrap(expr); + const seen = new Set(); + for (let hops = 0; hops < CS_ARG_RESOLVE_HOP_CAP; hops++) { + if (isCreateServerCall(cur)) return true; + if (!ts.isIdentifier(cur)) return false; + const decl = netUniqueConstDecl(cur, checker); + if (decl === null || decl.initializer === undefined) return false; // let/var/param/property/no-init: stop + if (seen.has(decl)) return false; // alias cycle: stop in finite time + seen.add(decl); + cur = binderUnwrap(decl.initializer); // unique `const` hop (const→const spine included) + } + return false; + }; + + // RULE A2 (assignment-target walk — MECH-2 object-rest + nested fail-close) — the assignment twin of + // `scanReqResBindingPattern`, walked over the finite ObjectLiteral/ArrayLiteral DESTRUCTURING + // TARGET of an `=` whose right-hand side is a tracked req/res receiver. Per property: a + // SpreadAssignment `{ ...rest }` REJECTS (object rest of a tracked receiver, never propagated); a + // `socket`/`connection`/`client` key rejects; an indeterminate computed key fails closed; a static + // harmless key is allowed — byte-for-byte the former top-level rules, now also applied to a NESTED + // object-literal target (`({ req: { ...rest } } = res)`), through a nested array target, and + // through a defaulted nested target (`({ req: { ...rest } = {} } = res)`, whose target is the LEFT + // of the inner `=`). Finite: it descends only nested literal targets and terminates at the leaves. + const scanReqResAssignmentTarget = (value: ts.Expression): void => { + let target = binderUnwrap(value); + if (ts.isBinaryExpression(target) && target.operatorToken.kind === ts.SyntaxKind.EqualsToken) { + target = binderUnwrap(target.left); // a defaulted nested target: `{ … } = default` + } + if (ts.isArrayLiteralExpression(target)) { + for (const el of target.elements) scanReqResAssignmentTarget(el); + return; + } + if (!ts.isObjectLiteralExpression(target)) return; + for (const prop of target.properties) { + if (ts.isSpreadAssignment(prop)) { + found = true; // object rest of a tracked receiver + } else if (ts.isShorthandPropertyAssignment(prop)) { + if (SOCKET_CAPABILITY_NAMES.has(prop.name.text)) found = true; + } else if (ts.isPropertyAssignment(prop)) { + const key = staticKeyText(prop.name); + if (ts.isComputedPropertyName(prop.name)) { + if (key === null || SOCKET_CAPABILITY_NAMES.has(key)) found = true; + } else if (key !== null && SOCKET_CAPABILITY_NAMES.has(key)) { + found = true; + } + scanReqResAssignmentTarget(prop.initializer); + } + } + }; + // RULE A (c, assignment parity — DELIVERY/REGISTRAR members ONLY) — the assignment-AST twin of the + // RULE A (c) per-binding-element key check, walked over the finite ObjectLiteralExpression + // destructuring TARGET of an `=`, but DELIBERATELY SCOPED to the receiver-independent + // delivery/registrar-member family (`SOCKET_DELIVERY_MEMBERS`: on/once/addListener/ + // prependListener/prependOnceListener/setTimeout), NOT the full `STATIC_SOCKET_ACQUISITION_NAMES` + // set. `({ on: register } = server)` extracts the registrar exactly like `const { on: register } + // = server`, but its target is an ObjectLiteralExpression (Shorthand/PropertyAssignment), not a + // BindingElement, so RULE A (c) never saw it. Each target KEY is resolved by the SAME + // binder-aware machinery A (c) uses — a plain/quoted key is its own text (`staticKeyText`), a + // computed key (`{ ['o'+'n']: … }`, `{ [k]: … }`) folds off the binder (`sockResolveKey`) — and a + // RESOLVED delivery/registrar name REJECTS receiver-independently (the `.on` / `const { on }` + // registrar bans are already receiver-independent, so no server identity is tracked). The + // socket/connection CAPABILITY names are intentionally EXCLUDED here: their assignment-extraction + // policy stays the receiver-sensitive req/res-bound RULE A2 assignment branch below (the accepted + // D3-CX-CODEX-ASSIGN "no global broadening" invariant — `({ socket } = unrelatedObject)` and + // `({ socket } = server)` remain allowed). Recurses ONLY into a nested ObjectLiteralExpression + // VALUE — the assignment twin of a nested binding pattern (`({ a: { on } } = x)`) — terminating at + // the finite AST depth with NO value flow, alias following, or receiver tracking. An INDETERMINATE + // computed key is NOT globally failed closed here (only a Resolved delivery name rejects); the + // req/res A2 branch below retains its own fail-closed behavior. + // Day-7 F2: the receiver-independent set is `RECEIVER_INDEPENDENT_ASSIGNMENT_NAMES` = the delivery + // members plus `constructor` (family iii), so `({ constructor: S } = server)` is rejected exactly like + // `({ on: register } = server)`; the socket-value exclusion above is unchanged. + const scanSocketAssignmentTarget = (target: ts.ObjectLiteralExpression): void => { + for (const prop of target.properties) { + if (ts.isShorthandPropertyAssignment(prop)) { + if (RECEIVER_INDEPENDENT_ASSIGNMENT_NAMES.has(prop.name.text)) found = true; + } else if (ts.isPropertyAssignment(prop)) { + let name: string | null = null; + if (ts.isComputedPropertyName(prop.name)) { + const key = sockResolveKey(prop.name.expression, checker, sockMemo); + if (key.kind === 'resolved') name = key.value; + } else { + name = staticKeyText(prop.name); + } + if (name !== null && RECEIVER_INDEPENDENT_ASSIGNMENT_NAMES.has(name)) found = true; + const value = binderUnwrap(prop.initializer); + if (ts.isObjectLiteralExpression(value)) scanSocketAssignmentTarget(value); + } + } + }; + const visit = (node: ts.Node): void => { + // RULE A3 — CONSTRUCTOR-INJECTION options on a permitted createServer call/new. The check runs + // ONLY where `isCreateServerCall` proved the node:http createServer capability, so an unrelated + // `foo.createServer({ IncomingMessage })` and any object literal OUTSIDE a createServer call are + // untouched. MECH-1 (Day-7): the whole createServer argument disposition — the options-literal + // reservation (property-kind parity, `__proto__`, spread, indeterminate key), the listener + // parameter collection, and the fail-closed unsupported-shape boundary — is decided ONCE in + // pass 1 (`disposeCreateServerArgument` / `scanCreateServerOptions` above), for the call AND + // `new` forms, so this pass no longer re-inspects createServer arguments. + // RULE A (a) — GLOBAL dotted socket-acquisition NAME: `.socket`/`.connection` or a delivery + // member `.on`/`.once`/`.addListener`/`.prependListener`/`.prependOnceListener`/ + // `.setTimeout` (optional chaining included), any receiver, any position. This is the node + // that closes the indirect-registrar family (F2): the inner `server.on` inside + // `server.on.call(...)` / `server.on.apply(...)` / `server.on.bind(...)` / + // `Reflect.apply(server.on, …)` / `const m = server.on`, and the `server.setTimeout` + // receiver of `server.setTimeout(t, socket => …)` (F1), are each a property access + // visited here regardless of how (or whether) they are later invoked. + if (ts.isPropertyAccessExpression(node) && STATIC_SOCKET_ACQUISITION_NAMES.has(node.name.text)) found = true; + // RULE A (b) — GLOBAL static-computed socket-acquisition NAME `['socket']`/`['connection']`/ + // `['on']`/…/`['setTimeout']` (any receiver, e.g. `server['on']('connection', …)`), + // resolved by BINDER identity (`sockResolveKey` → `netResolveKey`), never by identifier + // text. Resolved(socket name) REJECTS on any receiver; Resolved(other) and NotCapability are + // non-matches; Indeterminate falls to the unchanged RULE A2 fail-closed branch. + if (ts.isElementAccessExpression(node)) { + const arg = node.argumentExpression; + const key = sockResolveKey(arg, checker, sockMemo); + // RULE A extension: a binder-resolvable socket-acquisition NAME, ANY receiver + // (`server['o' + 'n']`, `const k='on'; server[k]`, `` server[`socket`] ``), even with an + // unrelated shadowing `const k` in another scope — binder identity pins the exact key. + if (key.kind === 'resolved') { + if (STATIC_SOCKET_ACQUISITION_NAMES.has(key.value)) found = true; + } + // RULE A2 (unchanged): an INDETERMINATE (runtime / resource-abort) computed key on a + // createServer req/res param fails closed. The trigger stays keyed on `!isStringLiteralLike`, + // so A2's fail-closed surface is byte-for-byte what it was — the resolver only ADDS global + // name rejections; a NotCapability key never reaches here. + else if (key.kind === 'indeterminate' && !ts.isStringLiteralLike(arg) && receiverIsReqRes(node.expression)) { + found = true; + } + } + // RULE A (c) — GLOBAL socket-acquisition NAME destructuring in any object binding pattern + // (variable, parameter, nested, callback): `{ socket }` / `{ connection }` / + // `{ on }` / `{ setTimeout }`, including the renamed `{ on: h }` / `{ socket: s }` form + // (the static source KEY is what is banned, never the local binding name). + if (ts.isBindingElement(node) && ts.isObjectBindingPattern(node.parent)) { + const keyNode = node.propertyName ?? node.name; + // RULE A extension: a COMPUTED destructuring key (`{ ['o'+'n']: h }`, `{ [k]: h }` with + // `const k='on'`) is resolved by the SAME binder-aware `sockResolveKey` used for member + // access — binder identity, never identifier text — so a shadowing same-text `const` + // elsewhere cannot flip it. A plain identifier/string key is its own literal text + // (`staticKeyText`, purely syntactic). The separate A2 destructuring branch below is + // untouched, so its fail-closed surface is preserved. + let name: string | null = null; + if (ts.isComputedPropertyName(keyNode)) { + const key = sockResolveKey(keyNode.expression, checker, sockMemo); + if (key.kind === 'resolved') name = key.value; + } else { + name = staticKeyText(keyNode); + } + if (name !== null && STATIC_SOCKET_ACQUISITION_NAMES.has(name)) found = true; + } + // RULE A2 (destructuring) — an INDETERMINATE computed binding key destructured DIRECTLY + // from a createServer request/response handler parameter fails closed (bound to the + // req/res initializer symbol identity, not globally). A statically resolvable key is + // unaffected here: a `socket`/`connection` name is already rejected by RULE A (c), a + // harmless name (`['method']`) is allowed. `const { ['sock'+'et']: s } = req` and + // `const { [key]: s } = req` are rejected; `const { [key]: s } = unrelated` is not. + if ( + ts.isVariableDeclaration(node) && + ts.isObjectBindingPattern(node.name) && + node.initializer !== undefined && + receiverIsReqRes(node.initializer) + ) { + // MECH-2 (Day-7): the same top-level predicate, walked through the finite pattern by + // `scanReqResBindingPattern` — plus the object-rest rejection — at every nesting depth. + scanReqResBindingPattern(node.name); + } + // RULE A2 (assignment) — an object DESTRUCTURING ASSIGNMENT `({ socket: s } = req)` reads + // the property off the req/res param exactly like a declaration destructuring, but the + // target is an ObjectLiteralExpression (PropertyAssignment / ShorthandPropertyAssignment), + // not a binding pattern, so RULE A (c) / RULE A2 above do not see it. This is bound to the + // req/res initializer symbol identity (NOT global — an unrelated `({ socket: x } = obj)` + // stays allowed): a `socket`/`connection` key is rejected, an indeterminate computed key + // fails closed, and a static harmless key (`method`/`url`) is allowed. + if ( + ts.isBinaryExpression(node) && + node.operatorToken.kind === ts.SyntaxKind.EqualsToken && + receiverIsReqRes(node.right) + ) { + // MECH-2 (Day-7): the same top-level rules, walked through the finite literal target by + // `scanReqResAssignmentTarget` — plus the object-rest (SpreadAssignment) rejection — at every + // nesting depth. + scanReqResAssignmentTarget(node.left); + } + // RULE A (c, assignment parity — DELIVERY/REGISTRAR members ONLY) — receiver-INDEPENDENT + // delivery/registrar-member NAME in an object DESTRUCTURING ASSIGNMENT target: + // `({ on: register } = server)` / `({ on } = server)` / `({ ['o'+'n']: h } = server)` / nested + // `({ a: { on } } = server)`. The target is an ObjectLiteralExpression (Shorthand/ + // PropertyAssignment), NOT a BindingElement, so RULE A (c) above does not see it, and the RULE + // A2 assignment branch just above is bound to req/res receivers + SOCKET_CAPABILITY_NAMES only. + // `scanSocketAssignmentTarget` closes the verified registrar-extraction gap by applying the + // receiver-independent SOCKET_DELIVERY_MEMBERS ban to the assignment AST — server identity is + // never tracked, exactly as `.on` / `const { on }` are already receiver-independent. The + // socket/connection CAPABILITY names are DELIBERATELY not broadened here: their assignment + // extraction stays the req/res-bound RULE A2 branch above (accepted D3-CX-CODEX-ASSIGN + // invariant). Only a RESOLVED delivery name rejects; an indeterminate computed key is left to + // the req/res-bound RULE A2 branch above (NOT globally failed closed). + if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken) { + const target = binderUnwrap(node.left); + if (ts.isObjectLiteralExpression(target)) scanSocketAssignmentTarget(target); + } + // RULE B (PROMOTED into RULE A's name family) — the event registrars and `setTimeout` are + // now banned by NAME at RULE A (a)/(b)/(c) above, receiver- and position-independent, so + // the former call-callee-only registrar ban is fully subsumed (a called `server.on(...)` + // is caught by its `.on` property access, exactly like an uncalled `const m = server.on`). + // Anchoring on the member NAME rather than the call callee is precisely what closes the + // `.call`/`.apply`/`.bind`/`Reflect.apply`/method-extraction indirection (F2) and the + // `setTimeout` delivery surface (F1) — with NO witness-specific `.call`/`.apply`/`.bind` + // blacklist and NO event-name enumeration. No separate call-shaped rule remains. + // MECH-2 (Day-7) — `arguments` delivery. A non-arrow function receives its request/response + // objects through the `arguments` object as well as through its named parameters, so a + // listener (or any function the listener reaches) can recover `req`/`res` with `arguments[0]` + // without a tracked parameter ever appearing. In this strict ESM host `arguments` has no + // legitimate use, so a value-position Identifier whose text is exactly `arguments` is a + // WHOLE-FILE syntactic reservation — no alias / data-flow analysis (`const a = arguments`, + // `const [req] = arguments`, an arrow capturing it, `Reflect.get(arguments, 0)` are all caught at + // the read of the identifier itself). The two existing name-position guards are BOTH required — + // `isValueReference` (RC/HA: declaration names, class members, type-position members, + // object-literal keys, member names) and `isBinderValueReference` (NET: additionally the + // destructuring SOURCE key `{ arguments: a }` and import/export specifiers) — so a property + // NAME (`obj.arguments`), an object-literal / destructuring KEY, a declaration name, a + // type-position member, and a string literal are all kept out: none of those read the binding. + // An unrelated `arguments.length` is an accepted policy false positive (no host/cockpit source + // uses `arguments`). + if (ts.isIdentifier(node) && node.text === 'arguments' && isValueReference(node) && isBinderValueReference(node)) { + found = true; + } + // MECH-2 (Day-7) — STRUCTURAL reflective acquisition. A direct call to the FREE, unshadowed + // built-in `Reflect.get` / `Reflect.getOwnPropertyDescriptor` / `Object.getOwnPropertyDescriptor` + // (`isSockReflectiveReadCallee`: base identifier binder-proven unshadowed, member name resolved + // statically to the approved API — dotted or bounded static element form) reads a property by a + // KEY ARGUMENT instead of by a syntactic member name, so RULE A's name ban never sees it. The + // property-key argument is classified by the SAME bounded `sockResolveKey`: a key that RESOLVES + // to a static socket-acquisition name DENIES on ANY target (`Reflect.get(server, 'on')`, exactly + // as `server['on']` does); an INDETERMINATE key DENIES only on a tracked req/res target (RULE A2 + // parity — `Reflect.get(unrelated, k)` stays at the frozen boundary); a resolved harmless key + // (`'method'`) is allowed. A spread in the target/key position is an unsupported argument shape + // at this reflective boundary and fails closed. This is NOT the rejected broad "deny any call + // argument whose string equals socket/on/…" rule: `res.setHeader('connection', 'close')`, + // `map.get('connection')`, `log('socket')`, a user-shadowed `Reflect`/`Object`, and an aliased + // `const R = Reflect` (frozen) are untouched — only the recognized free-builtin callee STRUCTURE + // is inspected, and nothing is followed after the acquisition. + if (ts.isCallExpression(node) && isSockReflectiveReadCallee(node.expression, checker, sourceFile)) { + const target = node.arguments[0]; + const keyArg = node.arguments[1]; + if ((target !== undefined && ts.isSpreadElement(target)) || (keyArg !== undefined && ts.isSpreadElement(keyArg))) { + found = true; // unsupported argument shape at the reflective boundary + } else if (keyArg !== undefined) { + const key = sockResolveKey(keyArg, checker, sockMemo); + if (key.kind === 'resolved') { + if (STATIC_SOCKET_ACQUISITION_NAMES.has(key.value)) found = true; + } else if (key.kind === 'indeterminate' && target !== undefined && receiverIsReqRes(target)) { + found = true; + } + } + } + // META-MUTATION CIRCUIT BREAKER — a direct call to a FREE, unshadowed built-in META-MUTATION API + // (`isMetaMutationCallee`: `Object.defineProperty`/`defineProperties`/`assign`/`setPrototypeOf`, + // `Reflect.defineProperty`/`setPrototypeOf`/`set` — dotted or bounded static element form) INSTALLS + // behavior on / re-parents its FIRST argument as a first-class value, so no `server.emit` member + // node exists for RULE A's name ban to see. The disposition is TARGET-GATED, never key-gated: + // DENY iff the target argument is a PROVEN PRIVILEGED TARGET (`targetIsPrivileged` — a createServer + // result, direct or via the bounded unique-`const` spine, or a tracked req/res root). The member + // KEY / descriptor / source / prototype is NEVER inspected — that is the entire circuit-breaker: + // `Object.defineProperty(server, 'futureMemberX', …)` denies exactly like `…(server, 'emit', …)`, + // with no `emit`/`on`/`constructor` enumeration. An UNPRIVILEGED target + // (`Object.defineProperty(config, 'emit', …)`, `Object.assign({}, { emit: 1 })`), a non-mutating + // API outside the inventory (`Object.freeze(server)`), reading FROM a server into a fresh object + // (`Object.assign({}, server)`), and an unprovable target (`Object.defineProperty(getServer(), …)` + // — value flow / arbitrary return, outside the finite proof) all stay allowed. A SPREAD in the + // target position (`Object.defineProperty(...args)`) is unsupported target propagation and stays + // outside the proof. Because the gate is on the target, an indeterminate KEY at a privileged + // target is already fail-closed (the target alone denies); no key resolution is needed. + if (ts.isCallExpression(node) && isMetaMutationCallee(node.expression, checker, sourceFile)) { + const target = node.arguments[0]; + if (target !== undefined && !ts.isSpreadElement(target) && targetIsPrivileged(target)) { + found = true; + } + } + ts.forEachChild(node, visit); + }; + ts.forEachChild(sourceFile, visit); + + // ========================================================================================== + // MECH-D3 (structural provenance/position model) — the AUTHORIZED D3 repair, layered OVER the + // receiver-independent name bans above as defense-in-depth (this pass only ADDS denials, so no + // historical DENY is weakened). The invariant is a single positive rule: + // + // PROVEN PRIVILEGED TARGET + NON-ALLOWLISTED OPERATION = DENY. + // + // A proven SERVER / REQUEST / RESPONSE may occur ONLY in its finite approved operations + // (`D3_*_MEMBERS`, plus alias / factory-return / propagation / neutral positions); EVERY other + // operation denies STRUCTURALLY, without this code ever naming the dangerous member. So + // `server.futureMemberX = …` denies exactly as `server.emit = …` would — key/name-independently, + // with no deny list to grow. Provenance is decided ONLY through the EXISTING bounded roots — the + // createServer call/new result, the createServer listener parameters, unique non-exported `const` + // aliases, approved local SERVER factories, and one-hop local-function parameter propagation — + // with binder identity and finite hop/visit bounds. NO module graph, taint, unrestricted call + // graph, or runtime interpretation: a resolved node's value/body is never interpreted, only its + // syntactic POSITION is classified. + // ========================================================================================== + { + type D3Class = 'SERVER' | 'REQUEST' | 'RESPONSE'; + const D3_ITERATION_CAP = 64; + + // A binder-proven LOCAL runnable function an identifier denotes — a bodied, non-ambient + // `FunctionDeclaration`, or a unique `const` bound to an Arrow/FunctionExpression — else null. + // NEVER an import, ambient declaration, method, or member: a propagation/factory callee must be + // in-file code whose parameters and returns this pass can inspect. This is the ONLY callee shape + // a proven target may be passed to (besides the free reflective-read shape); every other callee + // (`Object.defineProperty`, an aliased mutator, a comma/element callee) is unresolvable here and + // the argument denies. + const resolveLocalFunction = ( + callee: ts.Expression, + ): ts.FunctionDeclaration | ts.ArrowFunction | ts.FunctionExpression | null => { + const u = binderUnwrap(callee); + if (!ts.isIdentifier(u)) return null; + const symbol = checker.getSymbolAtLocation(u); + const decls = symbol?.declarations; + if (decls === undefined || decls.length !== 1) return null; + const decl = decls[0]; + if (decl === undefined) return null; + if (ts.isFunctionDeclaration(decl)) { + return decl.body !== undefined && !isInAmbientContext(decl) ? decl : null; + } + const constDecl = netUniqueConstDecl(u, checker); + if (constDecl === null || constDecl.initializer === undefined || isInAmbientContext(constDecl)) return null; + const init = binderUnwrap(constDecl.initializer); + return ts.isArrowFunction(init) || ts.isFunctionExpression(init) ? init : null; + }; + + // SHARED PROPAGATION ELIGIBILITY (the single source of truth for BOTH phases). The ONLY parameter + // shape into which Phase A propagates a proven authority class, and therefore the ONLY shape at + // which Phase B may allow a proven argument to escape into a local callee: a PRESENT, NON-REST, + // plain-IDENTIFIER parameter. A rest parameter, an object/array destructuring pattern, or a missing + // parameter (fewer parameters than arguments) is UNSUPPORTED — it never receives the class in + // Phase A, so allowing the call would let the argument escape the structural policy. Both phases + // consult this rule, so the Phase-A propagation set and the Phase-B allow set cannot disagree: a + // call is NOT permitted merely because `resolveLocalFunction` succeeds. Returns the parameter's + // binding identifier when supported (Phase A keys provenance by its symbol), else null. + const supportedPropagationParamName = (param: ts.ParameterDeclaration | undefined): ts.Identifier | null => + param !== undefined && param.dotDotDotToken === undefined && ts.isIdentifier(param.name) ? param.name : null; + + // Whether a VariableDeclaration is a UNIQUE NON-EXPORTED `const` with an identifier name — the + // sole approved alias / createServer-result binding shape. A `let`/`var`, an exported binding, or + // a destructuring name is NOT confined and denies (an unsupported result shape / identity escape). + const isConfinedConstBinding = (vd: ts.VariableDeclaration): boolean => { + if (!ts.isIdentifier(vd.name)) return false; + const list = vd.parent as ts.Node | undefined; + if (list === undefined || !ts.isVariableDeclarationList(list) || (list.flags & ts.NodeFlags.Const) === 0) { + return false; + } + const statement = list.parent as ts.Node | undefined; + if ( + statement !== undefined && + ts.isVariableStatement(statement) && + ts.getModifiers(statement)?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) === true + ) { + return false; + } + return true; + }; + + const provenClasses = new Map>(); + const serverFactories = new Set(); + const addClass = (symbol: ts.Symbol | undefined, cls: D3Class): boolean => { + if (symbol === undefined) return false; + let set = provenClasses.get(symbol); + if (set === undefined) { + set = new Set(); + provenClasses.set(symbol, set); + } + if (set.has(cls)) return false; + set.add(cls); + return true; + }; + + // A value expression that PRODUCES a proven SERVER: a createServer call/new, a reference to a + // proven SERVER binding, or a call to a proven local SERVER factory. (Depends on the current + // fixpoint state, so it is re-evaluated each iteration.) + const exprIsServerValue = (e: ts.Expression): boolean => { + const u = binderUnwrap(e); + if (isCreateServerCall(u)) return true; + if (ts.isIdentifier(u)) { + const symbol = checker.getSymbolAtLocation(u); + return symbol !== undefined && provenClasses.get(symbol)?.has('SERVER') === true; + } + if (ts.isCallExpression(u)) { + const fn = resolveLocalFunction(u.expression); + return fn !== null && serverFactories.has(fn); + } + return false; + }; + + // The classes an initializer confers on its binding: SERVER for a server value; the alias + // source's classes for a proven-binding identifier (so `const r = req` carries REQUEST). + const classesOfValue = (e: ts.Expression): readonly D3Class[] => { + const out: D3Class[] = []; + if (exprIsServerValue(e)) out.push('SERVER'); + const u = binderUnwrap(e); + if (ts.isIdentifier(u)) { + const symbol = checker.getSymbolAtLocation(u); + const set = symbol !== undefined ? provenClasses.get(symbol) : undefined; + if (set !== undefined) { + for (const c of set) if (!out.includes(c)) out.push(c); + } + } + return out; + }; + + // The finite OWN-body return values of a function (its arrow expression body, or the argument of + // every `return` NOT inside a nested function/class scope). No value flow — the returned nodes are + // only tested for the server-value SHAPE by the factory rule below. + const ownReturnValues = ( + fn: ts.FunctionDeclaration | ts.ArrowFunction | ts.FunctionExpression, + ): ts.Expression[] => { + if (ts.isArrowFunction(fn) && !ts.isBlock(fn.body)) return [fn.body]; + const body = fn.body; + if (body === undefined || !ts.isBlock(body)) return []; + const out: ts.Expression[] = []; + const walk = (n: ts.Node): void => { + if ( + ts.isFunctionDeclaration(n) || + ts.isFunctionExpression(n) || + ts.isArrowFunction(n) || + ts.isMethodDeclaration(n) || + ts.isGetAccessorDeclaration(n) || + ts.isSetAccessorDeclaration(n) || + ts.isClassDeclaration(n) || + ts.isClassExpression(n) + ) { + return; // a nested scope: its returns are not this function's + } + if (ts.isReturnStatement(n) && n.expression !== undefined) out.push(n.expression); + ts.forEachChild(n, walk); + }; + ts.forEachChild(body, walk); + return out; + }; + + // Visit every function-like createServer listener argument's parameter list (through the EXISTING + // bounded `resolveCreateServerArgument`, so a named / const-bound handler is covered exactly like + // a direct inline one). Object-literal (options) arguments are skipped. + const eachListenerParams = (visit: (params: ts.NodeArray) => void): void => { + const walk = (n: ts.Node): void => { + if (isCreateServerCall(n)) { + for (const arg of n.arguments ?? []) { + const src = resolveCreateServerArgument(arg); + if (ts.isArrowFunction(src) || ts.isFunctionExpression(src) || ts.isFunctionDeclaration(src)) { + visit(src.parameters); + } + } + } + ts.forEachChild(n, walk); + }; + ts.forEachChild(sourceFile, walk); + }; + + // ---- Phase A — bounded fixpoint over provenance (roots, aliases, factories, propagation). ---- + // TERMINATION: provenance facts (a symbol gaining an authority class; a node becoming a factory) + // are added MONOTONICALLY and never removed, over a FINITE symbol × {SERVER,REQUEST,RESPONSE} + // lattice, so the loop strictly ascends and must reach a fixed point in finitely many steps; + // `D3_ITERATION_CAP` bounds the work regardless. The loop has an EXPLICIT terminal state: + // CONVERGED (an iteration adds no fact → `break` with `converged = true`) or EXHAUSTED (the + // cap is consumed while a fact was still added on the final iteration → `converged` stays + // false). A reverse-ordered forwarding chain advances one authority hop per pass, so a chain + // longer than the cap EXHAUSTS with its deepest parameter still unclassified. + let converged = false; + for (let iter = 0; iter < D3_ITERATION_CAP; iter++) { + // A holder object (not a bare `let`), so the flag mutated inside the nested walk callbacks is + // read as `boolean` after those calls return — a plain `let` would be flow-narrowed to its + // `false` initializer (closure mutations are invisible to the checker) and the loop guard + // would read as an always-true `!changed`. + const flags = { changed: false }; + // (a) createServer listener parameters: parameter 0 → REQUEST, parameter 1 → RESPONSE (a plain + // identifier, non-rest; destructured/rest parameters stay with the existing RULE A2 machinery). + eachListenerParams((params) => { + params.forEach((param, index) => { + if (index > 1 || param.dotDotDotToken !== undefined || !ts.isIdentifier(param.name)) return; + if (addClass(checker.getSymbolAtLocation(param.name), index === 0 ? 'REQUEST' : 'RESPONSE')) { + flags.changed = true; + } + }); + }); + // (b) confined-`const` bindings: a server value, or an alias of a proven binding. + const walkBindings = (n: ts.Node): void => { + if (ts.isVariableDeclaration(n) && n.initializer !== undefined && isConfinedConstBinding(n)) { + const symbol = checker.getSymbolAtLocation(n.name); + for (const cls of classesOfValue(n.initializer)) if (addClass(symbol, cls)) flags.changed = true; + } + ts.forEachChild(n, walkBindings); + }; + ts.forEachChild(sourceFile, walkBindings); + // (c) approved local SERVER factories: a local function whose (≥1) own returns are ALL server values. + const walkFactories = (n: ts.Node): void => { + if ( + (ts.isFunctionDeclaration(n) || ts.isFunctionExpression(n) || ts.isArrowFunction(n)) && + !serverFactories.has(n) && + n.body !== undefined && + !isInAmbientContext(n) + ) { + const returns = ownReturnValues(n); + if (returns.length > 0 && returns.every((r) => exprIsServerValue(r))) { + serverFactories.add(n); + flags.changed = true; + } + } + ts.forEachChild(n, walkFactories); + }; + ts.forEachChild(sourceFile, walkFactories); + // (d) one-hop propagation: a proven target passed to a local function taints that parameter. + const walkPropagation = (n: ts.Node): void => { + if (ts.isCallExpression(n)) { + const fn = resolveLocalFunction(n.expression); + if (fn !== null) { + n.arguments.forEach((arg, index) => { + if (ts.isSpreadElement(arg)) return; + const classes = classesOfValue(arg); + if (classes.length === 0) return; + // Propagate ONLY into a SUPPORTED parameter shape (the same rule Phase B allows on). + const name = supportedPropagationParamName(fn.parameters[index]); + if (name === null) return; // rest / destructuring / missing parameter: no propagation + const symbol = checker.getSymbolAtLocation(name); + for (const cls of classes) if (addClass(symbol, cls)) flags.changed = true; + }); + } + } + ts.forEachChild(n, walkPropagation); + }; + ts.forEachChild(sourceFile, walkPropagation); + if (!flags.changed) { + converged = true; + break; + } + } + // CONVERGENCE GATE (F2): if the fixpoint EXHAUSTED the cap while still adding provenance, the map + // is known-partial — a privileged parameter may remain unclassified and its capability read + // would escape Phase B — so fail CLOSED rather than classify against an incomplete map. + if (!converged) found = true; + + // ---- Phase B — classify every USE of a proven target by its structural POSITION. ---- + const memberReadAllowed = (name: string, cls: D3Class): boolean => + cls === 'REQUEST' && D3_REQUEST_READ_MEMBERS.has(name); + + const isAssignmentOp = (kind: ts.SyntaxKind): boolean => + kind >= ts.SyntaxKind.FirstAssignment && kind <= ts.SyntaxKind.LastAssignment; + + // Classify a member/element operation on a proven target by its parent form: an assignment + // (only `RESPONSE.statusCode = NumericLiteral` allowed), compound/update/delete (write → DENY), + // a call (the class call allowlist), or a plain read (the class read allowlist). The member NAME + // is compared ONLY against the small positive allowlists — never a deny list. + const memberOpAllowed = ( + access: ts.PropertyAccessExpression | ts.ElementAccessExpression, + name: string, + cls: D3Class, + ): boolean => { + const top = outermostTransparentWrapper(access); + const gp = top.parent as ts.Node | undefined; + if (gp === undefined) return false; + if (ts.isBinaryExpression(gp) && gp.left === top && isAssignmentOp(gp.operatorToken.kind)) { + return ( + gp.operatorToken.kind === ts.SyntaxKind.EqualsToken && + cls === 'RESPONSE' && + D3_RESPONSE_WRITE_MEMBERS.has(name) && + ts.isNumericLiteral(binderUnwrap(gp.right)) + ); + } + if (ts.isPostfixUnaryExpression(gp) && gp.operand === top) return false; // update ++/-- + if ( + ts.isPrefixUnaryExpression(gp) && + (gp.operator === ts.SyntaxKind.PlusPlusToken || gp.operator === ts.SyntaxKind.MinusMinusToken) && + gp.operand === top + ) { + return false; // update ++/-- + } + if (ts.isDeleteExpression(gp) && gp.expression === top) return false; // delete + if (ts.isCallExpression(gp) && gp.expression === top) { + if (cls === 'SERVER') return D3_SERVER_CALL_MEMBERS.has(name); + if (cls === 'RESPONSE') return D3_RESPONSE_CALL_MEMBERS.has(name); + return D3_REQUEST_READ_MEMBERS.has(name); // REQUEST: `.method`/`.url` read, then the string is called + } + return memberReadAllowed(name, cls); // a plain read + }; + + // Whether the operation applied to a proven-target occurrence `w` (already unwrapped through + // transparent wrappers) is in the approved set for `cls`. + const opAllowedForClass = (w: ts.Node, cls: D3Class): boolean => { + const p = w.parent as ts.Node | undefined; + if (p === undefined) return false; + // neutral / terminal positions (any class) + if (ts.isExpressionStatement(p) && p.expression === w) return true; + if (ts.isVoidExpression(p) && p.expression === w) return true; + if (ts.isTypeOfExpression(p) && p.expression === w) return true; + // factory return / arrow expression body (SERVER only) + if (ts.isReturnStatement(p) && p.expression === w) return cls === 'SERVER'; + if (ts.isArrowFunction(p) && p.body === w) return cls === 'SERVER'; + // a variable binding: a unique non-exported `const` IDENTIFIER binding is the approved alias / + // result form; a BINDING-PATTERN name is a DESTRUCTURING whose per-key disposition off a + // tracked REQUEST/RESPONSE is already owned by the existing RULE A2 / A(c) machinery + // (static-harmless allowed, socket-name / object-rest / indeterminate-key denied there), so + // this pass defers it; a SERVER destructured here is an unsupported result-shape escape. + if (ts.isVariableDeclaration(p) && p.initializer === w) { + if (ts.isIdentifier(p.name)) return isConfinedConstBinding(p); + return cls !== 'SERVER'; + } + // a destructuring ASSIGNMENT `({ … } = req)` / `[ … ] = req` off a tracked REQUEST/RESPONSE is + // likewise owned by the existing RULE A2 assignment machinery; a SERVER (or an assignment + // into a plain identifier/member target) is an escape. + if (ts.isBinaryExpression(p) && p.operatorToken.kind === ts.SyntaxKind.EqualsToken && p.right === w) { + const lhs = binderUnwrap(p.left); + if (ts.isObjectLiteralExpression(lhs) || ts.isArrayLiteralExpression(lhs)) return cls !== 'SERVER'; + return false; + } + // member / element operation + if (ts.isPropertyAccessExpression(p) && p.expression === w) return memberOpAllowed(p, p.name.text, cls); + if (ts.isElementAccessExpression(p) && p.expression === w) { + const key = sockResolveKey(p.argumentExpression, checker, sockMemo); + if (key.kind !== 'resolved') return false; // runtime / oversized-static key on a proven target: DENY + return memberOpAllowed(p, key.value, cls); + } + // call / new argument — the CALLEE RULE + if (ts.isCallExpression(p) && p.expression !== w && p.arguments.some((a) => a === w)) { + // (1) the existing binder-proven free reflective-read shape, subject to key policy (arg0 only) + if (isSockReflectiveReadCallee(p.expression, checker, sourceFile) && p.arguments[0] === w) { + const keyArg = p.arguments[1]; + if (keyArg === undefined || ts.isSpreadElement(keyArg)) return false; + const key = sockResolveKey(keyArg, checker, sockMemo); + return key.kind === 'resolved' && memberReadAllowed(key.value, cls); + } + // (2) an approved propagating local function — allowed ONLY if the corresponding parameter + // position is a SUPPORTED shape (the identical `supportedPropagationParamName` rule Phase A + // propagates on). A rest / destructuring / missing parameter never carried the class in + // Phase A, so letting the argument escape here would defeat the structural policy: DENY. + // A call is NEVER allowed merely because the callee resolves locally. + const fn = resolveLocalFunction(p.expression); + if (fn !== null) { + const argIndex = p.arguments.indexOf(w as ts.Expression); + if (argIndex >= 0 && supportedPropagationParamName(fn.parameters[argIndex]) !== null) return true; + } + return false; // any other callee / unsupported parameter: DENY (Object.defineProperty, aliased mutator, comma/element callee, rest/destructuring parameter, …) + } + if (ts.isNewExpression(p) && (p.arguments ?? []).some((a) => a === w)) return false; + return false; // container element / object value / spread / export / any other escape + }; + + const classifyUse = (node: ts.Node, classes: Iterable): void => { + const w = outermostTransparentWrapper(node); + for (const cls of classes) { + if (!opAllowedForClass(w, cls)) { + found = true; + return; + } + } + }; + + const SERVER_ONLY: readonly D3Class[] = ['SERVER']; + const classifyWalk = (n: ts.Node): void => { + // THIS RESERVATION — a whole-file ThisExpression is rejected: `this` can re-derive a privileged + // receiver, and the real host has no ThisExpression. No `this` provenance is modeled. + if (n.kind === ts.SyntaxKind.ThisKeyword) found = true; + if (isCreateServerCall(n)) { + classifyUse(n, SERVER_ONLY); // the createServer result value's own position + } else if (ts.isCallExpression(n)) { + const fn = resolveLocalFunction(n.expression); + if (fn !== null && serverFactories.has(fn)) classifyUse(n, SERVER_ONLY); // a factory-call result value + } else if (ts.isIdentifier(n) && isBinderValueReference(n)) { + // A shorthand `{ server }` reads the VALUE binding through `getShorthandAssignmentValueSymbol` + // (the name identifier alone resolves to the fresh property symbol); every other value + // reference resolves directly. The shorthand's POSITION then denies as a container escape. + const parent = n.parent as ts.Node | undefined; + const symbol = + parent !== undefined && ts.isShorthandPropertyAssignment(parent) && parent.name === n + ? checker.getShorthandAssignmentValueSymbol(parent) + : checker.getSymbolAtLocation(n); + const set = symbol !== undefined ? provenClasses.get(symbol) : undefined; + if (set !== undefined && set.size > 0) classifyUse(n, set); + } + ts.forEachChild(n, classifyWalk); + }; + ts.forEachChild(sourceFile, classifyWalk); + } + + return found; +}; + +// F1/P2 — recursive binder-identity resolution for the NET free-global member check +// (D3-CX-POLICY-NET-BIND). `collectStringConsts` keys a resolved constant by identifier TEXT and, +// while resolving a `const`'s initializer, folds EVERY identifier leaf by text too. For the NET +// free-global member path that is unsound at two levels: (1) the element-access key identifier +// itself may resolve to a DIFFERENT same-text binding, and (2) even a genuine same-symbol key +// (`const key = Infinity`) can carry a value that was folded from an out-of-scope +// `const Infinity = 'fetch'` INSIDE its initializer — binding identity is lost during initializer +// resolution. Identifier text equality is never binding identity; the compiler binder is authority. +// +// The NET path therefore resolves strings itself, straight off the binder, WITHOUT consulting the +// text-keyed const map: every Identifier hop — the key and every identifier reached while resolving +// a collected initializer — must resolve (via `checker.getSymbolAtLocation`) to a single +// `const = ` declaration of matching text, and that declaration's initializer is then +// resolved under the SAME discipline. A hop that resolves to a different same-text binding, to no +// in-file binding (a free global under `noLib`), or to a non-const/duplicate binding, demotes the +// whole chain to unresolved (`null`), left to the fail-closed runtime-code guard. String literals +// and `+`-folds of literals carry no identifier and resolve as before, so a genuine same-symbol +// const chain (`const a = 'fetch'; const key = a; globalThis[key]`) still folds and is still +// rejected. `seen` bounds recursion: a const-initializer cycle terminates at `null`. Bounded to +// NET: `collectStringConsts` / `staticStringOf` / `memberNameOf` are unchanged, so the RC/HA +// text-only structural policy is not touched. + +// The single `const = ` declaration a binder-resolved identifier denotes, else null: +// the symbol has exactly one declaration, that declaration is a `const` VariableDeclaration whose +// name text matches the reference. Text equality alone never qualifies — a different-scope or +// free-global reference resolves to a different symbol (or none) and is rejected here. +const netUniqueConstDecl = (id: ts.Identifier, checker: ts.TypeChecker): ts.VariableDeclaration | null => { + const symbol = checker.getSymbolAtLocation(id); + if (symbol === undefined || symbol.declarations === undefined || symbol.declarations.length !== 1) { + return null; + } + const decl = symbol.declarations[0]; + if (decl === undefined || !ts.isVariableDeclaration(decl) || !ts.isIdentifier(decl.name) || decl.name.text !== id.text) { + return null; + } + const list = decl.parent; + return ts.isVariableDeclarationList(list) && (list.flags & ts.NodeFlags.Const) !== 0 ? decl : null; +}; + +// The statically-provable string an expression denotes for the NET path, resolved entirely by the +// binder: a string literal or substitution-free template, a `+`-fold of such, or an Identifier the +// binder proves is a unique `const` whose initializer resolves the same way — recursively, with +// binder identity required at EVERY hop. +// +// Two DISTINCT bookkeeping structures, both keyed by DECLARATION identity (never identifier text), +// so a shared initializer subtree is resolved once instead of exponentially (P2 memoization): +// - `seen`: the declarations on the CURRENT resolution path, so an initializer cycle yields +// `null` in finite time (a declaration re-entered before it completes is a cycle). +// - `memo`: the COMPLETED result of each declaration (a `string`, or `null` for unresolved), +// so a second reference to the same declaration — a diamond/doubling chain such as +// `aN = aN-1 + aN-1` — reads the cached result rather than recomputing its whole subtree. +// `memo.has` distinguishes a cached `null` from "not yet computed", so a genuine unresolved +// result never silently becomes a static value. The cache is per top-level key resolution and +// keyed by the binder-proven declaration node, so a result for one declaration is NEVER reused +// for a different same-text declaration in another scope. +// +// A per-member `budget` records the identifier hops ONE member resolution spends and CAPS them: +// memoization keeps the hop count linear in the number of const declarations, so a doubling chain +// costs O(N), never O(2^N). Because resolution is synchronous, an un-memoized regression would +// block the event loop rather than trip a test timeout — the cap converts that into a fast, +// deterministic failure. `netResolveVisits` accumulates hops across ALL member resolutions of ONE +// `usesOutboundNetwork` traversal (reset at its start), so a test can prove the CROSS-member cost +// is O(N + M) — a chain reused by M accesses is resolved once — not O(M × N). Both bounds sit far +// above anything real host/Cockpit source or any genuine const chain produces. +const NET_RESOLVE_VISIT_CAP = 200_000; + +// NET member-key CLASSIFICATION (D3-CX-POLICY-NET-KEY, frozen DDR). At a binder-verified FREE +// global receiver the computed member key is resolved to one of three states, and ONLY these three +// — a member name is never demoted to a bare `null` that silently means "allow": +// +// Resolved(string) — the key statically denotes exactly this string (a literal, a +// substitution-free template, a bounded `+`-fold, or a binder-proven unique +// `const` chain). DENY iff the string is a NETWORK_GLOBAL_NAMES member. +// NotCapability — the key is PROVABLY not a capability name: a `+`-fold whose result exceeds +// the longest capability name. Because `+` only ADDS characters, no further +// concatenation can shrink it to `fetch`/`WebSocket`, so this is a sound +// ALLOW — and it lets the fold stop BEFORE materializing the oversized string. +// Indeterminate — the key cannot be statically pinned down: a runtime/ambient/mutated/ +// duplicate/undeclared/shadowed-differently identifier, an initializer cycle, +// a non-string expression form, OR a RESOURCE-BOUND ABORT (depth/visit +// ceiling). At a free-global receiver this is DENIED fail-closed — a computed +// key that MIGHT be `fetch`/`WebSocket` at runtime must not slip past NET by +// being unresolvable. (Converting an abort to "allow" was the P1 egress hole: +// a deep `const shared='fetch'; nK='' + nK-1; globalThis[nN](...)` aborted and +// was allowed.) NET no longer relies on the runtime-code guard to catch these. +// +// The identifier-ALIAS spine (`const a = b; const b = c; …`) resolves ITERATIVELY, so a genuine +// long chain to `fetch`/`WebSocket` still classifies as Resolved (DENY), and a long benign chain as +// Resolved-non-capability (ALLOW), rather than aborting into a false positive. Only a non-identifier +// initializer (literal / `+`-fold) recurses, bounded by `NET_RESOLVE_DEPTH_CAP` (deterministic, far +// below the native stack limit and far above any real host/Cockpit source or genuine capability +// fold); the visit ceiling bounds total work. Both bounds raise `NetResolveAbort`, which maps to +// Indeterminate. Resolved and NotCapability are context-INDEPENDENT (intrinsic to a declaration's +// own initializer) and are memoized; the abort is context-DEPENDENT (it depends on the depth a +// declaration is reached from) and is thrown, so it is NEVER memoized — a declaration aborted on a +// deep path still resolves when later reached from a shallow one. (A deeply nested LITERAL `+` +// expression overflows the shared `ts.forEachChild` AST walk every detector uses, before this +// resolver is reached — a pre-existing whole-file traversal limit, out of scope here.) +const NET_RESOLVE_DEPTH_CAP = 2_000; +// The longest network-capability name (`WebSocket` = 9). Derived from the policy set so it stays +// correct if the set changes; a `+`-fold whose result exceeds it is provably NotCapability. +const MAX_NETWORK_MEMBER_LENGTH = Math.max(...[...NETWORK_GLOBAL_NAMES].map((name) => name.length)); +let netResolveVisits = 0; + +// The three-state key classification. `Resolved` carries the exact string; the other two are +// nullary. A member key is exactly one of these — never an ambiguous `null`. +type NetKey = + | { readonly kind: 'resolved'; readonly value: string } + | { readonly kind: 'notCapability' } + | { readonly kind: 'indeterminate' }; +const NET_NOT_CAPABILITY: NetKey = { kind: 'notCapability' }; +const NET_INDETERMINATE: NetKey = { kind: 'indeterminate' }; + +// A resource-bound abort (recursion depth or visit budget). Thrown (not returned) so no partially +// resolved declaration on the aborted path is memoized, and caught at the member boundary where it +// becomes an Indeterminate key (fail-closed DENY at a free-global receiver), never a crash. +class NetResolveAbort extends Error {} + +const netResolveKey = ( + node: ts.Expression, + checker: ts.TypeChecker, + seen: Set, + memo: Map, + budget: { spent: number }, + depth: number, + // The longest name any consumer of THIS resolution compares against: `MAX_NETWORK_MEMBER_LENGTH` + // for a network member key, `MAX_GLOBAL_RECEIVER_LENGTH` for a self-reference hop key (a hop can + // fold to `globalThis`, 10 > the 9-char network ceiling, so the ceiling must travel with the + // call). `memo` is keyed by declaration AND is caller-scoped to a single `maxLen`, so a + // NotCapability decided under one ceiling can never be read back under the other. + maxLen: number, +): NetKey => { + if (depth > NET_RESOLVE_DEPTH_CAP) throw new NetResolveAbort(); // resource bound: not memoized + const n = unwrapExpr(node); + if (ts.isStringLiteralLike(n)) return { kind: 'resolved', value: n.text }; + if (ts.isBinaryExpression(n) && n.operatorToken.kind === ts.SyntaxKind.PlusToken) { + const left = netResolveKey(n.left, checker, seen, memo, budget, depth + 1, maxLen); + if (left.kind === 'indeterminate') return NET_INDETERMINATE; + if (left.kind === 'notCapability') return NET_NOT_CAPABILITY; // already too long; `+` only grows it + const right = netResolveKey(n.right, checker, seen, memo, budget, depth + 1, maxLen); + if (right.kind === 'indeterminate') return NET_INDETERMINATE; + if (right.kind === 'notCapability') return NET_NOT_CAPABILITY; + // Bound OUTPUT before allocating `left + right`: an oversized result is provably NotCapability, + // so no exponential intermediate is ever materialized. + if (left.value.length + right.value.length > maxLen) return NET_NOT_CAPABILITY; + return { kind: 'resolved', value: left.value + right.value }; + } + // A SUBSTITUTED template `` `o${'n'}` `` denotes the same static string as the `+`-fold and folds the + // SAME way: the head text, then for every span the resolved substitution EXPRESSION followed by that + // span's literal text — each substitution resolved through THIS resolver (string literal / `+`-fold / + // unique-const identity), reusing the same depth/visit/length bounds. A substitution-free template is + // a `NoSubstitutionTemplateLiteral` already handled by the `isStringLiteralLike` branch above; a + // `TemplateExpression` always carries ≥1 span. Length is bounded BEFORE each concat (an oversized + // result is provably NotCapability, so no oversized intermediate is materialized). If ANY substitution + // is indeterminate (runtime / mutable / ambient / unresolvable), the whole template is Indeterminate — + // no runtime coercion, no `toString`, no value flow. + if (ts.isTemplateExpression(n)) { + let value = n.head.text; + if (value.length > maxLen) return NET_NOT_CAPABILITY; + for (const span of n.templateSpans) { + const part = netResolveKey(span.expression, checker, seen, memo, budget, depth + 1, maxLen); + if (part.kind === 'indeterminate') return NET_INDETERMINATE; + if (part.kind === 'notCapability') return NET_NOT_CAPABILITY; // already too long; concat only grows it + if (value.length + part.value.length > maxLen) return NET_NOT_CAPABILITY; + value += part.value; + if (value.length + span.literal.text.length > maxLen) return NET_NOT_CAPABILITY; + value += span.literal.text; + } + return { kind: 'resolved', value }; + } + if (ts.isIdentifier(n)) { + // Resolve an identifier-ALIAS spine (`const a = b; const b = c; …`) ITERATIVELY, so a chain of + // any length consumes O(1) native stack. Every declaration on the spine denotes the SAME key, so + // the completed classification is recorded for all of them at once. A hop that is not a + // binder-proven unique `const` (runtime/ambient/mutated/duplicate/undeclared/shadowed) or a + // cycle is Indeterminate. Only a non-identifier initializer (literal / `+`-fold) recurses. + const spine: ts.Declaration[] = []; + let cur: ts.Identifier = n; + let key: NetKey = NET_INDETERMINATE; + for (;;) { + netResolveVisits += 1; // cumulative across the whole usesOutboundNetwork traversal (test evidence) + budget.spent += 1; // per-member ceiling + if (budget.spent > NET_RESOLVE_VISIT_CAP) throw new NetResolveAbort(); // resource bound: not memoized + const decl = netUniqueConstDecl(cur, checker); + if (decl === null || decl.initializer === undefined) { + key = NET_INDETERMINATE; // not a binder-proven unique const → unknown key + break; + } + if (memo.has(decl)) { + key = memo.get(decl) ?? NET_INDETERMINATE; // completed classification + break; + } + if (seen.has(decl)) { + key = NET_INDETERMINATE; // re-entry before completion: cycle (do not cache) + break; + } + seen.add(decl); + spine.push(decl); + const init = unwrapExpr(decl.initializer); + if (ts.isIdentifier(init)) { + cur = init; // alias hop: iterate, no recursion + continue; + } + key = netResolveKey(init, checker, seen, memo, budget, depth + 1, maxLen); // literal / `+`-fold + break; + } + // Reached only on a NON-abort return (a thrown NetResolveAbort unwinds past this, leaving the + // aborted-path declarations UNcached). The completed classification is context-independent, so + // caching it for every alias on the spine is sound. + for (const d of spine) { + seen.delete(d); + memo.set(d, key); + } + return key; + } + return NET_INDETERMINATE; // any other expression form (call, number, conditional, …): unknown key +}; + +// Classify the member key of a property/element access for the NET path. A property name is read +// directly (always Resolved); an element-access key is resolved by `netResolveKey` (binder identity +// at every hop), never by identifier text alone. The completed-classification `memo` is SHARED +// across every member resolution of one `usesOutboundNetwork` traversal, so a const chain reused by +// many accesses is resolved once (O(N + M), not O(M × N)); a fresh `seen`/`budget` per call keeps +// active-path cycle detection and the per-member ceiling local. A resource-bound abort becomes +// Indeterminate (fail-closed at a free-global receiver). +const netMemberKey = ( + node: ts.PropertyAccessExpression | ts.ElementAccessExpression, + checker: ts.TypeChecker, + memo: Map, +): NetKey => { + if (ts.isPropertyAccessExpression(node)) return { kind: 'resolved', value: node.name.text }; + try { + return netResolveKey(node.argumentExpression, checker, new Set(), memo, { spent: 0 }, 0, MAX_NETWORK_MEMBER_LENGTH); + } catch (error) { + if (error instanceof NetResolveAbort) return NET_INDETERMINATE; + throw error; + } +}; + +// The longest global-receiver name (`globalThis` = 10). A self-reference hop key can fold to it, +// so the hop resolver's `+`-fold ceiling must reach 10 — one above the 9-char network member +// ceiling — or `globalThis['global' + 'This']` would be pruned as NotCapability before resolving. +const MAX_GLOBAL_RECEIVER_LENGTH = Math.max(...[...GLOBAL_RECEIVER_NAMES].map((name) => name.length)); + +// DDR-NET-STATIC-KEY-PARITY (F1) — the self-reference HOP name an element-access key denotes, +// resolved by the SAME bounded binder resolver used for network member keys (`netResolveKey`), +// never by identifier text: a string literal / substitution-free template, a `+`-fold, or a +// binder-proven unique `const` chain, capped at `MAX_GLOBAL_RECEIVER_LENGTH`. Only a Resolved key +// yields a hop name; NotCapability, Indeterminate, and a resource-bound abort all become null (NOT +// a hop), so a genuinely runtime key (`globalThis[runtimeKey]`) is never folded into a self-hop and +// stays outside the frozen boundary. A FRESH memo isolates the wider hop ceiling from the shared +// network-member memo (a declaration classified under one ceiling is never read back under the +// other). Binder/shadowing authority over the base identifier stays with `isFreeGlobalReceiver`. +const netHopName = (node: ts.Expression, checker: ts.TypeChecker): string | null => { + try { + const key = netResolveKey(node, checker, new Set(), new Map(), { spent: 0 }, 0, MAX_GLOBAL_RECEIVER_LENGTH); + return key.kind === 'resolved' ? key.value : null; + } catch (error) { + if (error instanceof NetResolveAbort) return null; + throw error; + } +}; + +// DDR-NET-STATIC-KEY-PARITY (F2) — classify a destructuring property KEY (declaration binding +// element OR assignment ObjectLiteral property) with the SAME three-state discipline as a member +// key: a plain identifier / string-literal name is its own text (Resolved); a computed key +// (`{ ['fe' + 'tch']: f }`, `{ [k]: f }`) is resolved off the binder by `netResolveKey` at the +// network-member ceiling into Resolved / NotCapability / Indeterminate. At a proven free-global +// receiver the caller applies the frozen NET policy — Resolved(capability) and Indeterminate DENY, +// Resolved(other) / NotCapability ALLOW — so an indeterminate destructuring key fails closed +// exactly like an indeterminate member key. Any other name form (numeric/private) is Indeterminate. +const netDestructuringKey = (keyNode: ts.Node, checker: ts.TypeChecker): NetKey => { + if (ts.isComputedPropertyName(keyNode)) { + try { + return netResolveKey(keyNode.expression, checker, new Set(), new Map(), { spent: 0 }, 0, MAX_NETWORK_MEMBER_LENGTH); + } catch (error) { + if (error instanceof NetResolveAbort) return NET_INDETERMINATE; + throw error; + } + } + if (ts.isIdentifier(keyNode) || ts.isStringLiteralLike(keyNode)) return { kind: 'resolved', value: keyNode.text }; + return NET_INDETERMINATE; +}; + +// DDR-NET-STATIC-KEY-PARITY (SOCK, F1) — SOCK's static socket acquisition-key resolution is +// CONSOLIDATED onto the SAME bounded binder-aware `netResolveKey` the NET member/hop/destructuring +// keys use, replacing the scope-insensitive whole-file text-keyed `collectStringConsts`/ +// `staticStringOf` path. Binder identity is required at the key AND at every const-initializer hop, +// so the exact occurrence is resolved by the TypeScript binder: an unrelated sibling/nested same-text +// declaration in another scope can no longer make a statically-known key unresolved — the fail-open +// that let `const k='on'; server[k](...)` escape when an unrelated `const k='noop'` existed elsewhere +// (the whole-file collector saw two `k` bindings and demoted the key to UNKNOWN even though the +// binder pins the exact `k='on'`). The socket ceiling is the longest name in +// STATIC_SOCKET_ACQUISITION_NAMES (`prependOnceListener` = 19), DERIVED from the set so a `+`-fold to +// any socket name (`'set' + 'Timeout'`) resolves rather than being pruned as NotCapability. A fresh +// `seen`/`budget` per call keeps active-path cycle detection and the per-key hop ceiling local; the +// caller's shared per-traversal `memo` (declaration-keyed, socket-ceiling-scoped) keeps a reused +// chain O(N + M); a resource-bound abort is caught and becomes Indeterminate. Mechanism +// CONSOLIDATION only — no new resolver, alias/capability/taint propagation, assignment following, or +// new policy family; NET's own resolver and the shared RC/HA text helpers are untouched. +const MAX_SOCKET_MEMBER_LENGTH = Math.max(...[...STATIC_SOCKET_ACQUISITION_NAMES].map((name) => name.length)); +const sockResolveKey = ( + node: ts.Expression, + checker: ts.TypeChecker, + memo: Map, +): NetKey => { + try { + return netResolveKey(node, checker, new Set(), memo, { spent: 0 }, 0, MAX_SOCKET_MEMBER_LENGTH); + } catch (error) { + if (error instanceof NetResolveAbort) return NET_INDETERMINATE; + throw error; + } +}; + +// MECH-2 (Day-7 convergence, SOCK reflective acquisition) — the FINITE family of free built-in +// reflective READ APIs that acquire a property by a KEY ARGUMENT (bypassing RULE A's syntactic member +// name): `Reflect.get`, `Reflect.getOwnPropertyDescriptor`, `Object.getOwnPropertyDescriptor`. This is a +// closed inventory keyed by base identifier; it is NOT a general reflective/value-flow model +// (`Reflect.has`/`ownKeys`, `Object.values`/`entries`/`assign`/`getOwnPropertyDescriptors`, an aliased +// `const R = Reflect`, a `.call`/`.apply`/`.bind` chain, and a wrapper all stay at the frozen boundary). +const SOCK_REFLECTIVE_READ_APIS: ReadonlyMap> = new Map([ + ['Reflect', new Set(['get', 'getOwnPropertyDescriptor'])], + ['Object', new Set(['getOwnPropertyDescriptor'])], +]); +// The longest API name (`getOwnPropertyDescriptor` = 24), DERIVED from the inventory so a static +// element-access spelling of the member (`Object['getOwnProperty' + 'Descriptor']`) folds to it rather +// than being pruned as NotCapability under the narrower socket/network ceilings. +const MAX_SOCK_REFLECTIVE_API_LENGTH = Math.max( + ...[...SOCK_REFLECTIVE_READ_APIS.values()].flatMap((names) => [...names]).map((name) => name.length), +); +// Whether a call callee is a DIRECT free built-in reflective READ member: a dotted `Reflect.get` / +// `Object.getOwnPropertyDescriptor` (optional chaining included) or a bounded static element access +// `Reflect['get']` / `Object['getOwnProperty' + 'Descriptor']`, with the member name resolved by the +// SAME `netResolveKey` machinery (binder identity, never identifier text) at the API-name ceiling, off a +// BASE identifier the binder proves UNSHADOWED — the `isFreeReflect` identifier rule reused verbatim +// (`hasLocalRuntimeShadow`): a local `const Reflect = { get() {} }` / `const Object = …` is an ordinary +// object. An aliased base (`const R = Reflect`), a runtime member key (`Reflect[k]`), or a shadowed base +// all fail this — one statically identifiable built-in member, no alias, no call/apply/bind, no wrapper. +const isSockReflectiveReadCallee = (callee: ts.Expression, checker: ts.TypeChecker, sourceFile: ts.SourceFile): boolean => { + const c = binderUnwrap(callee); + if (!ts.isPropertyAccessExpression(c) && !ts.isElementAccessExpression(c)) return false; + const base = binderUnwrap(c.expression); + if (!ts.isIdentifier(base)) return false; + const apis = SOCK_REFLECTIVE_READ_APIS.get(base.text); + if (apis === undefined) return false; + const symbol = checker.getSymbolAtLocation(base); + if (symbol !== undefined && hasLocalRuntimeShadow(symbol, sourceFile)) return false; + let member: string | null; + if (ts.isPropertyAccessExpression(c)) { + member = c.name.text; + } else { + try { + const key = netResolveKey(c.argumentExpression, checker, new Set(), new Map(), { spent: 0 }, 0, MAX_SOCK_REFLECTIVE_API_LENGTH); + member = key.kind === 'resolved' ? key.value : null; + } catch (error) { + if (!(error instanceof NetResolveAbort)) throw error; + member = null; + } + } + return member !== null && apis.has(member); +}; + +// META-MUTATION CIRCUIT BREAKER (reflective/meta-mutation sibling family) — the FINITE, closed +// inventory of free built-in META-MUTATION APIs that INSTALL behavior on / RE-PARENT a target object by +// operating on it as a first-class value (a KEY/DESCRIPTOR/SOURCE/PROTOTYPE argument), bypassing RULE +// A's syntactic member-name ban: `Object.defineProperty`/`defineProperties`/`assign`/`setPrototypeOf` +// and `Reflect.defineProperty`/`setPrototypeOf`/`set` (`Reflect.set` is the reflective property WRITE, the +// function twin of `server[key] = fn`, installing behavior under a key with no member node). +// Node invokes a server's OWN `emit('connection', socket)` +// internally, so replacing (or shadowing, via a re-parented prototype) ANY member of a privileged server +// delivers the live socket — the danger is the MUTATION of a privileged target, not a particular member +// name. This is the mutation twin of `SOCK_REFLECTIVE_READ_APIS`, keyed by base identifier the SAME way; +// like it, it is NOT a general reflective/value-flow model. Pure READS that merely EXPOSE the surface +// (`Object.getOwnPropertyDescriptors`, `Object.getPrototypeOf`, `Reflect.getPrototypeOf`) are DELIBERATELY +// excluded: their weaponization requires either NAMING the extracted member (already denied +// receiver-independently by RULE A — `getPrototypeOf(server).emit` is a `.emit` access) or VALUE FLOW +// (outside the frozen boundary); a read creates no authority, so no authority-creation reason admits it. +// Capability-REMOVING APIs (`Object.freeze`/`seal`/`preventExtensions`) are excluded for the same reason. +const META_MUTATION_APIS: ReadonlyMap> = new Map([ + ['Object', new Set(['defineProperty', 'defineProperties', 'assign', 'setPrototypeOf'])], + ['Reflect', new Set(['defineProperty', 'setPrototypeOf', 'set'])], +]); +// The longest API name (`defineProperties` = 16), DERIVED from the inventory so a static element-access +// spelling of the member (`Object['definePro' + 'perties']`) folds to it rather than being pruned as +// NotCapability under the narrower socket/network ceilings. +const MAX_META_MUTATION_API_LENGTH = Math.max( + ...[...META_MUTATION_APIS.values()].flatMap((names) => [...names]).map((name) => name.length), +); +// Whether a call callee is a DIRECT free built-in META-MUTATION member: a dotted +// `Object.defineProperty` / `Reflect.setPrototypeOf` (optional chaining included) or a bounded static +// element access `Object['assign']` / `Object['definePro' + 'perties']`, with the member name resolved by +// the SAME `netResolveKey` machinery (binder identity, never identifier text) at the API-name ceiling, +// off a BASE identifier the binder proves UNSHADOWED — the `isSockReflectiveReadCallee` structure reused +// verbatim (`hasLocalRuntimeShadow`): a local `const Object = { defineProperty() {} }` is an ordinary +// object. An aliased base (`const O = Object`), a runtime member key (`Object[k]`), or a shadowed base all +// fail this — one statically identifiable built-in member, no alias, no call/apply/bind, no wrapper. +const isMetaMutationCallee = (callee: ts.Expression, checker: ts.TypeChecker, sourceFile: ts.SourceFile): boolean => { + const c = binderUnwrap(callee); + if (!ts.isPropertyAccessExpression(c) && !ts.isElementAccessExpression(c)) return false; + const base = binderUnwrap(c.expression); + if (!ts.isIdentifier(base)) return false; + const apis = META_MUTATION_APIS.get(base.text); + if (apis === undefined) return false; + const symbol = checker.getSymbolAtLocation(base); + if (symbol !== undefined && hasLocalRuntimeShadow(symbol, sourceFile)) return false; + let member: string | null; + if (ts.isPropertyAccessExpression(c)) { + member = c.name.text; + } else { + try { + const key = netResolveKey(c.argumentExpression, checker, new Set(), new Map(), { spent: 0 }, 0, MAX_META_MUTATION_API_LENGTH); + member = key.kind === 'resolved' ? key.value : null; + } catch (error) { + if (!(error instanceof NetResolveAbort)) throw error; + member = null; + } + } + return member !== null && apis.has(member); +}; + +// DDR-NET-STATIC-KEY-PARITY (nested authority) — the self-reference HOP NAME a DESTRUCTURING key +// denotes, resolved by the SAME bounded binder resolver used for a member-access hop (`netHopName`) +// but reading a binding/object-literal KEY node instead of an element-access argument: a plain +// identifier / string-literal key is its own text, a computed key (`{ ['global' + 'This']: … }`, +// `{ [k]: … }` with `const k = 'globalThis'`) folds off the binder, capped at +// `MAX_GLOBAL_RECEIVER_LENGTH` (10 — a hop can fold to `globalThis`, one above the 9-char network +// member ceiling). Only a Resolved key yields a hop name; NotCapability, Indeterminate, and a +// resource-bound abort all become null (NOT a hop), so a genuinely runtime intermediate key never +// starts/continues authority and stays outside the frozen boundary. This is the destructuring twin +// of `netHopName`; it introduces NO new resolver — it reuses `netResolveKey` exactly like +// `netDestructuringKey`, only at the wider self-hop ceiling that `MAX_NETWORK_MEMBER_LENGTH` (9) +// would prune a 10-char self-reference name out of. +const netDestructuringHopName = (keyNode: ts.Node, checker: ts.TypeChecker): string | null => { + if (ts.isComputedPropertyName(keyNode)) { + try { + const key = netResolveKey(keyNode.expression, checker, new Set(), new Map(), { spent: 0 }, 0, MAX_GLOBAL_RECEIVER_LENGTH); + return key.kind === 'resolved' ? key.value : null; + } catch (error) { + if (error instanceof NetResolveAbort) return null; + throw error; + } + } + if (ts.isIdentifier(keyNode) || ts.isStringLiteralLike(keyNode)) return keyNode.text; + return null; +}; + +// DDR-NET-STATIC-KEY-PARITY (nested authority) — whether free-global-receiver authority REACHES an +// object binding PATTERN, resolved STRUCTURALLY over the finite binding AST (never value flow): +// - a TOP-LEVEL pattern (its container is the variable declaration) has authority iff the +// declaration's initializer is a binder-proven free global receiver (`isFreeGlobalReceiver`); +// - a NESTED pattern (its container is an OUTER binding element) has authority iff (a) the outer +// element's own pattern already has authority AND (b) the outer element's KEY resolves — through +// the SAME binder-aware key machinery (`netDestructuringHopName`) — to a global self-reference +// name in `GLOBAL_RECEIVER_NAMES`, exactly as `globalThis.globalThis` re-denotes the real global. +// Authority therefore CONTINUES only through a proven self-hop key; an intermediate key that is a +// non-self-hop name (`foo`), indeterminate, or a non-namespace receiver stops it (allow), mirroring +// how a shadowed/runtime hop demotes a member-access receiver. Finite: the recursion strips one +// binding-pattern nesting level per step and terminates at the variable declaration; it walks only +// binding-pattern parent links, never a value graph. +const objectPatternHasFreeGlobalAuthority = ( + pattern: ts.ObjectBindingPattern, + checker: ts.TypeChecker, + sourceFile: ts.SourceFile, +): boolean => { + const container = pattern.parent; + if (ts.isVariableDeclaration(container)) { + return container.initializer !== undefined && isFreeGlobalReceiver(container.initializer, checker, sourceFile); + } + if (ts.isBindingElement(container) && ts.isObjectBindingPattern(container.parent)) { + if (!objectPatternHasFreeGlobalAuthority(container.parent, checker, sourceFile)) return false; + const hop = netDestructuringHopName(container.propertyName ?? container.name, checker); + return hop !== null && GLOBAL_RECEIVER_NAMES.has(hop); + } + return false; +}; + +// DDR-NET-REFLECT-GET (F1) — whether `Reflect` is the binder-proven UNSHADOWED built-in intrinsic. +// Reflect is a DISTINCT intrinsic, deliberately NOT a member of `GLOBAL_RECEIVER_NAMES`: it is +// recognized only here, and only when the binder resolves the identifier to NO local runtime value +// binding, exactly the `isFreeGlobalReceiver` identifier rule reused verbatim. A local +// `const Reflect = { get() { … } }` (or any runtime binding of the name) is therefore an ordinary +// object and its `.get` is NOT the built-in — identifier text alone never qualifies. +const isFreeReflect = (expr: ts.Expression, checker: ts.TypeChecker, sourceFile: ts.SourceFile): boolean => { + const e = binderUnwrap(expr); + if (!ts.isIdentifier(e) || e.text !== 'Reflect') return false; + const symbol = checker.getSymbolAtLocation(e); + if (symbol === undefined) return true; + return !hasLocalRuntimeShadow(symbol, sourceFile); +}; + +// DDR-NET-REFLECT-GET (F1) — whether a call callee is a DIRECT built-in `Reflect.get` member: a +// dotted `Reflect.get` or a static-string element access `Reflect['get']` / `Reflect['g' + 'et']`, +// with the `get` name resolved by the SAME `netMemberKey` machinery (never identifier text), off a +// binder-proven unshadowed `Reflect`. A runtime/aliased member key (`Reflect[k]`), an alias of +// Reflect (`const R = Reflect; R.get(…)`), or a shadowed Reflect all fail this — no alias, no +// call/apply/bind, no wrapper: one statically identifiable built-in member. +const isBuiltinReflectGetCallee = (callee: ts.Expression, checker: ts.TypeChecker, sourceFile: ts.SourceFile): boolean => { + const c = binderUnwrap(callee); + if (!ts.isPropertyAccessExpression(c) && !ts.isElementAccessExpression(c)) return false; + const memberKey = netMemberKey(c, checker, new Map()); + if (!(memberKey.kind === 'resolved' && memberKey.value === 'get')) return false; + return isFreeReflect(c.expression, checker, sourceFile); +}; + +// DDR-NET-REFLECT-GET (F1) — classify the KEY ARGUMENT of a `Reflect.get(receiver, key)` call with +// the SAME three-state discipline as a computed member key: a string literal / substitution-free +// template / `+`-fold resolves to its value; an identifier resolves through the binder-proven unique +// `const` chain (`const k = 'fetch'; Reflect.get(globalThis, k)`); a runtime/ambient/undeclared key +// (`declare const runtimeKey; Reflect.get(globalThis, runtimeKey)`) or a resource-bound abort is +// Indeterminate. The key is a VALUE expression (not a binding key), so — unlike `netDestructuringKey` +// — a bare identifier is resolved, never taken as its own text; this reuses `netResolveKey` exactly +// like `netMemberKey`'s element-access branch, at the network-member ceiling. +const netReflectGetKey = (keyArg: ts.Expression, checker: ts.TypeChecker): NetKey => { + try { + return netResolveKey(keyArg, checker, new Set(), new Map(), { spent: 0 }, 0, MAX_NETWORK_MEMBER_LENGTH); + } catch (error) { + if (error instanceof NetResolveAbort) return NET_INDETERMINATE; + throw error; + } +}; + +/** + * NET — reject outbound network egress, decided by TypeScript BINDER identity + * (D3-CX-POLICY-NET). Lexical binding identity — nearest visible binding, shadowing, + * restoration, for/switch/catch scope, parameter scope, named function-expression + * self-binding, and computed-name evaluation order — is delegated to the compiler binder + * via `checker.getSymbolAtLocation`, so this guard NEVER re-implements ECMAScript/ + * TypeScript scoping. The Program is in-memory, single-file, `noLib`+`noResolve`: no + * filesystem, no module resolution, no network. Policy is the positive model: runtime + * `import('node:http')` is prohibited; the node:http namespace may be used only to obtain + * `createServer` (member access or `createServer`-only destructuring) or in type + * positions; any other node:http value (`HTTP_CLIENT`) may not be referenced; the free + * network globals `fetch`/`WebSocket` are rejected when truly unbound. + */ +const usesOutboundNetwork = (source: string): boolean => { + const { checker, sourceFile } = buildBinderProgram(source); + // Free-global network members (F1/P2/P1) are classified by `netMemberKey` straight off the binder + // — identity required at the key AND at every initializer hop (see `netResolveKey`) — into + // Resolved / NotCapability / Indeterminate. At a binder-verified free-global receiver: + // Resolved(`fetch`/`WebSocket`) and Indeterminate DENY; Resolved(other) and NotCapability ALLOW. + // NET is self-contained fail-closed here — it does NOT lean on the runtime-code guard to reject an + // indeterminate computed key. + // + // ONE completed-classification memo is shared by every member resolution in THIS traversal, so a + // const chain reused across many accesses is resolved once (O(N + M), not O(M × N)). It is keyed by + // the binder's declaration nodes for this Program, so it cannot leak into another + // `usesOutboundNetwork` call. `netResolveVisits` is reset here to make the hop count observable. + const netMemo = new Map(); + netResolveVisits = 0; + let found = false; + // Branch (4) helper — walk an object-literal ASSIGNMENT target with free-global authority already + // established for THIS object literal (the top-level call is guarded by the `= ` + // receiver; a nested call is reached only through a proven self-hop key below). This is the + // assignment twin of branch (3)'s `objectPatternHasFreeGlobalAuthority` recursion: each property's + // source KEY is classified for a network capability at the network ceiling, and authority CONTINUES + // into a nested object-literal value only through a self-reference hop key (resolved at the wider + // self-hop ceiling by `netDestructuringHopName`). Finite: it descends only nested object-literal + // targets, one per pattern level, and terminates when no nested object literal remains. + const scanFreeGlobalAssignmentTarget = (target: ts.ObjectLiteralExpression): void => { + for (const prop of target.properties) { + let keyNode: ts.Node | undefined; + let valueNode: ts.Expression | undefined; + if (ts.isShorthandPropertyAssignment(prop)) { + keyNode = prop.name; + } else if (ts.isPropertyAssignment(prop)) { + keyNode = prop.name; + valueNode = prop.initializer; + } else { + continue; // spread / accessor / method — not a destructuring target property + } + const key = netDestructuringKey(keyNode, checker); + if (key.kind === 'resolved') { + if (NETWORK_GLOBAL_NAMES.has(key.value)) found = true; + } else if (key.kind === 'indeterminate') { + found = true; // fail-closed at a proven free-global receiver + } + // Authority continues into a nested object-literal target ONLY through a self-reference hop key + // (a GLOBAL_RECEIVER_NAMES name resolved at the self-hop ceiling), mirroring branch (3). + if (valueNode !== undefined) { + const nested = binderUnwrap(valueNode); + if (ts.isObjectLiteralExpression(nested)) { + const hop = netDestructuringHopName(keyNode, checker); + if (hop !== null && GLOBAL_RECEIVER_NAMES.has(hop)) scanFreeGlobalAssignmentTarget(nested); + } + } + } + }; + const visit = (node: ts.Node): void => { + // (0) a runtime dynamic `import('node:http')` is prohibited outright, in every context. + if (isDynamicNodeHttpImport(node)) found = true; + // (1) member/element access: an HTTP_NS receiver is permitted ONLY for `createServer`, + // and that `createServer` access must itself sit in a direct-call position + // (DDR-CREATE-SERVER-ALIAS-POLICY) — a stored/forwarded `http.createServer` escapes; + // a network global (`fetch`/`WebSocket`) off a FREE global receiver is rejected. + if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) { + if (classifyHttpExpression(node.expression, checker) === 'HTTP_NS') { + const member = binderMemberName(node); + if (member === null || !HTTP_SERVER_VALUE_MEMBERS.has(member)) { + found = true; // a non-createServer (outbound client) member off the namespace + } else if (!isCreateServerSafePosition(node)) { + found = true; // the createServer constructor forwarded/stored out of a direct call + } + } + // F1/P2/P1 — classify the member key (Resolved / NotCapability / Indeterminate) off the binder + // and decide it ONLY at a binder-verified free-global receiver. Resolved(capability) DENY; + // Resolved(other) ALLOW; NotCapability (provably too long) ALLOW; Indeterminate (runtime/ + // ambient/mutated/undeclared/cycle key, or a depth/visit resource abort) DENY fail-closed — + // a key that MIGHT be `fetch`/`WebSocket` at runtime must not pass by being unresolvable. + const memberKey = netMemberKey(node, checker, netMemo); + if (isFreeGlobalReceiver(node.expression, checker, sourceFile)) { + if (memberKey.kind === 'resolved') { + if (NETWORK_GLOBAL_NAMES.has(memberKey.value)) found = true; + } else if (memberKey.kind === 'indeterminate') { + found = true; + } + } + } + // (2) an identifier value read: HTTP_CLIENT is forbidden; HTTP_NS must sit in a safe + // position (else escape); a FREE network global (`fetch`/`WebSocket`) is rejected. + if (ts.isIdentifier(node) && isBinderValueReference(node)) { + const symbol = checker.getSymbolAtLocation(node); + if (symbol !== undefined) { + const cap = classifyHttpSymbol(symbol, checker); + if (cap === 'HTTP_CLIENT') found = true; + if (cap === 'HTTP_NS' && !isHttpNsSafePosition(node)) found = true; + // A createServer-classified identifier (named/renamed import or directly-destructured + // binding) is the constructor capability itself: it may be READ only in a direct-call + // position (DDR-CREATE-SERVER-ALIAS-POLICY). A read that stores/forwards it + // (`const start = createServer`) escapes — decided here, without tracking `start`. + if (cap === 'CREATE_SERVER' && !isCreateServerSafePosition(node)) found = true; + // A bare network global (`fetch`/`WebSocket`) whose only in-file declarations emit no + // runtime value (an ambient `declare const fetch`, a type-only import) still reaches + // the runtime global — reject it. A real local shadow (const/function/class/…) does not. + if (NETWORK_GLOBAL_NAMES.has(node.text) && !hasLocalRuntimeShadow(symbol, sourceFile)) found = true; + } else if (NETWORK_GLOBAL_NAMES.has(node.text)) { + found = true; + } + } + // (2b) an EXPORT specifier forwarding the createServer constructor out of the module + // (`export { createServer }`) is a non-call escape position for the capability + // (DDR-CREATE-SERVER-ALIAS-POLICY). One binder hop to the local target's own + // classification — no module-graph or value-flow analysis. A type-only specifier, a + // re-export from node:http (no local target → NONE, left to export confinement), and + // the exported CALL RESULT (`export const server = http.createServer(...)`, whose + // binding is NONE) are all untouched, so `exportsHttpCapability` stays unchanged. + if ( + ts.isExportSpecifier(node) && + !node.isTypeOnly && + !node.parent.parent.isTypeOnly && + classifyHttpSymbol(checker.getExportSpecifierLocalTargetSymbol(node), checker) === 'CREATE_SERVER' + ) { + found = true; + } + // (3) DECLARATION destructuring a network global off a FREE global receiver, including + // RECURSIVE NESTED object binding patterns (DDR-NET-STATIC-KEY-PARITY, nested authority): + // `const { fetch } = globalThis`, `const { fetch: f } = globalThis.globalThis`, + // `const { globalThis: { fetch: f } } = globalThis.globalThis`. Free-global authority begins + // at the declaration initializer and CONTINUES into a nested pattern ONLY through a + // self-reference hop key (`objectPatternHasFreeGlobalAuthority`) — `{ globalThis: … }` off a + // global re-denotes the real global, exactly like `globalThis.globalThis`, while `{ foo: … }` + // does not. At an authoritative pattern this element's source KEY (`propertyName ?? name`) is + // classified with the SAME resolver as a member key: a plain/quoted key is its own text, a + // computed static key (`{ ['fe' + 'tch']: f }`, `{ [k]: f }`) folds off the binder. + // Resolved(capability) and Indeterminate DENY (an indeterminate key fails closed, just like a + // member key), Resolved(other)/NotCapability ALLOW. Capability is extracted here, at the + // destructuring; the bound name is not followed onward through value flow. Structural and + // finite — authority is proven by walking finite binding-pattern parents, never a value graph. + if (ts.isBindingElement(node) && ts.isObjectBindingPattern(node.parent)) { + if (objectPatternHasFreeGlobalAuthority(node.parent, checker, sourceFile)) { + const key = netDestructuringKey(node.propertyName ?? node.name, checker); + if (key.kind === 'resolved') { + if (NETWORK_GLOBAL_NAMES.has(key.value)) found = true; + } else if (key.kind === 'indeterminate') { + found = true; // fail-closed at a proven free-global receiver + } + } + } + // (4) ASSIGNMENT destructuring a network global off a FREE global receiver, including RECURSIVE + // NESTED object patterns (DDR-NET-STATIC-KEY-PARITY, assignment parity): + // `({ fetch: f } = globalThis)`, `({ fetch } = globalThis.globalThis)`, + // `({ globalThis: { fetch: f } } = globalThis.globalThis)`. The target is an + // ObjectLiteralExpression (Shorthand/PropertyAssignment), not a binding pattern, so branch (3) + // does not see it. `scanFreeGlobalAssignmentTarget` mirrors branch (3)'s structural recursion + // for the object-literal AST: the same leaf key classification and free-global fail-closed + // policy, with authority continuing into a nested object-literal value ONLY through a + // self-reference hop key — declaration and assignment forms have equivalent finite authority + // semantics where their AST forms correspond. Bound to the `=` right-hand receiver, so an + // unrelated `({ fetch: f } = obj)` and a shadowed-global receiver stay allowed. + if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken && isFreeGlobalReceiver(node.right, checker, sourceFile)) { + const target = binderUnwrap(node.left); + if (ts.isObjectLiteralExpression(target)) scanFreeGlobalAssignmentTarget(target); + } + // (5) a direct built-in `Reflect.get(, )` acquisition of a network + // global (DDR-NET-REFLECT-GET, F1): `Reflect.get(globalThis.globalThis, 'fetch')(...)`. + // Reflect must be binder-proven UNSHADOWED (a local `const Reflect = { get() {} }` is an + // ordinary object — `isBuiltinReflectGetCallee`); the RECEIVER argument must be a binder-proven + // free global (`isFreeGlobalReceiver`); the KEY argument is classified by the SAME + // `netResolveKey` as a computed member key (`netReflectGetKey`) — a bare identifier is + // resolved through its const chain, never taken as text. Resolved(capability)/Indeterminate + // DENY (fail-closed), Resolved(other)/NotCapability ALLOW. One statically + // identifiable built-in call: NO alias of Reflect or of get, NO call/apply/bind chain, NO + // wrapper, NO value-flow after acquisition. + if (ts.isCallExpression(node) && isBuiltinReflectGetCallee(node.expression, checker, sourceFile)) { + const recvArg = node.arguments[0]; + const keyArg = node.arguments[1]; + if (recvArg !== undefined && keyArg !== undefined && isFreeGlobalReceiver(recvArg, checker, sourceFile)) { + const key = netReflectGetKey(keyArg, checker); + if (key.kind === 'resolved') { + if (NETWORK_GLOBAL_NAMES.has(key.value)) found = true; + } else if (key.kind === 'indeterminate') { + found = true; // fail-closed at a proven free-global receiver + } + } + } + ts.forEachChild(node, visit); + }; + ts.forEachChild(sourceFile, visit); + // Inbound server-socket acquisition (SOCK) is decided over the SAME single-file binder + // program — one source of truth, no reparse — and is an additional rejection reason: the + // permitted createServer path may not be used to acquire the underlying socket capability. + if (acquiresInboundServerSocket(checker, sourceFile)) found = true; + return found; +}; + +// Design D / Option-B export confinement, consuming the SAME binder-backed classification +// as `usesOutboundNetwork`: a binding classified HTTP_NS or HTTP_CLIENT (namespace, named +// client import, or a locally-destructured non-createServer member) may not cross the +// module boundary; createServer/LOCAL and type-only forms may. One source of truth. +const exportsHttpCapability = (inputSourceFile: ts.SourceFile): boolean => { + const { checker, sourceFile } = buildBinderProgram(inputSourceFile.text); + const carriesAuthority = (symbol: ts.Symbol | undefined): boolean => { + const cap = classifyHttpSymbol(symbol, checker); + return cap === 'HTTP_NS' || cap === 'HTTP_CLIENT'; + }; + for (const statement of sourceFile.statements) { + // (a) a RUNTIME re-export from node:http (`export * from` / a non-type-only specifier). + if ( + ts.isExportDeclaration(statement) && + !statement.isTypeOnly && + statement.moduleSpecifier !== undefined && + ts.isStringLiteral(statement.moduleSpecifier) && + statement.moduleSpecifier.text === 'node:http' + ) { + if (statement.exportClause === undefined) return true; + // A runtime namespace re-export `export * as http from 'node:http'` binds the whole + // node:http namespace under a name another host file can import and call + // (`http.request(...)`). The `!statement.isTypeOnly` guard above already excludes the + // type-only `export type * as http from 'node:http'`, which emits no runtime authority. + if (ts.isNamespaceExport(statement.exportClause)) return true; + if (ts.isNamedExports(statement.exportClause)) { + for (const el of statement.exportClause.elements) { + if (!el.isTypeOnly) return true; + } + } + } + // (b) a local re-export of a binding carrying node:http authority. Type-only skipped. + if ( + ts.isExportDeclaration(statement) && + !statement.isTypeOnly && + statement.moduleSpecifier === undefined && + statement.exportClause !== undefined && + ts.isNamedExports(statement.exportClause) + ) { + for (const el of statement.exportClause.elements) { + if (!el.isTypeOnly && carriesAuthority(checker.getExportSpecifierLocalTargetSymbol(el))) return true; + } + } + // (c) `export default ` / `export = `. + if (ts.isExportAssignment(statement)) { + const cap = classifyHttpExpression(statement.expression, checker); + if (cap === 'HTTP_NS' || cap === 'HTTP_CLIENT') return true; + } + // (d) an EXPORTED declaration whose bound name(s) carry node:http authority. + if (ts.isVariableStatement(statement) && ts.getModifiers(statement)?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) === true) { + const names: ts.Identifier[] = []; + const collect = (binding: ts.BindingName): void => { + if (ts.isIdentifier(binding)) { + names.push(binding); + } else { + for (const el of binding.elements) { + if (ts.isBindingElement(el)) collect(el.name); + } + } + }; + for (const decl of statement.declarationList.declarations) collect(decl.name); + for (const nm of names) { + if (carriesAuthority(checker.getSymbolAtLocation(nm))) return true; } } + } + return false; +}; + +describe('D3 host has no mutation, subprocess, secret, or Git capability', () => { + it('references no subprocess, environment, or Git operation', () => { + const forbidden: readonly RegExp[] = [ + /child_process/, + /node:child_process/, + /process\.env/, + /\bexecSync\b/, + /\bspawn(?:Sync)?\s*\(/, + /\bexecFile\b/, + /octokit/i, + /simple-git/, + /\bgit\s+(?:push|commit|merge|rebase|checkout)\b/, + ]; + for (const { file, text } of hostSources()) { + for (const pattern of forbidden) { + expect(pattern.test(text), `${file} must not match ${String(pattern)}`).toBe(false); + } + } + }); +}); + +describe('D3 host import discipline', () => { + it('imports only allow-listed node builtins, itself, or the Cockpit boundary', () => { + for (const { file, text } of hostSources()) { + // Fail closed: a dynamic `import(...)` whose target is not a static string + // cannot be confined, so the host must contain none (D3-CX-POLICY-2). The + // extractor necessarily omits such a computed specifier, so this is the + // layer that must reject it. + expect( + hasUnverifiableDynamicImport(text), + `${file} contains an unverifiable (computed) dynamic import`, + ).toBe(false); + for (const specifier of extractModuleSpecifiers(text)) { + // A relative specifier is accepted only when its resolved destination is + // confined to the host tree or the Cockpit boundary (D3-CX-POLICY-1); a + // raw `./`/`../cockpit/` string prefix let a redundant escape like + // `./../index.js` reach `src/index.ts` (the domain re-export barrel). A + // `node:*` builtin is accepted only when on the exact production allowlist + // (D3-CX-POLICY-3), not by a blanket `node:` prefix. + const isRelative = specifier.startsWith('./') || specifier.startsWith('../'); + const allowed = + isAllowedNodeBuiltin(specifier) || + (isRelative && relativeImportStaysInBoundary(file, specifier)); + expect(allowed, `${file} imports forbidden specifier: ${specifier}`).toBe(true); + } + } + }); + + it('never imports an adapter, transport, or authority module (nor the domain kernel directly)', () => { + for (const { file, text } of hostSources()) { + for (const specifier of extractModuleSpecifiers(text)) { + expect( + /adapter|transport|authorization|repair-job|permit|\.\.\/domain\//i.test(specifier), + `${file} imports forbidden module: ${specifier}`, + ).toBe(false); + } + } + }); +}); + +describe('D3 host relative-import confinement rejects boundary escapes (D3-CX-POLICY-1)', () => { + // Mirror of the check-#1 acceptance predicate, exercised directly on synthetic + // (importer, specifier) pairs. No production file is created; the bounded policy + // helper is pure path arithmetic, so fixture paths resolve exactly as real ones. + const accepts = (importer: string, specifier: string): boolean => { + const isRelative = specifier.startsWith('./') || specifier.startsWith('../'); + return ( + isAllowedNodeBuiltin(specifier) || + (isRelative && relativeImportStaysInBoundary(importer, specifier)) + ); + }; + + // --- Rejections: every relative path whose resolved destination leaves the host --- + it('rejects the redundant `./`-prefixed parent escape to the barrel', () => { + expect(accepts('server.ts', './../index.js')).toBe(false); + }); + + it('rejects a redundant `./`-prefixed escape straight into the domain kernel', () => { + expect(accepts('server.ts', './../domain/index.js')).toBe(false); + }); + + it('rejects a deeper `../../src/index.js` traversal escape', () => { + expect(accepts('server.ts', './../../src/index.js')).toBe(false); + expect(accepts('server.ts', '../../src/index.js')).toBe(false); + }); + + it('rejects redundant dot segments that normalize outside the host', () => { + expect(accepts('server.ts', './cockpit/../../index.js')).toBe(false); + expect(accepts('server.ts', './././../adapters/foo.js')).toBe(false); + }); + + it('rejects an escape from a nested host file', () => { + expect(accepts('fixtures/stage-a.ts', '../../index.js')).toBe(false); + expect(accepts('fixtures/stage-a.ts', './../../domain/index.js')).toBe(false); + }); + + it('rejects a sibling directory whose name merely begins with the host dir name', () => { + // `src/cockpit-host/../cockpit-host-evil/x.js` -> `src/cockpit-host-evil/x.js` + expect(accepts('server.ts', '../cockpit-host-evil/x.js')).toBe(false); + }); + + it('rejects a sibling `cockpit-*` directory that is not the Cockpit boundary', () => { + // `src/cockpit-host/../cockpit-secrets/x.js` -> `src/cockpit-secrets/x.js`; + // must not be read as inside `src/cockpit`. + expect(accepts('server.ts', '../cockpit-secrets/x.js')).toBe(false); + }); + + it('rejects a path with misleading allowed text before escaping', () => { + // Threads through `cockpit/` yet resolves to `src/index.js`. + expect(accepts('server.ts', './cockpit/../../index.js')).toBe(false); + // Re-enters a `cockpit-host/`-named segment yet escapes above `src`. + expect(accepts('server.ts', '../../cockpit-host/../index.js')).toBe(false); + }); + + it('rejects a backslash-smuggled escape, folded to `/` (POSIX/Windows-consistent)', () => { + expect(accepts('server.ts', '.\\..\\index.js')).toBe(false); + expect(accepts('server.ts', './..\\domain\\index.js')).toBe(false); + }); + + it('rejects a bare or plain-parent specifier that is not node: and not confined', () => { + expect(accepts('server.ts', '../index.js')).toBe(false); // plain parent to the barrel + expect(accepts('server.ts', '../domain/index.js')).toBe(false); + expect(accepts('server.ts', 'typescript')).toBe(false); + }); + + // --- Preservations: every legitimate host / Cockpit import still accepted --- + it('accepts a same-directory local import from a top-level host file', () => { + expect(accepts('server.ts', './local.js')).toBe(true); + expect(accepts('render.ts', './escape.js')).toBe(true); + }); + + it('accepts a nested local import', () => { + expect(accepts('server.ts', './nested/local.js')).toBe(true); + expect(accepts('server.ts', './fixtures/stage-a.js')).toBe(true); + }); + + it('accepts legitimate parent navigation that stays inside the host', () => { + expect(accepts('fixtures/stage-a.ts', '../local.js')).toBe(true); + expect(accepts('fixtures/stage-a.ts', '../render.js')).toBe(true); + }); + + it('accepts the explicit sibling Cockpit boundary from a top-level host file', () => { + expect(accepts('server.ts', '../cockpit/index.js')).toBe(true); + }); + + it('accepts a legitimate Cockpit import from a nested host file', () => { + expect(accepts('fixtures/stage-a.ts', '../../cockpit/index.js')).toBe(true); + }); + + it('restricts node: builtins to the exact production allowlist (POLICY-3)', () => { + expect(accepts('server.ts', 'node:http')).toBe(true); + expect(accepts('server.ts', 'node:url')).toBe(true); + // Every non-allowlisted builtin is now refused (previously the blanket `node:` + // prefix accepted them all): a "read-only" host cannot reach filesystem- + // mutation or process authority through `node:*`. + expect(accepts('server.ts', 'node:fs')).toBe(false); + }); + + // Integration: check #1 now catches the escape, and the forbidden-module check + // (check #2) remains an independent defense whose behavior is unchanged. + it('check #1 rejects `./../index.js` while the forbidden-module defense stays independent', () => { + const spec = './../index.js'; + expect(accepts('server.ts', spec)).toBe(false); // now caught by check #1 + // check #2 independently does NOT match this specifier text, proving check #1 + // is the load-bearing defense here and check #2 is untouched by this repair. + expect(/adapter|transport|authorization|repair-job|permit|\.\.\/domain\//i.test(spec)).toBe( + false, + ); + }); + + // The real host sources still satisfy check #1 under the confinement predicate. + it('accepts every specifier the real host sources actually import', () => { + for (const { file, text } of hostSources()) { + for (const specifier of extractModuleSpecifiers(text)) { + expect(accepts(file, specifier), `${file} -> ${specifier}`).toBe(true); + } + } + }); +}); + +describe('D3 host confinement resolves percent-encoded URL dot-segments like Node (D3-CX-POLICY-F1)', () => { + // The confinement helper used to join/normalize the specifier *text*, so a + // percent-encoded dot segment (`%2e%2e`) was read as an ordinary directory name + // and `import('./%2e%2e/index.js')` was accepted as in-host — while WHATWG/Node + // ESM resolution decodes `%2e%2e` to `..` and lands it on `src/index.js`, the + // domain re-export barrel. The helper now resolves every specifier through + // `new URL` + `fileURLToPath` before the containment rule, so its verdict tracks + // the real loader. These fixtures are synthetic; no production file is created + // and no module is loaded — the helper performs pure URL/path arithmetic. + const accepts = (importer: string, specifier: string): boolean => { + const isRelative = specifier.startsWith('./') || specifier.startsWith('../'); + return ( + isAllowedNodeBuiltin(specifier) || + (isRelative && relativeImportStaysInBoundary(importer, specifier)) + ); + }; + + // --- The exact original bypass, proven against the INTEGRATED policy path --- + // Not a mirrored helper in isolation: the real scanner surfaces the specifier + // from a real `import(...)` statement, and the exact check-#1 discipline + // predicate then rejects it — parser through confinement, end to end. + it('the integrated import-discipline path rejects the original `./%2e%2e/index.js` bypass', () => { + const source = `import('./%2e%2e/index.js');`; + const specifiers = extractModuleSpecifiers(source); + expect(specifiers).toContain('./%2e%2e/index.js'); // the scanner surfaces it verbatim + for (const specifier of specifiers) { + // Identical to check #1 in `D3 host import discipline`. + const isRelative = specifier.startsWith('./') || specifier.startsWith('../'); + const allowed = + isAllowedNodeBuiltin(specifier) || + (isRelative && relativeImportStaysInBoundary('server.ts', specifier)); + expect(allowed, `integrated path must reject: ${specifier}`).toBe(false); + } + // check #2 (forbidden-term text match) does NOT catch this specifier, proving + // check #1's URL-aware confinement is the load-bearing defense here. + expect( + /adapter|transport|authorization|repair-job|permit|\.\.\/domain\//i.test('./%2e%2e/index.js'), + ).toBe(false); + }); + + // --- Cross-check against the runtime resolution model (WHATWG URL) --- + // Independent of the helper's internals: `new URL` is the same algorithm Node's + // ESM loader resolves specifiers with, so pinning the resolved pathname proves + // the policy verdict cannot silently drift from real resolution. + it('tracks Node URL resolution: encoded double-dot escapes onto the src barrel', () => { + const spec = './%2e%2e/index.js'; + expect(new URL(spec, 'file:///r/src/cockpit-host/server.ts').pathname).toBe('/r/src/index.js'); + expect(accepts('server.ts', spec)).toBe(false); + }); + + it('tracks Node URL resolution: encoded double-dot then legitimate re-entry stays in host', () => { + const spec = './%2e%2e/cockpit-host/index.js'; + expect(new URL(spec, 'file:///r/src/cockpit-host/server.ts').pathname).toBe( + '/r/src/cockpit-host/index.js', + ); + expect(accepts('server.ts', spec)).toBe(true); + }); + + it('tracks Node URL resolution: a query holding `%2e%2e` never changes the file path', () => { + const spec = './local.js?x=%2e%2e'; + const resolved = new URL(spec, 'file:///r/src/cockpit-host/server.ts'); + expect(resolved.pathname).toBe('/r/src/cockpit-host/local.js'); + expect(resolved.search).toBe('?x=%2e%2e'); + expect(accepts('server.ts', spec)).toBe(true); + }); + + it('fileURLToPath decodes a legitimate percent filename exactly once', () => { + // Drive-lettered URL so `fileURLToPath` accepts it on Windows and POSIX alike. + // `%2525` decodes ONCE to `%25`, never twice to `%`, preserving the filename. + const resolved = fileURLToPath(new URL('./file%2525.js', 'file:///C:/r/src/cockpit-host/a.ts')); + expect(toPosix(resolved).endsWith('/src/cockpit-host/file%25.js')).toBe(true); + expect(accepts('server.ts', './file%2525.js')).toBe(true); + }); + + // --- Rejection: the full encoded-dot-segment matrix (every ASCII case form) --- + const rejectedEncodedTraversal: readonly { readonly importer: string; readonly spec: string }[] = [ + { importer: 'server.ts', spec: './%2e%2e/index.js' }, // lower + { importer: 'server.ts', spec: './%2E%2E/index.js' }, // upper + { importer: 'server.ts', spec: './%2e%2E/index.js' }, // mixed + { importer: 'server.ts', spec: './%2E%2e/index.js' }, // mixed (other order) + { importer: 'server.ts', spec: './%2e./index.js' }, // encoded + literal dot + { importer: 'server.ts', spec: './.%2e/index.js' }, // literal + encoded dot + { importer: 'server.ts', spec: './%2e%2e//index.js' }, // trailing empty segment + { importer: 'server.ts', spec: './%2e%2e/%2e%2e/index.js' }, // two encoded hops + // Encoded traversal from a NESTED importing file (fixtures/ -> src/index.js). + { importer: 'fixtures/stage-a.ts', spec: './%2e%2e/%2e%2e/index.js' }, + // Encoded traversal followed by a misleading sibling-PREFIX destination: + // resolves to `src/cockpit-host-evil/x.js`, which merely begins with the host + // dir name and must not be read as inside it. + { importer: 'server.ts', spec: './%2e%2e/cockpit-host-evil/x.js' }, + // Encoded traversal straight into the domain kernel. + { importer: 'server.ts', spec: './%2e%2e/domain/index.js' }, + ]; + for (const { importer, spec } of rejectedEncodedTraversal) { + it(`rejects encoded traversal ${JSON.stringify(spec)} from ${importer}`, () => { + expect(accepts(importer, spec)).toBe(false); + }); + } + + // --- Rejection: encoded separators and malformed escapes fail closed --- + const rejectedInvalid: readonly string[] = [ + './%2f/x.js', // encoded '/' + './..%2f/x.js', // literal `..` fused to an encoded '/' + './%2F/x.js', // encoded '/' (upper) + './%5c/x.js', // encoded '\' + './%5C/x.js', // encoded '\' (upper) + './%2e%2e%2fx.js', // encoded '/' after an encoded double-dot + './%2/x.js', // truncated percent escape + './%zz/x.js', // non-hex percent escape + './%gg%2e/x.js', // non-hex escape beside an encoded dot + ]; + for (const spec of rejectedInvalid) { + it(`fails closed on ${JSON.stringify(spec)}`, () => { + expect(accepts('server.ts', spec)).toBe(false); + }); + } + + // --- Preservation: correct verdicts that must NOT regress --- + const preserved: readonly { + readonly importer: string; + readonly spec: string; + readonly verdict: boolean; + }[] = [ + { importer: 'server.ts', spec: './%2e/index.js', verdict: true }, // single encoded dot == ./index.js + { importer: 'server.ts', spec: './%2E/local.js', verdict: true }, // single encoded dot (upper) + { importer: 'server.ts', spec: './%2e%2e/cockpit-host/index.js', verdict: true }, // traversal + re-entry + { importer: 'server.ts', spec: './%252e%252e/index.js', verdict: true }, // double-encoded: literal `%2e%2e` dir + { importer: 'server.ts', spec: './file%20name.js', verdict: true }, // legitimate percent (space) filename + { importer: 'server.ts', spec: './file%2525.js', verdict: true }, // decode-once percent filename -> file%25.js + { importer: 'server.ts', spec: './local.js?x=%2e%2e', verdict: true }, // query with %2e%2e is ignored + { importer: 'server.ts', spec: './local.js#%2e%2e', verdict: true }, // fragment with %2e%2e is ignored + { importer: 'server.ts', spec: './local.js', verdict: true }, // plain unencoded local import + { importer: 'fixtures/stage-a.ts', spec: '../local.js', verdict: true }, // nested parent nav stays in host + // Top-level and nested encoded imports into the Cockpit boundary. + { importer: 'server.ts', spec: './%2e%2e/cockpit/index.js', verdict: true }, + { importer: 'fixtures/stage-a.ts', spec: './%2e%2e/%2e%2e/cockpit/index.js', verdict: true }, + ]; + for (const { importer, spec, verdict } of preserved) { + it(`preserves the ${verdict ? 'accept' : 'reject'} verdict for ${JSON.stringify(spec)} from ${importer}`, () => { + expect(accepts(importer, spec)).toBe(verdict); + }); + } + + // --- No silent drift: the policy verdict equals an independent runtime oracle --- + // The oracle re-derives containment from `new URL` + `fileURLToPath` under a + // DIFFERENT synthetic root and DIFFERENT containment string logic than the + // helper, so a change that decoupled the policy from real resolution would make + // these disagree and fail the test. + const runtimeOracleStaysInBoundary = (importer: string, specifier: string): boolean => { + const importerUrl = new URL( + `src/cockpit-host/${importer.replace(/\\/g, '/')}`, + 'file:///D:/oracle-root/', + ); + try { + const u = new URL(specifier, importerUrl); + if (/%2f|%5c/i.test(u.pathname)) return false; + // fileURLToPath emits a drive-prefixed `D:\…` on Windows and `/D:/…` on + // POSIX; anchor on the unique synthetic-root marker and match the segments + // *below* it, so the comparison is independent of platform path formatting. + const full = fileURLToPath(u) + .replace(/\\/g, '/') + .replace(/\/{2,}/g, '/'); + const marker = '/oracle-root/'; + const at = full.indexOf(marker); + if (at < 0) return false; // resolved above the synthetic repo root entirely + const rel = full.slice(at + marker.length); + const inside = (base: string): boolean => rel === base || rel.startsWith(`${base}/`); + return inside('src/cockpit-host') || inside('src/cockpit'); + } catch { + return false; + } + }; + + it('agrees with an independent `new URL` + `fileURLToPath` oracle across the matrix', () => { + const specimens: readonly { readonly importer: string; readonly spec: string }[] = [ + ...rejectedEncodedTraversal, + ...rejectedInvalid.map((spec) => ({ importer: 'server.ts', spec })), + ...preserved.map(({ importer, spec }) => ({ importer, spec })), + { importer: 'server.ts', spec: './../index.js' }, // unencoded escape still rejected + { importer: 'server.ts', spec: '../cockpit/index.js' }, // unencoded cockpit still accepted + ]; + for (const { importer, spec } of specimens) { + expect(accepts(importer, spec), `policy vs oracle drift: ${importer} -> ${spec}`).toBe( + runtimeOracleStaysInBoundary(importer, spec), + ); + } + }); + + // --- Preservation: the unencoded POLICY-1 behavior is unchanged --- + it('preserves the original unencoded confinement verdicts', () => { + expect(accepts('server.ts', './../index.js')).toBe(false); + expect(accepts('server.ts', './../domain/index.js')).toBe(false); + expect(accepts('server.ts', '../cockpit-host-evil/x.js')).toBe(false); + expect(accepts('server.ts', '.\\..\\index.js')).toBe(false); // backslash-smuggled escape + expect(accepts('server.ts', './local.js')).toBe(true); + expect(accepts('server.ts', '../cockpit/index.js')).toBe(true); + expect(accepts('server.ts', 'node:http')).toBe(true); + }); + + // --- Preservation: real host sources still satisfy the URL-aware confinement --- + it('accepts every specifier the real host sources actually import', () => { + for (const { file, text } of hostSources()) { + for (const specifier of extractModuleSpecifiers(text)) { + expect(accepts(file, specifier), `${file} -> ${specifier}`).toBe(true); + } + } + }); +}); + +describe('D3 host import scanner recognizes every supported ESM form (D3-CR-F1)', () => { + // A forbidden domain/adapter import must be surfaced no matter which valid + // import syntax hides it — otherwise the discipline checks above are blind to + // it. Each fixture below is a single valid TypeScript/NodeNext ESM statement. + const forbiddenForms: readonly { readonly form: string; readonly source: string }[] = [ + { form: 'single-quoted static from', source: `import x from '../domain/foo.js';` }, + { form: 'double-quoted static from', source: `import x from "../domain/foo.js";` }, + { form: 'single-quoted side-effect', source: `import '../adapters/foo.js';` }, + { form: 'double-quoted side-effect', source: `import "../adapters/foo.js";` }, + { form: 'single-quoted dynamic', source: `const m = import('../domain/foo.js');` }, + { form: 'double-quoted dynamic', source: `const m = import("../domain/foo.js");` }, + { form: 're-export from', source: `export { y } from "../domain/foo.js";` }, + ]; + + for (const { form, source } of forbiddenForms) { + it(`extracts the forbidden specifier from a ${form} import`, () => { + const specifiers = extractModuleSpecifiers(source); + const forbidden = specifiers.filter((s) => /\.\.\/(?:domain|adapters)\//.test(s)); + expect(forbidden.length, `no specifier extracted from: ${source}`).toBeGreaterThan(0); + }); + } + + it('extracts allowed node builtin, local, and Cockpit-boundary specifiers', () => { + expect(extractModuleSpecifiers(`import http from 'node:http';`)).toContain('node:http'); + expect(extractModuleSpecifiers(`import { a } from "./local.js";`)).toContain('./local.js'); + expect( + extractModuleSpecifiers(`import { readCockpitSnapshot } from '../cockpit/index.js';`), + ).toContain('../cockpit/index.js'); + }); + + it('extracts a multi-line `import type { ... } from` specifier', () => { + const source = [ + 'import type {', + ' CockpitSnapshot,', + ' CockpitFindingReadModel,', + "} from '../cockpit/index.js';", + ].join('\n'); + expect(extractModuleSpecifiers(source)).toContain('../cockpit/index.js'); + }); + + it('does not treat `import.meta.url` as a module specifier', () => { + const source = `const isEntry = import.meta.url === pathToFileURL(entry).href;`; + expect(extractModuleSpecifiers(source)).toEqual([]); + }); +}); + +describe('D3 host import scanner covers dynamic-options and block-comment forms (D3-CR-F2/F3)', () => { + const forbiddenIn = (source: string): readonly string[] => + extractModuleSpecifiers(source).filter((s) => /\.\.\/(?:domain|adapters)\//.test(s)); + + // D3-CR-F2: a dynamic import that carries a second options argument still + // surfaces its specifier. Before this fix the scanner required `)` right after + // the closing quote, so the comma-led options form extracted nothing and the + // forbidden dependency slipped past both discipline checks. + it('extracts the specifier from a dynamic import with an import-attributes options object', () => { + expect(forbiddenIn(`import('../domain/foo.js', { with: { type: 'json' } })`).length).toBeGreaterThan(0); + }); + + it('extracts the specifier from a dynamic import with a bundler-style options object', () => { + expect(forbiddenIn(`import('../domain/foo.js', { webpackChunkName: 'foo' })`).length).toBeGreaterThan(0); + }); + + // D3-CR-F3 (narrow, independently reproduced cases only). + it('extracts the specifier when a block comment sits inside the dynamic import', () => { + expect(forbiddenIn(`import(/* note */ '../domain/foo.js')`).length).toBeGreaterThan(0); + }); + + it('extracts the specifier across a quoted-comment token separator', () => { + // The block comment contains a quote character; the scanner must consume the + // whole comment as a unit rather than treating that inner quote as the + // specifier delimiter. + expect(forbiddenIn(`import /* 'note' */ x from '../domain/foo.js';`).length).toBeGreaterThan(0); + }); + + // Preservation: the ordinary unquoted comment separator was never broken and + // must keep working (guards against over-narrowing the fix). The broad claim + // that ordinary comment separators evade the scanner was NOT REPRODUCIBLE. + it('still extracts across an ordinary unquoted comment separator', () => { + expect(forbiddenIn(`import /* note */ x from '../domain/foo.js';`).length).toBeGreaterThan(0); + expect(forbiddenIn(`export /* note */ { x } from '../domain/foo.js';`).length).toBeGreaterThan(0); + }); + + // Preservation: a single-argument dynamic import and allowed specifiers are + // unaffected, and import.meta.url is still ignored. + it('preserves single-argument dynamic, allowed, and import.meta behaviour', () => { + expect(extractModuleSpecifiers(`import('../domain/foo.js');`)).toContain('../domain/foo.js'); + expect(extractModuleSpecifiers(`import http from 'node:http';`)).toContain('node:http'); + expect(extractModuleSpecifiers(`import { a } from "./local.js";`)).toContain('./local.js'); + expect(extractModuleSpecifiers(`import { r } from '../cockpit/index.js';`)).toContain( + '../cockpit/index.js', + ); + expect(extractModuleSpecifiers(`const isEntry = import.meta.url === x;`)).toEqual([]); + }); +}); + +describe('D3 host import scanner covers boundary block-comment positions (D3-CR-F4/F5)', () => { + const forbiddenIn = (source: string): readonly string[] => + extractModuleSpecifiers(source).filter((s) => /\.\.\/(?:domain|adapters)\//.test(s)); + + // D3-CR-F4: a bare side-effect import has no `from`, so a block comment + // between `import` and the specifier previously fell through every branch and + // the forbidden dependency was not surfaced. + it('extracts a side-effect specifier preceded by an unquoted block comment', () => { + expect(forbiddenIn(`import /* note */ '../domain/foo.js';`).length).toBeGreaterThan(0); + }); + + it('extracts a side-effect specifier preceded by a quoted block comment', () => { + expect(forbiddenIn(`import /* "note" */ '../domain/foo.js';`).length).toBeGreaterThan(0); + }); + + it('extracts a double-quoted side-effect specifier preceded by a block comment', () => { + expect(forbiddenIn(`import /* note */ "../adapters/foo.js";`).length).toBeGreaterThan(0); + }); + + // D3-CR-F5: a block comment after the dynamic-import specifier, before the + // options comma or the closing paren, previously blocked the match because + // only whitespace was allowed in that position. + it('extracts a dynamic specifier with a trailing block comment before the options object', () => { + expect( + forbiddenIn(`import('../domain/foo.js' /* note */, { with: { type: 'json' } })`).length, + ).toBeGreaterThan(0); + }); + + it('extracts a double-quoted dynamic specifier with a trailing block comment before options', () => { + expect( + forbiddenIn(`import("../domain/foo.js" /* note */, { with: { type: "json" } })`).length, + ).toBeGreaterThan(0); + }); + + it('extracts a dynamic specifier with a trailing block comment before the closing paren', () => { + expect(forbiddenIn(`import('../domain/foo.js' /* note */)`).length).toBeGreaterThan(0); + }); + + // Preservation: the earlier boundary-comment forms and allowed/import.meta + // behaviour are unaffected by widening these two positions. + it('preserves prior comment forms, allowed imports, and import.meta exclusion', () => { + expect(forbiddenIn(`import(/* note */ '../domain/foo.js')`).length).toBeGreaterThan(0); + expect( + forbiddenIn(`import('../domain/foo.js', { with: { type: 'json' } })`).length, + ).toBeGreaterThan(0); + expect(forbiddenIn(`import /* 'note' */ x from '../domain/foo.js';`).length).toBeGreaterThan(0); + expect(extractModuleSpecifiers(`import http from 'node:http';`)).toContain('node:http'); + expect(extractModuleSpecifiers(`import { r } from '../cockpit/index.js';`)).toContain( + '../cockpit/index.js', + ); + expect(extractModuleSpecifiers(`const isEntry = import.meta.url === x;`)).toEqual([]); + }); +}); + +describe('D3 host import scanner covers post-`from` re-export comments (D3-CR-F6)', () => { + const forbiddenIn = (source: string): readonly string[] => + extractModuleSpecifiers(source).filter((s) => /\.\.\/(?:domain|adapters)\//.test(s)); + + // D3-CR-F6: a block comment between a re-export's `from` and its module string + // blocked the match, because the pattern required the quote right after + // `from`. The static-import pattern already tolerated that position, so only + // the re-export form was blind and the forbidden dependency slipped past both + // discipline checks. + it('extracts a re-export specifier preceded by an unquoted block comment', () => { + expect(forbiddenIn(`export { x } from /* note */ '../domain/foo.js';`).length).toBeGreaterThan( + 0, + ); + }); + + it('extracts a re-export specifier preceded by a quoted block comment', () => { + // The comment holds a quote character, so it must be consumed as a whole + // unit rather than mistaken for the specifier delimiter. + expect( + forbiddenIn(`export { x } from /* "note" */ '../domain/foo.js';`).length, + ).toBeGreaterThan(0); + }); + + it('extracts a double-quoted re-export specifier preceded by a block comment', () => { + expect(forbiddenIn(`export { x } from /* note */ "../domain/foo.js";`).length).toBeGreaterThan( + 0, + ); + }); + + it('extracts an export-star specifier preceded by a block comment', () => { + expect(forbiddenIn(`export * from /* note */ '../domain/foo.js';`).length).toBeGreaterThan(0); + }); + + it('extracts a double-quoted export-star specifier preceded by a block comment', () => { + expect(forbiddenIn(`export * from /* note */ "../adapters/foo.js";`).length).toBeGreaterThan(0); + }); + + it('extracts an `export type` specifier preceded by a block comment', () => { + expect( + forbiddenIn(`export type { T } from /* note */ '../domain/foo.js';`).length, + ).toBeGreaterThan(0); + }); + + it('extracts a re-export specifier across a multi-line block comment', () => { + const source = ['export { x } from /* multi', " line note */ '../domain/foo.js';"].join('\n'); + expect(forbiddenIn(source).length).toBeGreaterThan(0); + }); + + // Preservation: the comment-free re-export, every earlier comment position, + // allowed specifiers, and the import.meta.url exclusion are unaffected by + // widening this one position. + it('preserves prior re-export, import, dynamic, allowed, and import.meta behaviour', () => { + expect(forbiddenIn(`export { y } from "../domain/foo.js";`).length).toBeGreaterThan(0); + expect(forbiddenIn(`export /* note */ { x } from '../domain/foo.js';`).length).toBeGreaterThan( + 0, + ); + expect(forbiddenIn(`import /* note */ '../domain/foo.js';`).length).toBeGreaterThan(0); + expect(forbiddenIn(`import /* 'note' */ x from '../domain/foo.js';`).length).toBeGreaterThan(0); + expect(forbiddenIn(`import(/* note */ '../domain/foo.js')`).length).toBeGreaterThan(0); + expect(forbiddenIn(`import('../domain/foo.js' /* note */)`).length).toBeGreaterThan(0); + expect( + forbiddenIn(`import('../domain/foo.js', { with: { type: 'json' } })`).length, + ).toBeGreaterThan(0); + expect(extractModuleSpecifiers(`import http from 'node:http';`)).toContain('node:http'); + expect(extractModuleSpecifiers(`import { a } from "./local.js";`)).toContain('./local.js'); + expect(extractModuleSpecifiers(`import { r } from '../cockpit/index.js';`)).toContain( + '../cockpit/index.js', + ); + expect(extractModuleSpecifiers(`const isEntry = import.meta.url === x;`)).toEqual([]); + }); + + // The widened position must not double-count a specifier, and one match must + // not swallow the statement that follows it. + it('extracts each re-export specifier once, without capturing across statements', () => { + const source = [ + "export { a } from /* note */ './local.js';", + "export { b } from '../cockpit/index.js';", + ].join('\n'); + expect(extractModuleSpecifiers(source)).toEqual(['./local.js', '../cockpit/index.js']); + }); +}); + +describe('D3 host import scanner is comment/string-aware across the whole family (D3-CR-F7/D3-CX-F8 consolidated)', () => { + const forbiddenIn = (source: string): readonly string[] => + extractModuleSpecifiers(source).filter((s) => /\.\.\/(?:domain|adapters)\//.test(s)); + + // --- Comment adjacent to / abutting `from` (D3-CR-F7) --- + it('extracts a named re-export with a block comment abutting `from`', () => { + expect(forbiddenIn(`export { x } from/* note */'../domain/foo.js';`).length).toBeGreaterThan(0); + }); + + it('extracts star/type/double-quoted re-exports with a comment abutting `from`', () => { + expect(forbiddenIn(`export * from/* note */'../domain/foo.js';`).length).toBeGreaterThan(0); + expect( + forbiddenIn(`export type { T } from/* note */'../domain/foo.js';`).length, + ).toBeGreaterThan(0); + expect(forbiddenIn(`export * from/* note */"../adapters/foo.js";`).length).toBeGreaterThan(0); + }); + + it('extracts a specifier across a multi-line block comment abutting `from`', () => { + const source = ['export { x } from/* multi', " line note */'../domain/foo.js';"].join('\n'); + expect(forbiddenIn(source).length).toBeGreaterThan(0); + }); + + it('tolerates multiple consecutive comments after `from`', () => { + expect(forbiddenIn(`export { x } from/* a *//* b */'../domain/foo.js';`).length).toBeGreaterThan( + 0, + ); + }); + + // --- Previously-open holes now closed --- + // A: a line comment INSIDE a real re-export clause (before the real `from`). + it('extracts a re-export whose clause contains a line comment (hole A)', () => { + const source = ['export {', ' x, // note', ' y', "} from '../domain/foo.js';"].join('\n'); + expect(forbiddenIn(source).length).toBeGreaterThan(0); + expect(extractModuleSpecifiers(source)).toEqual(['../domain/foo.js']); + }); + + // B: a line comment between the export clause and the real `from`. + it('extracts a re-export with a line comment before `from` (hole B)', () => { + const source = ["export { x } // note", "from '../domain/foo.js';"].join('\n'); + expect(forbiddenIn(source).length).toBeGreaterThan(0); + expect(extractModuleSpecifiers(source)).toEqual(['../domain/foo.js']); + }); + + // C: a valid string ModuleExportName in the clause must not be read as the + // specifier, and must not block reaching the real specifier after `from`. + it('extracts a re-export with a quoted export name, not the quoted name (hole C)', () => { + expect(extractModuleSpecifiers(`export { "foo" as bar } from '../domain/foo.js';`)).toEqual([ + '../domain/foo.js', + ]); + expect(extractModuleSpecifiers(`export { x as "foo" } from "../adapters/foo.js";`)).toEqual([ + '../adapters/foo.js', + ]); + }); + + it('extracts a static import that uses a quoted import name', () => { + expect(extractModuleSpecifiers(`import { "foo" as bar } from '../domain/foo.js';`)).toEqual([ + '../domain/foo.js', + ]); + }); + + it('extracts a re-export whose binding is literally named `from`', () => { + expect(extractModuleSpecifiers(`export { from } from '../domain/foo.js';`)).toEqual([ + '../domain/foo.js', + ]); + }); + + // --- Comment-contained `from` must NOT fabricate a dependency (D3-CX-F8) --- + const falsePositiveFixtures: readonly { readonly form: string; readonly source: string }[] = [ + { + form: 'line comment, `from` abutting the quote', + source: `export const safe = true; // docs: from'../domain/example.js'`, + }, + { + form: 'line comment, whitespace before the quote', + source: `export const safe = true; // docs: from '../domain/example.js'`, + }, + { + form: 'block comment, `from` abutting the quote', + source: `export const safe = true; /* docs: from'../domain/example.js' */`, + }, + { + form: 'multi-line block comment', + source: ['export const safe = true;', '/* docs:', " from'../domain/example.js'", '*/'].join( + '\n', + ), + }, + { + form: 'ASI (no semicolon), trailing block comment', + source: `export const x = true /* docs: from'../domain/example.js' */`, + }, + { + form: 'ASI (no semicolon), trailing line comment', + source: ['export const x = true', "// docs: from'../domain/example.js'"].join('\n'), + }, + { + form: 'a `from`-bearing string value, not a re-export', + source: `export const doc = "from '../domain/example.js'";`, + }, + ]; + + for (const { form, source } of falsePositiveFixtures) { + it(`does not extract a comment- or string-contained module (${form})`, () => { + expect(extractModuleSpecifiers(source)).not.toContain('../domain/example.js'); + expect(forbiddenIn(source)).toEqual([]); + }); + } + + it('still consumes a prefix comment holding quotes or the word `from` as trivia', () => { + expect(extractModuleSpecifiers(`export /* 'note' */ { x } from '../domain/foo.js';`)).toEqual([ + '../domain/foo.js', + ]); + expect(extractModuleSpecifiers(`export /* from 'x' */ { a } from '../domain/foo.js';`)).toEqual([ + '../domain/foo.js', + ]); + }); + + // --- Identifier safety: only the exact `from` keyword introduces a clause --- + it('does not treat an identifier beginning with `from` as the clause keyword', () => { + expect(extractModuleSpecifiers(`export const fromValues = 1;`)).toEqual([]); + expect(extractModuleSpecifiers(`export const from_foo = true;`)).toEqual([]); + expect(extractModuleSpecifiers(`export { y } fromX '../domain/foo.js';`)).toEqual([]); + expect(extractModuleSpecifiers(`export { y } from1 '../domain/foo.js';`)).toEqual([]); + expect(extractModuleSpecifiers(`const a = Array.from(xs);`)).toEqual([]); + expect(extractModuleSpecifiers(`const b = Object.fromEntries(e);`)).toEqual([]); + }); + + // --- Boundaries: no cross-statement capture, no duplicates --- + it('extracts each specifier once across mixed comment-heavy statements', () => { + const source = [ + "export { a } from/* note */'./local.js';", + "export const doc = true; // from'../domain/example.js'", + "export { b } from '../cockpit/index.js';", + ].join('\n'); + expect(extractModuleSpecifiers(source)).toEqual(['./local.js', '../cockpit/index.js']); + }); + + it('extracts mixed import and re-export forms in order, once each', () => { + const source = [ + `import http from 'node:http';`, + `export { a } from './local.js';`, + `const d = import('../domain/foo.js');`, + ].join('\n'); + expect(extractModuleSpecifiers(source)).toEqual([ + 'node:http', + './local.js', + '../domain/foo.js', + ]); + }); + + // --- Slash / token cases: division, regex literals, comment markers in strings --- + it('is not confused by division, regex literals, or comment markers inside strings', () => { + expect(extractModuleSpecifiers(`const q = a / b; const r = 1 / 2;`)).toEqual([]); + expect(extractModuleSpecifiers(`const re = /['"]/g; const s = text.replace(/from/g, 'x');`)).toEqual( + [], + ); + expect(extractModuleSpecifiers(`const u = 'node:http//x'; const v = "a//b";`)).toEqual([]); + expect(extractModuleSpecifiers(`const u = 'a/*b*/c';`)).toEqual([]); + expect(extractModuleSpecifiers(`const u = 'https://example.com/from/x';`)).toEqual([]); + }); + + it('does not read a specifier out of a template literal', () => { + const template = ['const t = `', `import x from '../domain/x.js'`, '`;'].join(''); + expect(extractModuleSpecifiers(template)).toEqual([]); + }); + + // --- Preservation of every earlier form under the new mechanism --- + it('preserves static, side-effect, dynamic, allowed, and import.meta behaviour', () => { + expect(forbiddenIn(`import /* note */ '../domain/foo.js';`).length).toBeGreaterThan(0); + expect(forbiddenIn(`import /* 'note' */ x from '../domain/foo.js';`).length).toBeGreaterThan(0); + expect(forbiddenIn(`import(/* note */ '../domain/foo.js')`).length).toBeGreaterThan(0); + expect(forbiddenIn(`import('../domain/foo.js' /* note */)`).length).toBeGreaterThan(0); + expect( + extractModuleSpecifiers(`import('../domain/foo.js', { with: { type: 'json' } })`), + ).toContain('../domain/foo.js'); + expect(extractModuleSpecifiers(`import http from 'node:http';`)).toContain('node:http'); + expect(extractModuleSpecifiers(`import { a } from "./local.js";`)).toContain('./local.js'); + expect(extractModuleSpecifiers(`import { r } from '../cockpit/index.js';`)).toContain( + '../cockpit/index.js', + ); + expect(extractModuleSpecifiers(`const isEntry = import.meta.url === x;`)).toEqual([]); + }); + + // --- Liveness: comment-heavy legal input scans in linear time --- + // The prior regex family exhibited catastrophic backtracking here (seconds for + // ~20 comments). The tokenizer is single-pass, so a much larger input resolves + // instantly; a regression to backtracking would blow vitest's per-test timeout. + it('scans comment-heavy legal input in bounded, linear time', () => { + const heavyImport = `import ${'/* c */'.repeat(400)} '../domain/foo.js';`; + const heavyClause = `export {\n${' a, // note\n'.repeat(400)}} from '../domain/foo.js';`; + const start = performance.now(); + expect(extractModuleSpecifiers(heavyImport)).toEqual(['../domain/foo.js']); + expect(extractModuleSpecifiers(heavyClause)).toEqual(['../domain/foo.js']); + expect(performance.now() - start).toBeLessThan(1000); + }); +}); + +describe('D3 host import scanner handles template literals and import context (D3-CR-C1/C3/R1)', () => { + // A backtick built without a template literal, so the source fixtures below can + // embed real backticks and `${ }` sequences as plain text. + const BT = '`'; + const forbiddenIn = (source: string): readonly string[] => + extractModuleSpecifiers(source).filter((s) => /\.\.\/(?:domain|adapters)\//.test(s)); + + // --- C1: executable code inside a `${ }` substitution is still scanned --- + it('surfaces a dynamic import hidden inside a template substitution (C1)', () => { + const source = 'const text = ' + BT + "${import('../domain/foo.js')}" + BT + ';'; + expect(extractModuleSpecifiers(source)).toEqual(['../domain/foo.js']); + expect(forbiddenIn(source).length).toBeGreaterThan(0); + }); + + it('surfaces a dynamic import inside a nested template substitution (C1)', () => { + const source = 'const t = ' + BT + '${ f(' + BT + '${import("../domain/foo.js")}' + BT + ') }' + BT + ';'; + expect(extractModuleSpecifiers(source)).toEqual(['../domain/foo.js']); + }); + + it('does not fabricate a specifier from a substitution that holds no import (C1 safety)', () => { + const source = 'const t = ' + BT + '${ compute(x) + y }' + BT + ';'; + expect(extractModuleSpecifiers(source)).toEqual([]); + }); + + // --- R1: a substitution-free template is a valid fixed dynamic specifier --- + it('surfaces a substitution-free template dynamic import specifier (R1)', () => { + const source = 'import(' + BT + '../domain/foo.js' + BT + ');'; + expect(extractModuleSpecifiers(source)).toEqual(['../domain/foo.js']); + }); + + it('surfaces a substitution-free template dynamic import with options (R1)', () => { + const source = 'import(' + BT + '../domain/foo.js' + BT + ", { with: { type: 'json' } });"; + expect(extractModuleSpecifiers(source)).toContain('../domain/foo.js'); + }); + + it('does NOT surface a template dynamic import that carries a substitution (R1 negative)', () => { + // The extractor omits a computed specifier by contract (it surfaces only + // static ones). Security is not "silent omission": the import-discipline check + // treats such an unverifiable dynamic import as a rejection via + // `hasUnverifiableDynamicImport` — see the D3-CX-POLICY-2 regression block. + const source = 'import(' + BT + '../domain/${name}.js' + BT + ');'; + expect(extractModuleSpecifiers(source)).toEqual([]); + expect(hasUnverifiableDynamicImport(source)).toBe(true); + }); + + it('does NOT accept a template after `from` or as a bare specifier (syntax-invalid forms)', () => { + // `import x from ` template ` ` and `import ` template ` ` are not legal ESM; + // the scanner must not surface them. + expect(extractModuleSpecifiers('import x from ' + BT + '../domain/foo.js' + BT + ';')).toEqual([]); + expect(extractModuleSpecifiers('import ' + BT + '../domain/foo.js' + BT + ';')).toEqual([]); + }); + + // --- C3: `import` only counts in a genuine import context --- + it('does not treat a property keyed `import` as an import (C3)', () => { + expect(extractModuleSpecifiers("const config = { import: '../domain/foo.js' };")).toEqual([]); + expect(extractModuleSpecifiers("const config = { import : '../domain/foo.js' };")).toEqual([]); + }); + + it('does not treat a method named `import` as an import (C3)', () => { + expect(extractModuleSpecifiers('const obj = { import() { return 1; } };')).toEqual([]); + expect( + extractModuleSpecifiers("const obj = { import() { return '../domain/foo.js'; } };"), + ).toEqual([]); + }); + + it('does not treat member access `obj.import(...)` as a dynamic import (C3)', () => { + expect(extractModuleSpecifiers("obj.import('../domain/foo.js');")).toEqual([]); + expect(extractModuleSpecifiers('const x = obj.import;')).toEqual([]); + }); + + it('does not treat a quoted `"import"` key or `import`-prefixed identifier as an import (C3)', () => { + expect(extractModuleSpecifiers('const o = { "import": \'../domain/foo.js\' };')).toEqual([]); + expect(extractModuleSpecifiers("const importX = '../domain/foo.js';")).toEqual([]); + expect(extractModuleSpecifiers("const reimport = '../domain/foo.js';")).toEqual([]); + }); + + // --- Adversarial template tokenizer state --- + it('keeps tokenizer state correct across template escapes, comments, strings, and regex', () => { + const F = '../domain/foo.js'; + const cases: readonly string[] = [ + 'const t = ' + BT + 'a\\' + BT + 'b' + BT + "; import '" + F + "';", // escaped backtick + 'const t = ' + BT + '\\${import("x")}' + BT + "; import '" + F + "';", // escaped ${ is text + 'const t = ' + BT + '${ {a:1} }' + BT + "; import '" + F + "';", // object braces in subst + 'const t = ' + BT + '${ "}" + import(\'' + F + '\') }' + BT + ';', // string holding } in subst + 'const t = ' + BT + '${ /* } */ import(\'' + F + '\') }' + BT + ';', // comment holding } in subst + 'const t = ' + BT + '${ /[}]/g.test(x) }' + BT + "; import '" + F + "';", // regex holding } in subst + ]; + // Every case links exactly one real forbidden import (the trailing/inner one). + for (const source of cases) { + expect(forbiddenIn(source).length).toBeGreaterThan(0); + } + // Escaped-`${` and object-brace cases must not themselves fabricate a module. + expect(extractModuleSpecifiers('const t = ' + BT + '\\${import("../domain/x.js")}' + BT + ';')).toEqual( + [], + ); + }); + + it('scans substitution-heavy and deeply-nested templates in bounded, linear time', () => { + const many = 'const t = ' + BT + '${x}'.repeat(600) + BT + "; import '../domain/foo.js';"; + let nested = "import('../domain/foo.js')"; + for (let k = 0; k < 600; k += 1) nested = BT + '${' + nested + '}' + BT; + const nestedSource = 'const t = ' + nested + ';'; + const start = performance.now(); + expect(extractModuleSpecifiers(many)).toEqual(['../domain/foo.js']); + expect(extractModuleSpecifiers(nestedSource)).toEqual(['../domain/foo.js']); + expect(performance.now() - start).toBeLessThan(1000); + }); +}); + +describe('D3 host import scanner treats a `${` substitution as expression-start (D3-CR-S1)', () => { + // A `${ ... }` substitution begins a fresh JavaScript expression. The prior + // template-substitution tokenizer left `previous` pointing at the template + // prefix `str`, so regexCanFollow() reported a *value* context and a leading + // `/` inside the substitution was mis-tokenized as division. A quote in the + // resulting "regex-as-division" text then opened a spurious string that either + // swallowed a following real import (false negative) or exposed a fake one + // buried in the regex body (false positive). The fix resets `previous` to + // expression-start whenever a substitution opens (both `\`${` and `}…${`). + const BT = '`'; + const forbiddenIn = (source: string): readonly string[] => + extractModuleSpecifiers(source).filter((s) => /\.\.\/(?:domain|adapters)\//.test(s)); + + // --- S1-A: a regex whose class holds a quote must not swallow a later import. + // Single-line form is load-bearing: with the defect the spurious string runs + // to the real import's quote and the specifier is lost (returns []). + it('surfaces a real import after a `${ /[\']/ }` regex on the same line (S1-A)', () => { + const source = 'const t = ' + BT + "${ /[']/.test(x) }" + BT + "; import '../domain/secret.js';"; + expect(extractModuleSpecifiers(source)).toEqual(['../domain/secret.js']); + expect(forbiddenIn(source).length).toBeGreaterThan(0); + }); + + it('surfaces a real import after a `${ /[\']/ }` regex on the next line (S1-A)', () => { + const source = ['const t = ' + BT + "${ /[']/.test(x) }" + BT + ';', "import '../domain/secret.js';"].join( + '\n', + ); + expect(extractModuleSpecifiers(source)).toEqual(['../domain/secret.js']); + }); + + // --- S1-B: a fake `import('...')` buried inside a regex body must NOT surface. + it('does not fabricate a module from an import call inside a `${ /.../ }` regex body (S1-B)', () => { + const source = 'const t = ' + BT + "${ /import('../domain/evil.js')/ }" + BT + ';'; + expect(extractModuleSpecifiers(source)).toEqual([]); + expect(forbiddenIn(source)).toEqual([]); + }); + + // --- S1-C: a leading regex followed by a ternary must not hide a later import. + it('surfaces a real import after a `${ /\\s+/ ? .. : .. }` ternary regex (S1-C)', () => { + const source = 'const t = ' + BT + '${ /\\s+/.test(x) ? "a" : "b" }' + BT + "; import '../domain/z.js';"; + expect(extractModuleSpecifiers(source)).toEqual(['../domain/z.js']); + expect(forbiddenIn(source).length).toBeGreaterThan(0); + }); + + // --- Regex-vs-division context matrix. Each `${` independently begins an + // expression, so a leading `/` is a regex; after a value-producing token a `/` + // is division. A trailing real import proves no spurious string swallowed it. + it('reads a `/` at expression-start inside `${` as a regex literal', () => { + const withImport = (subst: string): string => + 'const t = ' + BT + subst + BT + "; import '../domain/z.js';"; + // leading / parenthesised / unary / ternary / logical / assignment regex + expect(extractModuleSpecifiers(withImport('${ /abc/.test(x) }'))).toEqual(['../domain/z.js']); + expect(extractModuleSpecifiers(withImport('${ /a\\/b/.test(x) }'))).toEqual(['../domain/z.js']); + expect(extractModuleSpecifiers(withImport('${ /[\'"]/.test(x) }'))).toEqual(['../domain/z.js']); + expect(extractModuleSpecifiers(withImport('${ (/abc/).test(x) }'))).toEqual(['../domain/z.js']); + expect(extractModuleSpecifiers(withImport('${ !/abc/.test(x) }'))).toEqual(['../domain/z.js']); + expect(extractModuleSpecifiers(withImport('${ x ? /a/ : /b/ }'))).toEqual(['../domain/z.js']); + expect(extractModuleSpecifiers(withImport('${ x && /a/.test(y) }'))).toEqual(['../domain/z.js']); + expect(extractModuleSpecifiers(withImport('${ (r = /a/) }'))).toEqual(['../domain/z.js']); + }); + + it('reads a `/` after a value-producing token inside `${` as division', () => { + // No import is present, so a mis-read regex (which would eat to the next `/`) + // could only ADD a phantom; each of these must stay empty. + expect(extractModuleSpecifiers('const t = ' + BT + '${ x / 2 }' + BT + ';')).toEqual([]); + expect(extractModuleSpecifiers('const t = ' + BT + '${ 4 / 2 }' + BT + ';')).toEqual([]); + expect(extractModuleSpecifiers('const t = ' + BT + '${ fn() / 2 }' + BT + ';')).toEqual([]); + expect(extractModuleSpecifiers('const t = ' + BT + '${ arr[0] / 2 }' + BT + ';')).toEqual([]); + expect(extractModuleSpecifiers('const t = ' + BT + '${ ({ x: 1 }).x / 2 }' + BT + ';')).toEqual([]); + }); + + // --- Each `${` in a multi-substitution template independently resets context. + it('gives every substitution its own expression-start (regex then division)', () => { + const a = 'const t = ' + BT + '${ /a/.test(x) }-${ /b/.test(y) }' + BT + "; import '../domain/z.js';"; + expect(extractModuleSpecifiers(a)).toEqual(['../domain/z.js']); + // second substitution is a division context and must not fabricate a module + const b = 'const t = ' + BT + '${ /a/.test(x) }-${ y / 2 }' + BT + ';'; + expect(extractModuleSpecifiers(b)).toEqual([]); + }); + + // --- A nested template restores expression-start for its own substitution and + // then correctly resumes division in the outer expression on return. + it('resets and restores context correctly across nested template substitutions', () => { + const source = + 'const t = ' + BT + '${ ' + BT + 'x ${ /a/.test(p) }' + BT + ' + q / 2 }' + BT + "; import '../domain/z.js';"; + expect(extractModuleSpecifiers(source)).toEqual(['../domain/z.js']); + }); + + // --- A regex body may hold quotes, `//`, `}`, or a `${` sequence without + // desyncing the substitution depth or fabricating a fake dependency. + it('keeps substitution depth intact when a regex body holds quotes, comments, or `${`', () => { + const withImport = (subst: string): string => + 'const t = ' + BT + subst + BT + "; import '../domain/z.js';"; + expect(extractModuleSpecifiers(withImport('${ /a"b/.test(x) }'))).toEqual(['../domain/z.js']); + expect(extractModuleSpecifiers(withImport("${ /a'b/.test(x) }"))).toEqual(['../domain/z.js']); + expect(extractModuleSpecifiers(withImport('${ /a\\/\\/b/.test(x) }'))).toEqual(['../domain/z.js']); + expect(extractModuleSpecifiers(withImport('${ /a${b}/.test(x) }'))).toEqual(['../domain/z.js']); + expect(extractModuleSpecifiers(withImport('${ /[}]/g.test(x) }'))).toEqual(['../domain/z.js']); + // fake import/export text living inside a regex body must never surface + expect( + extractModuleSpecifiers('const t = ' + BT + "${ /from '..\\/domain\\/evil.js'/.test(x) }" + BT + ';'), + ).toEqual([]); + expect( + extractModuleSpecifiers( + 'const t = ' + BT + "${ /export y from '..\\/domain\\/e.js'/.test(x) }" + BT + ';', + ), + ).toEqual([]); + }); + + // --- Preservation: C1/C3/R1 remain fixed under the expression-start change. + it('preserves C1 substitution imports, R1 template specifiers, and C3 non-imports', () => { + expect(extractModuleSpecifiers('const text = ' + BT + "${import('../domain/foo.js')}" + BT + ';')).toEqual([ + '../domain/foo.js', + ]); + expect( + extractModuleSpecifiers('const t = ' + BT + '${ f(' + BT + '${import("../domain/foo.js")}' + BT + ') }' + BT + ';'), + ).toEqual(['../domain/foo.js']); + expect(extractModuleSpecifiers('import(' + BT + '../domain/foo.js' + BT + ');')).toEqual([ + '../domain/foo.js', + ]); + expect(extractModuleSpecifiers('import(' + BT + '../domain/${name}.js' + BT + ');')).toEqual([]); + expect(extractModuleSpecifiers("const config = { import: '../domain/foo.js' };")).toEqual([]); + }); + + // --- Liveness: many regex-leading substitutions and nested templates scan in + // bounded linear time; a regression to rescanning would blow the timeout. + it('scans many regex-leading substitutions in bounded, linear time', () => { + const many = + 'const t = ' + BT + '${ /a/.test(x) }'.repeat(600) + BT + "; import '../domain/foo.js';"; + let nested = "import('../domain/foo.js')"; + for (let k = 0; k < 600; k += 1) nested = BT + '${ /q/.test(z) || ' + nested + ' }' + BT; + const nestedSource = 'const t = ' + nested + ';'; + const start = performance.now(); + expect(extractModuleSpecifiers(many)).toEqual(['../domain/foo.js']); + expect(extractModuleSpecifiers(nestedSource)).toEqual(['../domain/foo.js']); + expect(performance.now() - start).toBeLessThan(1000); + }); +}); + +describe('D3 host import scanner rejects a class field named `import` (D3-CR-A)', () => { + // `import` is a reserved word, but reserved words are legal member names, so a + // class field may be named `import`. Its `=` initializer is data, not a module + // specifier — the scanner must not fabricate a dependency from it. Reported + // independently by both Codex and CodeRabbit as the same false positive. + it('does not treat a one-line class field `import = …` as an import', () => { + expect(extractModuleSpecifiers("class Config { import = '../domain/foo.js'; }")).toEqual([]); + }); + + it('does not treat a multi-line class field `import = …` as an import', () => { + const source = ['class Config {', " import = '../domain/foo.js';", '}'].join('\n'); + expect(extractModuleSpecifiers(source)).toEqual([]); + }); + + it('does not treat an `import` field beside other fields as an import', () => { + const source = ['class Config {', " import = '../domain/foo.js';", ' other = 1;', '}'].join( + '\n', + ); + expect(extractModuleSpecifiers(source)).toEqual([]); + }); + + it('does not treat a typed or static class field named `import` as an import', () => { + // `import: string = …` is caught by the `:` guard; `static import = …` still + // lands on the `=` guard, since the field-name token is `import`. + expect(extractModuleSpecifiers("class C { import: string = '../domain/foo.js'; }")).toEqual([]); + expect(extractModuleSpecifiers("class C { static import = '../domain/foo.js'; }")).toEqual([]); + }); + + // Preservation: the `=` guard must not blind the scanner to genuine imports, + // which never place `=` immediately after the `import` keyword. + it('still surfaces every genuine import form under the `=` guard', () => { + expect(extractModuleSpecifiers("import '../domain/foo.js';")).toEqual(['../domain/foo.js']); + expect(extractModuleSpecifiers("import x from '../domain/foo.js';")).toEqual([ + '../domain/foo.js', + ]); + expect(extractModuleSpecifiers("import { x } from '../domain/foo.js';")).toEqual([ + '../domain/foo.js', + ]); + expect(extractModuleSpecifiers("import type { T } from '../domain/foo.js';")).toEqual([ + '../domain/foo.js', + ]); + expect(extractModuleSpecifiers("import('../domain/foo.js');")).toEqual(['../domain/foo.js']); + expect( + extractModuleSpecifiers("import('../domain/foo.js', { with: { type: 'json' } });"), + ).toContain('../domain/foo.js'); + }); + + // Preservation: the other `import`-member exclusions are unaffected. + it('keeps excluding property/method/member/prefixed `import` forms', () => { + expect(extractModuleSpecifiers("const o = { import: '../domain/foo.js' };")).toEqual([]); + expect( + extractModuleSpecifiers("const o = { import() { return '../domain/foo.js'; } };"), + ).toEqual([]); + expect(extractModuleSpecifiers("obj.import('../domain/foo.js');")).toEqual([]); + expect(extractModuleSpecifiers('const o = { "import": ' + "'../domain/foo.js' };")).toEqual([]); + expect(extractModuleSpecifiers("const importX = '../domain/foo.js';")).toEqual([]); + expect(extractModuleSpecifiers('const isEntry = import.meta.url === x;')).toEqual([]); + }); +}); + +describe('D3 host import scanner classifies `/` after a control-flow header as a regex (D3-CR-B)', () => { + const forbiddenIn = (source: string): readonly string[] => + extractModuleSpecifiers(source).filter((s) => /\.\.\/(?:domain|adapters)\//.test(s)); + + // The `)` that closes `if (…)` / `while (…)` / `for (…)` / `with (…)` is a + // control-flow header, whose statement body may start with a regex literal + // (`if (ok) /re/.test(x);`). Before this fix that `)` was read as a + // value-producing close, so `regexCanFollow()` returned false and the `/` was + // treated as division. A quote inside the regex could then open a spurious + // string (swallowing a following real import — false negative), and an + // `import('…')` inside the regex body could be tokenized as code (fabricating + // a dependency — false positive). This is distinct from the object-literal + // `}` division case (C2), which is left unchanged. + + // False negative: a regex whose class holds a quote must not swallow a later + // import (single-line form is load-bearing). + it('surfaces a real import after a control-flow-header regex on the same line (B false negative)', () => { + const source = "if (ok) /[']/.test(value); import '../domain/foo.js';"; + expect(extractModuleSpecifiers(source)).toEqual(['../domain/foo.js']); + expect(forbiddenIn(source).length).toBeGreaterThan(0); + }); + + it('surfaces a real import after a control-flow-header regex on the next line (B false negative)', () => { + const source = ["if (ok) /[']/.test(value);", "import '../domain/foo.js';"].join('\n'); + expect(extractModuleSpecifiers(source)).toEqual(['../domain/foo.js']); + }); + + // False positive: an `import('…')` buried in a regex body after a control + // header must NOT surface. + it('does not fabricate a module from an import call inside a control-flow-header regex (B false positive)', () => { + expect(extractModuleSpecifiers("if (ok) /import('../domain/evil.js')/.test(value);")).toEqual( + [], + ); + expect(extractModuleSpecifiers("if (ok) /import('evil')/.test(value);")).toEqual([]); + }); + + // Context matrix: a regex is recognised after every control-flow header, so a + // trailing real import is always surfaced (proving no spurious string ran on). + it('recognizes a regex after if/while/for/with headers and preserves the trailing import', () => { + const withImport = (stmt: string): string => stmt + " import '../domain/z.js';"; + for (const stmt of [ + "if (ok) /abc/.test(x);", + "if (ok) /[']/.test(x);", + 'if (ok) /["]/.test(x);', + "while (ok) /[']/.test(x);", + "for (; ok;) /[']/.test(x);", + "for (let i = 0; i < n; i += 1) /[']/.test(x);", + "if (a && b) /[']/.test(x);", + "if (f(x)) /[']/.test(x);", // nested value paren inside the control header + "if ((a)) /[']/.test(x);", + ]) { + expect(extractModuleSpecifiers(withImport(stmt))).toEqual(['../domain/z.js']); + expect(extractModuleSpecifiers(stmt + "\nimport '../domain/z.js';")).toEqual([ + '../domain/z.js', + ]); + } + }); + + // Preservation: a `/` after a value-producing `)` or `]` stays division, so no + // phantom module is fabricated and a following real import is still surfaced. + it('keeps division after value-producing parens and brackets', () => { + expect(extractModuleSpecifiers('const r = fn() / 2;')).toEqual([]); + expect(extractModuleSpecifiers('const r = (x) / 2;')).toEqual([]); + expect(extractModuleSpecifiers('const r = arr[0] / 2;')).toEqual([]); + expect(extractModuleSpecifiers('const r = (a + b) / c;')).toEqual([]); + expect(extractModuleSpecifiers('function f() { return (x) / 2; }')).toEqual([]); + expect(extractModuleSpecifiers("const r = fn() / 2;\nimport '../domain/z.js';")).toEqual([ + '../domain/z.js', + ]); + }); + + // C2 reconciliation: `const ratio = {} / value;` is object-literal division in + // expression position, so a following `import '…'` is a real dependency in + // *both* the same-line and next-line forms. The removed hand-lexer classified a + // `/` after `}` as a regex opener and let the same-line fake regex swallow the + // import — a false negative it deliberately preserved. The structural parser + // reads the division correctly, so the specifier now surfaces on both forms. + it('surfaces the import across an object-literal `}` division (C2)', () => { + expect(extractModuleSpecifiers("const ratio = {} / value; import '../domain/foo.js';")).toEqual([ + '../domain/foo.js', + ]); + expect( + extractModuleSpecifiers("const ratio = {} / value;\nimport '../domain/foo.js';"), + ).toEqual(['../domain/foo.js']); + }); + + // Member-guard (D3-CR-B2): a control keyword spelled as a member/property name + // (`Symbol.for(…)`, `obj.if(…)`) opens a value-producing call, not a control + // header, so its `)` stays a value paren and a following `/` is division. Before + // this guard, such a `)` was stamped `controlHeader`, the `/` began a regex, and + // the regex swallowed the later real import (false negative — dependency skipped). + it('does not treat a control keyword used as a member name as a control header', () => { + for (const call of [ + "Symbol.for('x')", + 'obj.for(x)', + 'obj.if(x)', + 'a.while(y)', + 'a.with(y)', + 'foo.bar.for(x)', + 'ns.Symbol.for(x)', + ]) { + const source = call + " / 2; import '../domain/foo.js';"; + expect(extractModuleSpecifiers(source)).toEqual(['../domain/foo.js']); + // next-line form too — the trailing import must survive regardless of layout + expect(extractModuleSpecifiers(call + " / 2;\nimport '../domain/foo.js';")).toEqual([ + '../domain/foo.js', + ]); + } + }); + + // Member-guard, computed / optional / prefixed forms must likewise never be + // read as control headers (none should even reach the keyword-member check, but + // pin the behaviour so a future tokenizer change can't silently regress it). + it('keeps division after computed, optional, and control-word-prefixed member calls', () => { + for (const call of [ + "obj['for'](x)", + 'obj["if"](x)', + 'obj?.for(x)', + 'obj?.if(x)', + 'beforeThing(x)', + 'format(x)', + 'different(x)', + 'whileX(x)', + 'ifX(x)', + ]) { + expect(extractModuleSpecifiers(call + " / 2; import '../domain/foo.js';")).toEqual([ + '../domain/foo.js', + ]); + } + }); + + // Preservation of the genuine fix: a *bare* control-flow header still allows a + // following regex, so the false-positive and false-negative B cases stay fixed. + it('still treats a bare control-flow header regex correctly after the member guard', () => { + expect(extractModuleSpecifiers("if (ok) /import('../domain/evil.js')/.test(value);")).toEqual( + [], + ); + expect( + extractModuleSpecifiers("if (ok) /[']/.test(value); import '../domain/foo.js';"), + ).toEqual(['../domain/foo.js']); + expect( + extractModuleSpecifiers("while (ok) /[']/.test(value); import '../domain/foo.js';"), + ).toEqual(['../domain/foo.js']); + expect( + extractModuleSpecifiers("for (; ok;) /[']/.test(value); import '../domain/foo.js';"), + ).toEqual(['../domain/foo.js']); + }); + + // No stale marker leak: a member call named like a control keyword nested inside + // a genuine control header must not corrupt the header's own `)` classification. + it('keeps a genuine header regex working when it wraps a control-word member call', () => { + expect( + extractModuleSpecifiers("if (Symbol.for('x')) /[']/.test(value); import '../domain/foo.js';"), + ).toEqual(['../domain/foo.js']); + // …and a member call after the header body still divides, not regexes. + expect( + extractModuleSpecifiers("if (ok) { obj.for(x) / 2; } import '../domain/foo.js';"), + ).toEqual(['../domain/foo.js']); + }); + + // Liveness: many control-flow-header regex statements scan in bounded linear + // time; a regression to rescanning would blow vitest's per-test timeout. + it('scans many control-flow-header regex statements in bounded, linear time', () => { + const many = "if (ok) /[']/.test(x);\n".repeat(2000) + "import '../domain/foo.js';"; + const start = performance.now(); + expect(extractModuleSpecifiers(many)).toEqual(['../domain/foo.js']); + expect(performance.now() - start).toBeLessThan(1000); + }); +}); + +describe('D3 host import scanner classifies `/` after a postfix operator as division (D3-CR postfix)', () => { + // `x++` / `x--` / TS non-null `x!` end a value, so the following `/` is + // division. Before this fix the tokenizer saw the bare trailing operator and + // `regexCanFollow()` opened a regex; a quote inside that fake regex ran on and + // swallowed a later real import (false negative). `++`/`--` are now emitted as + // one maximal-munch token, and `!` is disambiguated from logical-not by the + // token it follows. + it('surfaces a real import after `++`/`--`/`!` postfix division', () => { + expect(extractModuleSpecifiers("let x = 0; const r = x++ / 2; import '../domain/foo.js';")).toEqual([ + '../domain/foo.js', + ]); + expect(extractModuleSpecifiers("let x = 0; const r = x-- / 2; import '../domain/foo.js';")).toEqual([ + '../domain/foo.js', + ]); + expect(extractModuleSpecifiers("const r = x! / 2; import '../domain/foo.js';")).toEqual([ + '../domain/foo.js', + ]); + }); + + it('surfaces a real import after postfix on member/index/call targets', () => { + for (const lhs of ['arr[i]++', 'obj.value--', 'fn()!', 'arr[i]!']) { + expect(extractModuleSpecifiers(`const r = ${lhs} / 2;\nimport '../domain/foo.js';`)).toEqual([ + '../domain/foo.js', + ]); + } + }); + + it('never fabricates a module from the fake regex a postfix `/` used to open', () => { + expect(extractModuleSpecifiers("let x = 0; const r = x++ / 2;")).toEqual([]); + expect(extractModuleSpecifiers("const r = x! / 2;")).toEqual([]); + }); + + // Preservation: a *prefix* `++x`/`--x` puts the operand (not the operator) + // immediately before the `/`, so division is already correct there; and a + // genuine regex after a real prefix operator / operator position must still be + // recognised (logical-not `!/re/`, binary `+ /re/`, `return /re/`). + it('keeps prefix increment as division and prefix/operator regex as regex', () => { + expect(extractModuleSpecifiers("let x = 0; const r = ++x / 2;\nimport '../domain/foo.js';")).toEqual([ + '../domain/foo.js', + ]); + expect(extractModuleSpecifiers("let x = 0; const r = --x / 2;\nimport '../domain/foo.js';")).toEqual([ + '../domain/foo.js', + ]); + expect(extractModuleSpecifiers("if (!/[']/.test(v)) {} import '../domain/foo.js';")).toEqual([ + '../domain/foo.js', + ]); + expect(extractModuleSpecifiers("const r = a + /[']/.source; import '../domain/foo.js';")).toEqual([ + '../domain/foo.js', + ]); + expect(extractModuleSpecifiers("function f() { return /[']/.test(v); } import '../domain/foo.js';")).toEqual([ + '../domain/foo.js', + ]); + }); + + it('scans many postfix-division statements in bounded, linear time', () => { + const many = "let x = 0; const r = x++ / 2;\n".repeat(2000) + "import '../domain/foo.js';"; + const start = performance.now(); + expect(extractModuleSpecifiers(many)).toEqual(['../domain/foo.js']); + expect(performance.now() - start).toBeLessThan(1000); + }); +}); + +describe('D3 host import scanner recognizes `for await (…)` as a control header (D3-CR for-await)', () => { + // `for await (…)` is the async-iteration header: the token before `(` is + // `await`, not `for`, so the base control-header check missed it and its `)` + // was read as a value paren — a following regex became division, its quote ran + // on, and a later real import was swallowed (false negative); an `import('…')` + // inside that regex body was tokenized as code (false positive). The header is + // now recognised only for the exact bare `for` + `await` + `(` sequence. + it('surfaces a real import after a `for await` header regex', () => { + const source = "async function f() { for await (const y of xs) /[']/.test(y); }\nimport '../domain/foo.js';"; + expect(extractModuleSpecifiers(source)).toEqual(['../domain/foo.js']); + }); + + it('does not fabricate a module from an import call inside a `for await` header regex', () => { + expect( + extractModuleSpecifiers("async function f() { for await (const y of xs) /import('../domain/evil.js')/.test(y); }"), + ).toEqual([]); + }); + + // Preservation: `await` in any non-header position is a value/member call, not a + // control header, so a following `/` stays division and a later import surfaces. + it('keeps `await` value/member forms as value contexts, not headers', () => { + expect( + extractModuleSpecifiers("async function f() { const r = await fn() / 2; import '../domain/foo.js'; }"), + ).toEqual(['../domain/foo.js']); + expect(extractModuleSpecifiers("const r = obj.await(x) / 2;\nimport '../domain/foo.js';")).toEqual([ + '../domain/foo.js', + ]); + expect( + extractModuleSpecifiers("async function f() { const r = await (y) / 2; import '../domain/foo.js'; }"), + ).toEqual(['../domain/foo.js']); }); -}); - -describe('D3 host confinement resolves percent-encoded URL dot-segments like Node (D3-CX-POLICY-F1)', () => { - // The confinement helper used to join/normalize the specifier *text*, so a - // percent-encoded dot segment (`%2e%2e`) was read as an ordinary directory name - // and `import('./%2e%2e/index.js')` was accepted as in-host — while WHATWG/Node - // ESM resolution decodes `%2e%2e` to `..` and lands it on `src/index.js`, the - // domain re-export barrel. The helper now resolves every specifier through - // `new URL` + `fileURLToPath` before the containment rule, so its verdict tracks - // the real loader. These fixtures are synthetic; no production file is created - // and no module is loaded — the helper performs pure URL/path arithmetic. - const accepts = (importer: string, specifier: string): boolean => { - const isRelative = specifier.startsWith('./') || specifier.startsWith('../'); - return ( - isAllowedNodeBuiltin(specifier) || - (isRelative && relativeImportStaysInBoundary(importer, specifier)) - ); - }; - // --- The exact original bypass, proven against the INTEGRATED policy path --- - // Not a mirrored helper in isolation: the real scanner surfaces the specifier - // from a real `import(...)` statement, and the exact check-#1 discipline - // predicate then rejects it — parser through confinement, end to end. - it('the integrated import-discipline path rejects the original `./%2e%2e/index.js` bypass', () => { - const source = `import('./%2e%2e/index.js');`; - const specifiers = extractModuleSpecifiers(source); - expect(specifiers).toContain('./%2e%2e/index.js'); // the scanner surfaces it verbatim - for (const specifier of specifiers) { - // Identical to check #1 in `D3 host import discipline`. - const isRelative = specifier.startsWith('./') || specifier.startsWith('../'); - const allowed = - isAllowedNodeBuiltin(specifier) || - (isRelative && relativeImportStaysInBoundary('server.ts', specifier)); - expect(allowed, `integrated path must reject: ${specifier}`).toBe(false); - } - // check #2 (forbidden-term text match) does NOT catch this specifier, proving - // check #1's URL-aware confinement is the load-bearing defense here. + // Preservation: a plain `for (…)` and the other headers keep working, and a + // keyword-member (`obj.for`) nested in a `for await` condition still divides. + it('keeps plain `for`/`if`/`while` headers and nested keyword-member division working', () => { + expect(extractModuleSpecifiers("for (let i = 0; i < n; i += 1) /[']/.test(x); import '../domain/foo.js';")).toEqual([ + '../domain/foo.js', + ]); expect( - /adapter|transport|authorization|repair-job|permit|\.\.\/domain\//i.test('./%2e%2e/index.js'), - ).toBe(false); + extractModuleSpecifiers("async function f() { for await (const y of obj.for(x)) /[']/.test(y); }\nimport '../domain/foo.js';"), + ).toEqual(['../domain/foo.js']); }); - // --- Cross-check against the runtime resolution model (WHATWG URL) --- - // Independent of the helper's internals: `new URL` is the same algorithm Node's - // ESM loader resolves specifiers with, so pinning the resolved pathname proves - // the policy verdict cannot silently drift from real resolution. - it('tracks Node URL resolution: encoded double-dot escapes onto the src barrel', () => { - const spec = './%2e%2e/index.js'; - expect(new URL(spec, 'file:///r/src/cockpit-host/server.ts').pathname).toBe('/r/src/index.js'); - expect(accepts('server.ts', spec)).toBe(false); + it('scans many `for await` header regex statements in bounded, linear time', () => { + const many = + "async function f() { for await (const y of xs) /[']/.test(y); }\n".repeat(2000) + + "import '../domain/foo.js';"; + const start = performance.now(); + expect(extractModuleSpecifiers(many)).toEqual(['../domain/foo.js']); + expect(performance.now() - start).toBeLessThan(1000); }); +}); - it('tracks Node URL resolution: encoded double-dot then legitimate re-entry stays in host', () => { - const spec = './%2e%2e/cockpit-host/index.js'; - expect(new URL(spec, 'file:///r/src/cockpit-host/server.ts').pathname).toBe( - '/r/src/cockpit-host/index.js', +describe('D3 host import scanner classifies `/` after a restricted-statement keyword as a regex (D3-CR-BREAK-CONTINUE-ASI)', () => { + const forbiddenIn = (source: string): readonly string[] => + extractModuleSpecifiers(source).filter((s) => /\.\.\/(?:domain|adapters)\//.test(s)); + + // `break`/`continue` are restricted productions — a `[no LineTerminator here]` + // precedes the optional label — so a newline after the bare keyword triggers ASI + // and the next line begins a fresh statement whose first token may be a regex + // literal. Before this fix the keyword was read as an ordinary value-ending + // identifier, so `regexCanFollow()` returned false and the leading `/` was treated + // as division. A quote inside the regex then opened a spurious string that could + // swallow a following real import (false negative), and an `import('…')` in the + // regex body could be tokenized as code (false positive). The `/` is now a regex + // opener after a *bare* `break`/`continue`; a `.`-member form stays division. + + // False positive: an `import('…')` buried in a regex body after a bare + // `break`/`continue` + newline must NOT surface (this was the reproduced defect — + // the exact newline fixtures A/B returned the right set only accidentally, because + // a single-quoted string dies at the line end, but C/D fabricated `evil`). + it('does not fabricate a module from an import call in a regex after `break` + newline', () => { + const source = ['while (ok) {', ' break', " /import('../domain/evil.js')/.test(x);", '}'].join( + '\n', ); - expect(accepts('server.ts', spec)).toBe(true); + expect(extractModuleSpecifiers(source)).toEqual([]); + expect(forbiddenIn(source)).toEqual([]); }); - it('tracks Node URL resolution: a query holding `%2e%2e` never changes the file path', () => { - const spec = './local.js?x=%2e%2e'; - const resolved = new URL(spec, 'file:///r/src/cockpit-host/server.ts'); - expect(resolved.pathname).toBe('/r/src/cockpit-host/local.js'); - expect(resolved.search).toBe('?x=%2e%2e'); - expect(accepts('server.ts', spec)).toBe(true); + it('does not fabricate a module from an import call in a regex after `continue` + newline', () => { + const source = ['while (ok) {', ' continue', " /import('../domain/evil.js')/.test(x);", '}'].join( + '\n', + ); + expect(extractModuleSpecifiers(source)).toEqual([]); + expect(forbiddenIn(source)).toEqual([]); }); - it('fileURLToPath decodes a legitimate percent filename exactly once', () => { - // Drive-lettered URL so `fileURLToPath` accepts it on Windows and POSIX alike. - // `%2525` decodes ONCE to `%25`, never twice to `%`, preserving the filename. - const resolved = fileURLToPath(new URL('./file%2525.js', 'file:///C:/r/src/cockpit-host/a.ts')); - expect(toPosix(resolved).endsWith('/src/cockpit-host/file%25.js')).toBe(true); - expect(accepts('server.ts', './file%2525.js')).toBe(true); + // False negative: a quote-bearing regex after the keyword must not swallow a + // later real import. The exact Codex newline fixtures (import on its own line) + // pass either way — the load-bearing form places the import on the regex's line, + // where the old spurious string ran straight through it. + it('surfaces a real import after a `break`-newline quote-bearing regex (same line)', () => { + const source = "while (ok) { break\n/[']/.test(x); import '../domain/foo.js'; }"; + expect(extractModuleSpecifiers(source)).toEqual(['../domain/foo.js']); + expect(forbiddenIn(source).length).toBeGreaterThan(0); }); - // --- Rejection: the full encoded-dot-segment matrix (every ASCII case form) --- - const rejectedEncodedTraversal: readonly { readonly importer: string; readonly spec: string }[] = [ - { importer: 'server.ts', spec: './%2e%2e/index.js' }, // lower - { importer: 'server.ts', spec: './%2E%2E/index.js' }, // upper - { importer: 'server.ts', spec: './%2e%2E/index.js' }, // mixed - { importer: 'server.ts', spec: './%2E%2e/index.js' }, // mixed (other order) - { importer: 'server.ts', spec: './%2e./index.js' }, // encoded + literal dot - { importer: 'server.ts', spec: './.%2e/index.js' }, // literal + encoded dot - { importer: 'server.ts', spec: './%2e%2e//index.js' }, // trailing empty segment - { importer: 'server.ts', spec: './%2e%2e/%2e%2e/index.js' }, // two encoded hops - // Encoded traversal from a NESTED importing file (fixtures/ -> src/index.js). - { importer: 'fixtures/stage-a.ts', spec: './%2e%2e/%2e%2e/index.js' }, - // Encoded traversal followed by a misleading sibling-PREFIX destination: - // resolves to `src/cockpit-host-evil/x.js`, which merely begins with the host - // dir name and must not be read as inside it. - { importer: 'server.ts', spec: './%2e%2e/cockpit-host-evil/x.js' }, - // Encoded traversal straight into the domain kernel. - { importer: 'server.ts', spec: './%2e%2e/domain/index.js' }, - ]; - for (const { importer, spec } of rejectedEncodedTraversal) { - it(`rejects encoded traversal ${JSON.stringify(spec)} from ${importer}`, () => { - expect(accepts(importer, spec)).toBe(false); - }); - } - - // --- Rejection: encoded separators and malformed escapes fail closed --- - const rejectedInvalid: readonly string[] = [ - './%2f/x.js', // encoded '/' - './..%2f/x.js', // literal `..` fused to an encoded '/' - './%2F/x.js', // encoded '/' (upper) - './%5c/x.js', // encoded '\' - './%5C/x.js', // encoded '\' (upper) - './%2e%2e%2fx.js', // encoded '/' after an encoded double-dot - './%2/x.js', // truncated percent escape - './%zz/x.js', // non-hex percent escape - './%gg%2e/x.js', // non-hex escape beside an encoded dot - ]; - for (const spec of rejectedInvalid) { - it(`fails closed on ${JSON.stringify(spec)}`, () => { - expect(accepts('server.ts', spec)).toBe(false); - }); - } + it('surfaces a real import after a `continue`-newline quote-bearing regex (same line)', () => { + const source = "while (ok) { continue\n/[']/.test(x); import '../domain/foo.js'; }"; + expect(extractModuleSpecifiers(source)).toEqual(['../domain/foo.js']); + expect(forbiddenIn(source).length).toBeGreaterThan(0); + }); - // --- Preservation: correct verdicts that must NOT regress --- - const preserved: readonly { - readonly importer: string; - readonly spec: string; - readonly verdict: boolean; - }[] = [ - { importer: 'server.ts', spec: './%2e/index.js', verdict: true }, // single encoded dot == ./index.js - { importer: 'server.ts', spec: './%2E/local.js', verdict: true }, // single encoded dot (upper) - { importer: 'server.ts', spec: './%2e%2e/cockpit-host/index.js', verdict: true }, // traversal + re-entry - { importer: 'server.ts', spec: './%252e%252e/index.js', verdict: true }, // double-encoded: literal `%2e%2e` dir - { importer: 'server.ts', spec: './file%20name.js', verdict: true }, // legitimate percent (space) filename - { importer: 'server.ts', spec: './file%2525.js', verdict: true }, // decode-once percent filename -> file%25.js - { importer: 'server.ts', spec: './local.js?x=%2e%2e', verdict: true }, // query with %2e%2e is ignored - { importer: 'server.ts', spec: './local.js#%2e%2e', verdict: true }, // fragment with %2e%2e is ignored - { importer: 'server.ts', spec: './local.js', verdict: true }, // plain unencoded local import - { importer: 'fixtures/stage-a.ts', spec: '../local.js', verdict: true }, // nested parent nav stays in host - // Top-level and nested encoded imports into the Cockpit boundary. - { importer: 'server.ts', spec: './%2e%2e/cockpit/index.js', verdict: true }, - { importer: 'fixtures/stage-a.ts', spec: './%2e%2e/%2e%2e/cockpit/index.js', verdict: true }, - ]; - for (const { importer, spec, verdict } of preserved) { - it(`preserves the ${verdict ? 'accept' : 'reject'} verdict for ${JSON.stringify(spec)} from ${importer}`, () => { - expect(accepts(importer, spec)).toBe(verdict); - }); - } + // The exact Codex fixtures (A/B): import on a separate line — must stay correct. + it('surfaces a real import on a separate line after break/continue + newline regex', () => { + for (const kw of ['break', 'continue']) { + const source = ['while (ok) {', ` ${kw}`, " /[']/.test(x);", '}', "import '../domain/foo.js';"].join( + '\n', + ); + expect(extractModuleSpecifiers(source)).toEqual(['../domain/foo.js']); + } + }); - // --- No silent drift: the policy verdict equals an independent runtime oracle --- - // The oracle re-derives containment from `new URL` + `fileURLToPath` under a - // DIFFERENT synthetic root and DIFFERENT containment string logic than the - // helper, so a change that decoupled the policy from real resolution would make - // these disagree and fail the test. - const runtimeOracleStaysInBoundary = (importer: string, specifier: string): boolean => { - const importerUrl = new URL( - `src/cockpit-host/${importer.replace(/\\/g, '/')}`, - 'file:///D:/oracle-root/', - ); - try { - const u = new URL(specifier, importerUrl); - if (/%2f|%5c/i.test(u.pathname)) return false; - // fileURLToPath emits a drive-prefixed `D:\…` on Windows and `/D:/…` on - // POSIX; anchor on the unique synthetic-root marker and match the segments - // *below* it, so the comparison is independent of platform path formatting. - const full = fileURLToPath(u) - .replace(/\\/g, '/') - .replace(/\/{2,}/g, '/'); - const marker = '/oracle-root/'; - const at = full.indexOf(marker); - if (at < 0) return false; // resolved above the synthetic repo root entirely - const rel = full.slice(at + marker.length); - const inside = (base: string): boolean => rel === base || rel.startsWith(`${base}/`); - return inside('src/cockpit-host') || inside('src/cockpit'); - } catch { - return false; + // Regex-vs-division context matrix after the keyword: a leading `/` is a regex, + // so a trailing real import always survives (no spurious string ran on). + it('recognizes a regex after break/continue across newline, CRLF, and comment separators', () => { + const bodies: readonly string[] = [ + 'break\n/[\']/.test(x);', + 'continue\n/[\']/.test(x);', + 'break\r\n/[\']/.test(x);', + 'continue\r\n/[\']/.test(x);', + 'break /* c */\n/[\']/.test(x);', + 'continue /* c */\n/[\']/.test(x);', + 'break // c\n/[\']/.test(x);', + 'continue // c\n/[\']/.test(x);', + ]; + for (const body of bodies) { + const source = `while (ok) { ${body} }\nimport '../domain/foo.js';`; + expect(extractModuleSpecifiers(source)).toEqual(['../domain/foo.js']); } - }; + }); - it('agrees with an independent `new URL` + `fileURLToPath` oracle across the matrix', () => { - const specimens: readonly { readonly importer: string; readonly spec: string }[] = [ - ...rejectedEncodedTraversal, - ...rejectedInvalid.map((spec) => ({ importer: 'server.ts', spec })), - ...preserved.map(({ importer, spec }) => ({ importer, spec })), - { importer: 'server.ts', spec: './../index.js' }, // unencoded escape still rejected - { importer: 'server.ts', spec: '../cockpit/index.js' }, // unencoded cockpit still accepted + // A regex body holding an `import('…')` must not fabricate a dependency across any + // of those same separators. + it('never fabricates a module from an import-bearing regex across separators', () => { + const bodies: readonly string[] = [ + "break\n/import('../domain/evil.js')/.test(x);", + "continue\n/import('../domain/evil.js')/.test(x);", + "break\r\n/import('../domain/evil.js')/.test(x);", + "continue /* c */\n/import('../domain/evil.js')/.test(x);", + "break // c\n/import('../domain/evil.js')/.test(x);", ]; - for (const { importer, spec } of specimens) { - expect(accepts(importer, spec), `policy vs oracle drift: ${importer} -> ${spec}`).toBe( - runtimeOracleStaysInBoundary(importer, spec), - ); + for (const body of bodies) { + expect(extractModuleSpecifiers(`while (ok) { ${body} }`)).toEqual([]); } }); - // --- Preservation: the unencoded POLICY-1 behavior is unchanged --- - it('preserves the original unencoded confinement verdicts', () => { - expect(accepts('server.ts', './../index.js')).toBe(false); - expect(accepts('server.ts', './../domain/index.js')).toBe(false); - expect(accepts('server.ts', '../cockpit-host-evil/x.js')).toBe(false); - expect(accepts('server.ts', '.\\..\\index.js')).toBe(false); // backslash-smuggled escape - expect(accepts('server.ts', './local.js')).toBe(true); - expect(accepts('server.ts', '../cockpit/index.js')).toBe(true); - expect(accepts('server.ts', 'node:http')).toBe(true); - }); - - // --- Preservation: real host sources still satisfy the URL-aware confinement --- - it('accepts every specifier the real host sources actually import', () => { - for (const { file, text } of hostSources()) { - for (const specifier of extractModuleSpecifiers(text)) { - expect(accepts(file, specifier), `${file} -> ${specifier}`).toBe(true); - } + // Member guard: a control/restricted keyword spelled as a *member name* is a + // value, so a following `/` is division, not a regex — otherwise the fake regex + // would swallow the later real import (false negative). Covers `.` and `?.`. + it('keeps division after a `break`/`continue` used as a member name', () => { + for (const access of ['obj.break', 'obj.continue', 'obj?.break', 'obj?.continue']) { + const source = `const r = ${access} / 2; import '../domain/foo.js';`; + expect(extractModuleSpecifiers(source)).toEqual(['../domain/foo.js']); + expect(extractModuleSpecifiers(`const r = ${access} / 2;\nimport '../domain/foo.js';`)).toEqual([ + '../domain/foo.js', + ]); } }); -}); - -describe('D3 host import scanner recognizes every supported ESM form (D3-CR-F1)', () => { - // A forbidden domain/adapter import must be surfaced no matter which valid - // import syntax hides it — otherwise the discipline checks above are blind to - // it. Each fixture below is a single valid TypeScript/NodeNext ESM statement. - const forbiddenForms: readonly { readonly form: string; readonly source: string }[] = [ - { form: 'single-quoted static from', source: `import x from '../domain/foo.js';` }, - { form: 'double-quoted static from', source: `import x from "../domain/foo.js";` }, - { form: 'single-quoted side-effect', source: `import '../adapters/foo.js';` }, - { form: 'double-quoted side-effect', source: `import "../adapters/foo.js";` }, - { form: 'single-quoted dynamic', source: `const m = import('../domain/foo.js');` }, - { form: 'double-quoted dynamic', source: `const m = import("../domain/foo.js");` }, - { form: 're-export from', source: `export { y } from "../domain/foo.js";` }, - ]; - for (const { form, source } of forbiddenForms) { - it(`extracts the forbidden specifier from a ${form} import`, () => { - const specifiers = extractModuleSpecifiers(source); - const forbidden = specifiers.filter((s) => /\.\.\/(?:domain|adapters)\//.test(s)); - expect(forbidden.length, `no specifier extracted from: ${source}`).toBeGreaterThan(0); - }); - } + it('fabricates no module from a bare `obj.break` / `obj.continue` division', () => { + expect(extractModuleSpecifiers('const r = obj.break / 2;')).toEqual([]); + expect(extractModuleSpecifiers('const r = obj.continue / 2;')).toEqual([]); + }); - it('extracts allowed node builtin, local, and Cockpit-boundary specifiers', () => { - expect(extractModuleSpecifiers(`import http from 'node:http';`)).toContain('node:http'); - expect(extractModuleSpecifiers(`import { a } from "./local.js";`)).toContain('./local.js'); - expect( - extractModuleSpecifiers(`import { readCockpitSnapshot } from '../cockpit/index.js';`), - ).toContain('../cockpit/index.js'); + // The `!` lookback must still read `obj.break!` as a non-null assertion (value), + // so the following `/` is division — `break`/`continue` stay value-ending in + // `endsValue`, unlike REGEX_CONTEXT_KEYWORDS. + it('keeps division after a non-null member assertion `obj.break!`', () => { + expect(extractModuleSpecifiers("const r = obj.break! / 2; import '../domain/foo.js';")).toEqual([ + '../domain/foo.js', + ]); + expect(extractModuleSpecifiers('const r = obj.break! / 2;')).toEqual([]); }); - it('extracts a multi-line `import type { ... } from` specifier', () => { - const source = [ - 'import type {', - ' CockpitSnapshot,', - ' CockpitFindingReadModel,', - "} from '../cockpit/index.js';", - ].join('\n'); - expect(extractModuleSpecifiers(source)).toContain('../cockpit/index.js'); + // Preservation: a labelled `break`/`continue`, an explicit-semicolon form, and the + // untouched `return`/`throw` restricted keywords all keep working. + it('preserves labelled, explicit-semicolon, and return/throw forms', () => { + expect( + extractModuleSpecifiers("outer: while (ok) { break outer; } import '../domain/foo.js';"), + ).toEqual(['../domain/foo.js']); + expect( + extractModuleSpecifiers("outer: while (ok) { continue outer; } import '../domain/foo.js';"), + ).toEqual(['../domain/foo.js']); + expect( + extractModuleSpecifiers("while (ok) { break;\n/[']/.test(x); } import '../domain/foo.js';"), + ).toEqual(['../domain/foo.js']); + expect( + extractModuleSpecifiers("function f() { return\n/[']/.test(v); } import '../domain/foo.js';"), + ).toEqual(['../domain/foo.js']); }); - it('does not treat `import.meta.url` as a module specifier', () => { - const source = `const isEntry = import.meta.url === pathToFileURL(entry).href;`; - expect(extractModuleSpecifiers(source)).toEqual([]); + // Liveness: many restricted-statement regex lines scan in bounded linear time; a + // regression to rescanning would blow vitest's per-test timeout. + it('scans many `break`-newline regex statements in bounded, linear time', () => { + const many = "while (ok) { break\n/[']/.test(x); }\n".repeat(2000) + "import '../domain/foo.js';"; + const start = performance.now(); + expect(extractModuleSpecifiers(many)).toEqual(['../domain/foo.js']); + expect(performance.now() - start).toBeLessThan(1000); }); }); -describe('D3 host import scanner covers dynamic-options and block-comment forms (D3-CR-F2/F3)', () => { +describe('D3 host import scanner classifies `/` after a labelled restricted statement as a regex (D3-CR-BREAK-CONTINUE-LABEL-ASI)', () => { const forbiddenIn = (source: string): readonly string[] => extractModuleSpecifiers(source).filter((s) => /\.\.\/(?:domain|adapters)\//.test(s)); - // D3-CR-F2: a dynamic import that carries a second options argument still - // surfaces its specifier. Before this fix the scanner required `)` right after - // the closing quote, so the comma-led options form extracted nothing and the - // forbidden dependency slipped past both discipline checks. - it('extracts the specifier from a dynamic import with an import-attributes options object', () => { - expect(forbiddenIn(`import('../domain/foo.js', { with: { type: 'json' } })`).length).toBeGreaterThan(0); + // `break outer` / `continue outer` carry the statement's optional label on the + // *same* line as the keyword; the statement is then complete, so a `/` beginning + // the next line is a regex opener, not division. The token immediately before that + // `/` is the *label* id (not the keyword), so the bare-keyword guard alone missed + // it: before this fix `regexCanFollow()` read the label as an ordinary value and + // classified the `/` as division. An `import('…')` in the regex body then + // fabricated a dependency (false positive) and a quote-bearing regex could swallow + // a following same-line import (false negative). The label is now marked + // `restrictedLabel` when it directly follows a bare `break`/`continue` with no + // intervening LineTerminator; a newline in between is ASI, leaving the id an + // ordinary fresh statement whose `/` stays division. + + // False positive: an `import('…')` in a regex body after `break