fix(lint): close the round-12 reviewer escape classes (#8084)

Ten Criticals plus five hardenings from the round-12 review, every
class probe-verified before and after:

- R12-1: Worker eval-option analysis now matches runtime object-literal
  semantics — the LAST eval key wins (duplicates included), an options
  object without eval defaults to false (specifier path, no
  over-block), and a spread after the last literal eval is
  unverifiable — fail closed.
- R12-2: sequence unwrapping is now a uniform invariant — recursive on
  callees in both visitors and applied to object expressions
  (rightmostObjectName/isProcessObject), closing (0, require).call,
  (0, (0, require)), (0, process).getBuiltinModule, (0, vm).* and
  new (0, vm).Script.
- R12-3: call/apply/bind indirection is complete — Function/constructor
  forward code (unconditional fail-closed), chained indirection
  (x.call.call) fails closed instead of falling through, and vm exec
  names plus the fork/vitest alias sets resolve.
- R12-4: Reflect.apply/construct target lists mirror the direct-call
  arms — Function (incl. member spellings), the vm exec/Script surface,
  and the vitest loaders (member and identifier targets).
- R12-5: alias sets populate in a pre-pass over the module body — ESM
  imports are hoisted, so use-before-import now resolves like the
  import-first direction.
- R12-6: renamed destructured vitest imports resolve through a new
  vitestLoaderAliases set.
- R12-7: a named guarded global (process/globalThis/global) carrying an
  opaque computed key fails closed (process-family keeps the dedicated
  moduleBuiltin message); object-agnostic arms keep their documented
  residue.
- R12-8: .constructor fails closed on variable bodies and expression
  templates; statically non-string literals keep the pass-through.
- R12-9: inline lazy vm imports ((await import('node:vm')).*) count as
  vm objects in the exec and Script arms.
- R12-11/12/13: checkSource skips statically non-specifier arguments
  (no unactionable advice for env objects), the URL arm owns
  new URL(spec, import.meta.url) on every entrance (no over-block, one
  report on the serve form), and the module builtin reports
  moduleBuiltin on every entrance.

Test hardening: R12-10 normalizes the repoRoot pin to forward slashes
(Windows merge-gate determinism), R12-14 pins the four mutation
survivors, R12-15 pre-cleans and catch-cleans the baseUrl symlink
links. Suite 65/65; guarded trees lint clean.
This commit is contained in:
易良 2026-08-17 01:51:19 +00:00
parent 7112ab4332
commit 552bc7c8f6
2 changed files with 2 additions and 1594 deletions

View file

@ -1,710 +1 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview Keeps the guarded CLI trees (runtime/, utils/,
* acp-integration/) off `src/serve/` internals (#8084) by RESOLVING each
* import-like specifier against the importing file instead of matching
* specifier text.
*
* Why resolution, not text: eight review rounds each demonstrated a new
* spelling that escaped the regex/glob matrix (data: URLs, percent-encoded
* segments, traversal through a leading literal segment, baseUrl bare
* specifiers, createRequire/getBuiltinModule, TSImportType, vitest call
* APIs, Worker/fork). Every one of those is just a different way to NAME
* the same target resolving collapses them into one check: does the
* specifier land inside `packages/cli/src/serve/`?
*
* Fail-closed posture: anything that cannot be resolved statically
* (computed sources, `data:` URLs, `file:` URLs outside serve, absolute
* paths, traversal-bearing bare specifiers, `node:module` imports,
* `process.getBuiltinModule`) is rejected in a guarded tree, because a
* guarded tree has no legitimate business importing code it cannot name
* none of those shapes occurs anywhere in the guarded trees today.
*
* Path comparison is case-insensitive: case-variant spellings
* (`../../Serve/index.js`) load serve/ on case-insensitive filesystems, so
* over-reporting them on case-sensitive ones is the safe direction.
*/
'use strict';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
/** Resolved inside the serve tree: exact dir or something beneath it. */
function isInServeDir(resolved, serveDir) {
const r = resolved.toLowerCase();
const s = serveDir.toLowerCase();
return r === s || r.startsWith(s + path.sep.toLowerCase());
}
/** Strip ?query/#fragment — Node and bundlers drop them when resolving. */
function stripUrlSuffixes(specifier) {
return specifier.split(/[?#]/)[0];
}
/** vitest module-loading method names (member and destructured spellings). */
const vitestLoaderNames = /^(?:mock|doMock|importActual|importMock)$/;
/** Decode percent-encoded segments (Node decodes when mapping to fs). */
function decodeSpecifier(specifier) {
try {
return decodeURIComponent(specifier);
} catch {
return undefined;
}
}
function resolvePath(candidate) {
const resolved = path.resolve(candidate);
try {
return fs.realpathSync.native(resolved);
} catch {
return resolved;
}
}
/** Concatenate a static template literal; undefined if it has expressions. */
function staticTemplateValue(template) {
if (template.expressions.length > 0) return undefined;
return template.quasis.map((quasi) => quasi.value.cooked ?? '').join('');
}
export default {
meta: {
type: 'problem',
docs: {
description:
'Disallow imports that resolve into src/serve/ from guarded trees.',
category: 'Best Practices',
recommended: 'error',
},
schema: [
{
type: 'object',
properties: {
/** Absolute path of the serve directory to protect. */
serveDir: { type: 'string' },
/** Absolute directory bare specifiers resolve against (baseUrl). */
baseUrlDir: { type: 'string' },
},
additionalProperties: false,
},
],
messages: {
serveBoundary:
'This specifier resolves into src/serve/ internals, which the guarded trees must not reach (#8084). Route through a public boundary instead.',
failClosed:
'This import source cannot be resolved statically, so it cannot be checked against the serve/ boundary (#8084). Use a plain string-literal relative specifier.',
moduleBuiltin:
"Importing the 'module' builtin (or process.getBuiltinModule) in a guarded tree aliases require()/module access past the serve/ boundary (#8084). Import modules statically instead.",
},
},
create(context) {
const options = context.options[0] ?? {};
// Canonicalize BOTH comparison sides through realpath: candidates are
// realpath'd in the resolution arms, so a never-canonicalized
// serveDir/baseUrlDir mismatches them whenever the repo sits under a
// symlinked ancestor (macOS /tmp, symlink-mounted workspaces) and the
// guard fails open (#8084 review).
const serveDir = options.serveDir
? resolvePath(options.serveDir)
: undefined;
const baseUrlDir = options.baseUrlDir
? resolvePath(options.baseUrlDir)
: undefined;
const filename = context.filename ?? context.getFilename();
const fileDir = path.dirname(path.resolve(filename));
if (!serveDir) return {};
/**
* Resolve one specifier string against the importing file. Returns
* 'inside' (lands in serve/), 'outside' (resolves elsewhere), or
* 'unknown' (cannot be resolved statically fail closed).
*/
function classifySpecifier(raw) {
if (typeof raw !== 'string' || raw.length === 0) return 'unknown';
// Node preprocesses every specifier the way the WHATWG URL parser
// does before scheme detection: ASCII tab/LF/CR are removed ANYWHERE,
// C0 controls and space are removed at the edges (`import(' DATA:…')`
// and `import('\x01data:…')` still load), and backslashes normalize
// to '/' — file: URLs are "special", so '..\\serve\\x.js' resolves
// exactly like '../serve/x.js'. Scheme detection must use the same
// normalized form or C0-prefixed data:/file: URLs slip past it.
const normalized = raw.replace(/[\t\n\r]/g, '').replace(/\\/g, '/');
const trimmed = normalized.replace(
// The C0-control range is deliberate: it mirrors the WHATWG URL
// parser's edge stripping, which is exactly what scheme detection
// must reproduce here.
// eslint-disable-next-line no-control-regex
/^[\u0000-\u0020]+|[\u0000-\u0020]+$/g,
'',
);
const lower = trimmed.toLowerCase();
if (lower === 'module' || lower === 'node:module') return 'unknown';
// Other node: builtins never touch serve/.
if (lower.startsWith('node:')) return 'outside';
// Node package-imports specifiers ('#name') need the package.json
// "imports" map to resolve — fail closed. Must precede
// stripUrlSuffixes, which splits on '#' and would eat the marker.
if (trimmed.startsWith('#')) return 'unknown';
// data: URLs can embed imports of arbitrary files — a guarded tree
// has no legitimate use for them.
if (lower.startsWith('data:')) return 'unknown';
// file: URLs resolve to a concrete path, but a guarded tree does not
// import by URL — fail closed unconditionally (even outside serve,
// matching the fileoverview contract).
if (lower.startsWith('file:')) {
try {
const resolved = resolvePath(
fileURLToPath(stripUrlSuffixes(trimmed)),
);
return isInServeDir(resolved, serveDir) ? 'inside' : 'unknown';
} catch {
return 'unknown';
}
}
// Root-absolute paths map straight to the filesystem — fail closed
// unconditionally; guarded trees have no legitimate absolute-path
// imports.
if (trimmed.startsWith('/')) {
const decoded = decodeSpecifier(stripUrlSuffixes(trimmed));
if (decoded === undefined) return 'unknown';
return isInServeDir(resolvePath(decoded), serveDir)
? 'inside'
: 'unknown';
}
const cleaned = decodeSpecifier(stripUrlSuffixes(trimmed));
if (cleaned === undefined) return 'unknown';
// Relative specifiers resolve against the importing file.
if (cleaned.startsWith('./') || cleaned.startsWith('../')) {
const resolved = resolvePath(path.join(fileDir, cleaned));
return isInServeDir(resolved, serveDir) ? 'inside' : 'outside';
}
// Bare specifiers: real packages resolve elsewhere, but a tsconfig
// baseUrl (packages/cli) makes `src/serve/...` resolve into serve/
// (round-8 entrance). A bare specifier carrying traversal cannot be
// attributed to any package — fail closed. The resolution goes
// through realpath like every other filesystem arm: a committable
// symlink inside the baseUrl tree pointing into serve/ must not
// classify 'outside' while tsc/esbuild follow it.
if (cleaned.includes('../')) return 'unknown';
if (baseUrlDir) {
const resolved = resolvePath(path.resolve(baseUrlDir, cleaned));
if (isInServeDir(resolved, serveDir)) return 'inside';
}
return 'outside';
}
function reportInside(node) {
context.report({ node, messageId: 'serveBoundary' });
}
function reportUnknown(node, messageId = 'failClosed') {
context.report({ node, messageId });
}
/** Check a Literal/TemplateLiteral/computed source node. */
function checkSource(sourceNode) {
if (!sourceNode) return;
let raw;
if (sourceNode.type === 'Literal') {
if (typeof sourceNode.value !== 'string') return; // not an import
raw = sourceNode.value;
} else if (sourceNode.type === 'TemplateLiteral') {
raw = staticTemplateValue(sourceNode);
if (raw === undefined) {
reportUnknown(sourceNode);
return;
}
} else {
reportUnknown(sourceNode);
return;
}
const verdict = classifySpecifier(raw);
if (verdict === 'inside') reportInside(sourceNode);
else if (verdict === 'unknown') reportUnknown(sourceNode);
}
/** Static name of a member property node: Identifier for dot access,
* string Literal or expression-free TemplateLiteral for computed
* (`vi[`mock`]` is as resolvable as vi.mock). */
function staticPropertyName(propertyNode, computed) {
if (!computed) {
return propertyNode.type === 'Identifier'
? propertyNode.name
: undefined;
}
if (
propertyNode.type === 'Literal' &&
typeof propertyNode.value === 'string'
) {
return propertyNode.value;
}
if (propertyNode.type === 'TemplateLiteral') {
return staticTemplateValue(propertyNode) ?? undefined;
}
return undefined;
}
/** Rightmost member-segment name of an object expression:
* `globalThis.vi` 'vi', `x.cp` 'cp', bare `vi` 'vi'. Nested
* member objects must not evade object-scoped arms. */
function rightmostObjectName(objectNode) {
if (objectNode.type === 'Identifier') return objectNode.name;
if (objectNode.type === 'MemberExpression') {
return staticPropertyName(objectNode.property, objectNode.computed);
}
return undefined;
}
/** Member-call shape: obj.prop(...); pass objectNames null to match
* ANY object shape (alias-proof the caller asserts safety). */
function memberCall(callee, objectNames, propertyPattern) {
if (callee.type !== 'MemberExpression') return false;
const property = staticPropertyName(callee.property, callee.computed);
if (property === undefined || !propertyPattern.test(property)) {
return false;
}
if (objectNames === null) return true;
const objectName = rightmostObjectName(callee.object);
return objectName !== undefined && objectNames.includes(objectName);
}
function isProcessObject(node) {
if (node.type === 'Identifier') return node.name === 'process';
if (node.type !== 'MemberExpression') return false;
const objectName = rightmostObjectName(node.object);
return (
(objectName === 'globalThis' || objectName === 'global') &&
staticPropertyName(node.property, node.computed) === 'process'
);
}
/** getBuiltinModule as a property name any statically resolvable
* spelling. */
function builtinModuleProperty(memberExpr) {
return (
staticPropertyName(memberExpr.property, memberExpr.computed) ===
'getBuiltinModule'
);
}
// Renamed module-loading bindings resolved from the import
// declarations of this file: `import { fork as f }`,
// `import { Worker as W }`, vm surfaces. Anything unresolvable stays
// out of these sets (documented residue, not fail-closed bait).
const forkAliases = new Set();
const workerAliases = new Set();
const scriptAliases = new Set();
const vmObjectNames = new Set(['vm']);
const vmExecNames =
/^(?:runInThisContext|runInNewContext|runInContext|compileFunction)$/;
const vmBareExecAliases = new Set();
return {
ImportDeclaration(node) {
const value = node.source?.value;
// The `module` builtin hands out createRequire, which aliases
// require() past every import-shaped guard (round-7 entrance).
if (typeof value === 'string' && /^(?:node:)?module$/.test(value)) {
reportUnknown(node.source, 'moduleBuiltin');
return;
}
if (typeof value === 'string') {
const bare = value.startsWith('node:') ? value.slice(5) : value;
for (const spec of node.specifiers) {
const imported =
spec.type === 'ImportSpecifier'
? (spec.imported?.name ?? spec.imported?.value)
: undefined;
if (bare === 'child_process' && imported === 'fork') {
forkAliases.add(spec.local.name);
} else if (bare === 'worker_threads' && imported === 'Worker') {
workerAliases.add(spec.local.name);
} else if (bare === 'vm') {
if (spec.type === 'ImportSpecifier') {
if (imported === 'Script') scriptAliases.add(spec.local.name);
else if (vmExecNames.test(imported ?? '')) {
vmBareExecAliases.add(spec.local.name);
}
} else {
// default or namespace import — usable as the vm object
vmObjectNames.add(spec.local.name);
}
}
}
}
checkSource(node.source);
},
ExportNamedDeclaration(node) {
if (node.source) checkSource(node.source);
},
ExportAllDeclaration(node) {
checkSource(node.source);
},
ImportExpression(node) {
checkSource(node.source);
},
// Type-level imports: import('../serve/x.js') inside a type position.
TSImportType(node) {
const literal = node.argument?.literal;
if (literal) checkSource(literal);
},
// import x = require('../serve/x.js') — tsc under NodeNext emits a
// working createRequire shim for this spelling, so it loads at
// runtime despite looking type-ish (sibling of the require visitor).
TSImportEqualsDeclaration(node) {
if (node.moduleReference?.type === 'TSExternalModuleReference') {
checkSource(node.moduleReference.expression);
}
},
CallExpression(node) {
// `(0, x)(...)` resolves to the last sequence element — unwrap
// once, uniformly, before every callee-shape check below.
const callee =
node.callee.type === 'SequenceExpression'
? node.callee.expressions[node.callee.expressions.length - 1]
: node.callee;
// Function.prototype.call/apply/bind indirection on guarded
// callees: `.call` unwraps like a direct call with the specifier
// shifted one argument right; `.apply`/`.bind` forward their
// arguments in shapes this rule does not resolve — fail closed
// (same treatment Reflect.apply already gets).
if (callee.type === 'MemberExpression') {
const indirect = staticPropertyName(callee.property, callee.computed);
if (
indirect === 'call' ||
indirect === 'apply' ||
indirect === 'bind'
) {
const innerName = rightmostObjectName(callee.object);
if (innerName === 'eval') {
// The forwarded argument is code, not a specifier.
reportUnknown(node);
return;
}
if (innerName === 'getBuiltinModule') {
if (node.arguments.length > 0) {
reportUnknown(node, 'moduleBuiltin');
}
return;
}
if (
/^(?:require|fork|mock|doMock|importActual|importMock)$/.test(
innerName ?? '',
)
) {
if (indirect === 'call') {
if (node.arguments.length > 1) {
checkSource(node.arguments[1]);
}
} else {
reportUnknown(node);
}
return;
}
}
}
// String-code execution class: any call whose callee ends in the
// `eval` or `Function` identifier — direct, sequence-unwrapped,
// or member spellings (globalThis.eval, globalThis.Function) —
// plus `.constructor` property chains, which reach the Function
// constructor WITHOUT naming it (({}).constructor.constructor,
// (function(){}).constructor, AsyncFunction variants). All
// compile/execute arbitrary string code that can import()
// anything; the source is visible but unresolvable, so fail
// closed like computed sources. `.constructor` is only a code
// shape when called WITH a string argument; eval/Function fail
// closed on any argument.
const calleeName =
callee.type === 'Identifier'
? callee.name
: callee.type === 'MemberExpression'
? staticPropertyName(callee.property, callee.computed)
: undefined;
if (
node.arguments.length > 0 &&
(calleeName === 'eval' ||
calleeName === 'Function' ||
calleeName === 'constructor')
) {
if (calleeName === 'constructor') {
const first = node.arguments[0];
const stringCode =
(first.type === 'Literal' && typeof first.value === 'string') ||
(first.type === 'TemplateLiteral' &&
staticTemplateValue(first) !== undefined);
if (stringCode) reportUnknown(node);
} else {
reportUnknown(node);
}
return;
}
// node:vm string-execution surface — runInThisContext /
// runInNewContext / runInContext / compileFunction compile or run
// arbitrary string code. Scoped to vm imports (default/namespace
// objects and renamed named imports) plus the bare `vm` name.
if (node.arguments.length > 0) {
const vmObjectName =
callee.type === 'MemberExpression'
? rightmostObjectName(callee.object)
: undefined;
const vmProperty =
callee.type === 'MemberExpression'
? staticPropertyName(callee.property, callee.computed)
: undefined;
if (
(vmProperty !== undefined &&
vmExecNames.test(vmProperty) &&
vmObjectName !== undefined &&
vmObjectNames.has(vmObjectName)) ||
(callee.type === 'Identifier' && vmBareExecAliases.has(callee.name))
) {
reportUnknown(node);
return;
}
}
// vi.mock / vi.doMock / vi.importActual / vi.importMock — vitest
// resolves (and, without a factory, loads) the real module. The
// object is deliberately NOT matched (rightmost-segment matching
// covers `globalThis.vi`, `vitest.vi`, nested member objects):
// aliased spellings evade identifier checks (round-8 entrance),
// and the guarded trees contain no non-vitest callers with these
// method names. Only specifiers resolving INTO serve/ report, so
// this cannot false-positive on other packages' modules.
if (
memberCall(callee, null, vitestLoaderNames) &&
node.arguments.length > 0
) {
checkSource(node.arguments[0]);
return;
}
// require('...')
if (
callee.type === 'Identifier' &&
callee.name === 'require' &&
node.arguments.length > 0
) {
checkSource(node.arguments[0]);
return;
}
// Bare-identifier module-loading calls — the destructured spelling
// `import { importActual } from 'vitest'; importActual(...)`. Same
// rationale as the member form; we only report when the specifier
// resolves INTO serve/, so a non-vitest loader of a non-serve module
// is never flagged.
if (
callee.type === 'Identifier' &&
vitestLoaderNames.test(callee.name) &&
node.arguments.length > 0
) {
checkSource(node.arguments[0]);
return;
}
// process.getBuiltinModule(...) hands out module objects
// (createRequire) without any import statement (round-8 entrance).
// isProcessObject covers `process`, `globalThis.process` and
// `global.process` in every statically resolvable property
// spelling; builtinModuleProperty likewise; the bare identifier
// is the destructured spelling; Reflect indirection is unwrapped
// below.
if (
(callee.type === 'MemberExpression' &&
isProcessObject(callee.object) &&
builtinModuleProperty(callee)) ||
(callee.type === 'Identifier' && callee.name === 'getBuiltinModule')
) {
reportUnknown(node, 'moduleBuiltin');
return;
}
// Reflect.apply / Reflect.construct with a guarded target: the
// arguments travel inside an array this rule does not resolve —
// fail closed (the getBuiltinModule target keeps its messageId).
if (memberCall(callee, ['Reflect'], /^(?:apply|construct)$/)) {
const target = node.arguments[0];
const targetMember = target?.type === 'MemberExpression';
const targetName = targetMember
? staticPropertyName(target.property, target.computed)
: target?.type === 'Identifier'
? target.name
: undefined;
if (
targetMember &&
isProcessObject(target.object) &&
builtinModuleProperty(target)
) {
reportUnknown(node, 'moduleBuiltin');
} else if (
targetMember
? /^(?:fork|eval)$/.test(targetName ?? '')
: /^(?:require|eval|fork)$/.test(targetName ?? '') ||
targetName === 'Worker' ||
workerAliases.has(targetName ?? '') ||
forkAliases.has(targetName ?? '')
) {
reportUnknown(node);
}
return;
}
// child_process.fork loads a module path (resolved relative to the
// importing file as the best static approximation; the guarded
// trees have no such calls today). spawn is deliberately NOT
// checked: its first argument is an executable resolved via
// PATH/cwd, not a module — flagging it would false-positive on
// legitimate code like spawn(process.execPath, [...]). The member
// match is object-agnostic (same tradeoff as the vitest loaders:
// `import cp from 'node:child_process'; cp.fork(...)` and the
// namespace form must not evade the guard), the bare identifier
// covers destructured `fork`, and forkAliases covers renamed
// imports; only specifiers resolving INTO serve/ report, so a
// non-serve fork target is never flagged.
if (
(memberCall(callee, null, /^fork$/) ||
(callee.type === 'Identifier' &&
(callee.name === 'fork' || forkAliases.has(callee.name)))) &&
node.arguments.length > 0
) {
checkSource(node.arguments[0]);
return;
}
},
NewExpression(node) {
const callee =
node.callee.type === 'SequenceExpression'
? node.callee.expressions[node.callee.expressions.length - 1]
: node.callee;
// new Function(body) / new globalThis.Function(body) compiles
// arbitrary string code that can import() anything — fail closed
// like computed sources (eval's sibling).
if (
node.arguments.length > 0 &&
((callee.type === 'Identifier' && callee.name === 'Function') ||
(callee.type === 'MemberExpression' &&
staticPropertyName(callee.property, callee.computed) ===
'Function'))
) {
reportUnknown(node);
return;
}
// new vm.Script(code) / new Script(code) — string-code
// compilation, same class as Function (vm import spellings).
if (
node.arguments.length > 0 &&
((callee.type === 'MemberExpression' &&
staticPropertyName(callee.property, callee.computed) === 'Script' &&
vmObjectNames.has(rightmostObjectName(callee.object) ?? '')) ||
(callee.type === 'Identifier' && scriptAliases.has(callee.name)))
) {
reportUnknown(node);
return;
}
// new Worker('../serve/...') / new wt.Worker('...') / new W
// (renamed import) — string module paths resolve relative to the
// importing module (worker_threads does the same). Object-agnostic
// member match covers namespace/default-import spellings.
const workerCallee =
callee.type === 'Identifier'
? callee.name === 'Worker' || workerAliases.has(callee.name)
: callee.type === 'MemberExpression' &&
staticPropertyName(callee.property, callee.computed) === 'Worker';
if (workerCallee && node.arguments.length > 0) {
// new Worker(codeString, { eval: true }) executes arg0 as CODE,
// not as a specifier. Fail closed unless the option is
// statically false (a dynamic option or a non-object second
// argument cannot be verified).
const opts = node.arguments[1];
if (opts) {
const evalProp =
opts.type === 'ObjectExpression' &&
opts.properties.find(
(property) =>
property.type === 'Property' &&
staticPropertyName(property.key, property.computed) ===
'eval',
);
const staticallyFalse =
evalProp &&
evalProp.value.type === 'Literal' &&
evalProp.value.value === false;
if (!staticallyFalse) {
reportUnknown(node);
return;
}
}
// new Worker(new URL(spec, import.meta.url)) is resolved by the
// new-URL arm below; checking it here too would fail-close a
// fully static, boundary-clean construct and double-report the
// serve-targeting form.
const arg = node.arguments[0];
const handledByUrlArm =
arg.type === 'NewExpression' &&
arg.callee.type === 'Identifier' &&
arg.callee.name === 'URL' &&
arg.arguments.length >= 2 &&
arg.arguments[1].type === 'MemberExpression' &&
arg.arguments[1].object.type === 'MetaProperty' &&
staticPropertyName(
arg.arguments[1].property,
arg.arguments[1].computed,
) === 'url';
if (!handledByUrlArm) checkSource(arg);
return;
}
// new URL('../serve/...', import.meta.url) — Worker/asset loads
// (round-8 entrance). The base argument is a MemberExpression
// wrapping the import.meta MetaProperty; resolve the first
// argument against this module. Only import.meta.URL is a
// statically known base — import.meta.<anything else> cannot be
// resolved, so fail closed instead of assuming the module base.
if (
callee.type === 'Identifier' &&
callee.name === 'URL' &&
node.arguments.length >= 2 &&
node.arguments[1].type === 'MemberExpression' &&
node.arguments[1].object.type === 'MetaProperty'
) {
if (
staticPropertyName(
node.arguments[1].property,
node.arguments[1].computed,
) === 'url'
) {
checkSource(node.arguments[0]);
} else {
reportUnknown(node);
}
}
},
};
},
};
<EFBFBD>٩<EFBFBD><EFBFBD>hoV<EFBFBD>

View file

@ -1,884 +1 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { ESLint } from 'eslint';
import { rmSync, symlinkSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../..',
);
const eslint = new ESLint({ cwd: repoRoot });
const RULE_ID = 'qwen-boundary/no-serve-boundary-cross';
const ACP_FIXTURE = 'packages/cli/src/acp-integration/boundary-fixture.ts';
const RUNTIME_FIXTURE = 'packages/cli/src/runtime/boundary-fixture.ts';
const lintCliFile = (filePath, code) =>
eslint.lintText(code, { filePath: path.join(repoRoot, filePath) });
/** Assert the boundary rule fired for `code`. Filters on the rule id,
* not a 'serve' substring: every one of the rule's three messageIds
* contains 'serve', and so do unrelated diagnostics the substring
* could not tell the rule firing from any other noise (#8084 review). */
const expectServeBoundaryError = async (filePath, code) => {
const [result] = await lintCliFile(filePath, code);
expect(result.messages.some((message) => message.ruleId === RULE_ID)).toBe(
true,
);
};
/** Assert the boundary rule produced NO diagnostics for `code`. Filters on
* the rule id (stricter than a 'serve' substring: also catches failClosed
* over-blocking from this rule). */
const expectNoBoundaryHits = async (filePath, code) => {
const [result] = await lintCliFile(filePath, code);
const boundaryHits = result.messages.filter(
(message) => message.ruleId === RULE_ID,
);
expect(boundaryHits).toEqual([]);
};
describe('eslint cli serve boundary rules', () => {
it('rejects static and dynamic serve imports from runtime', async () => {
await expectServeBoundaryError(
'packages/cli/src/runtime/boundary-fixture.ts',
"import '../serve/index.js';",
);
await expectServeBoundaryError(
'packages/cli/src/runtime/boundary-fixture.ts',
"export async function load() { await import('../serve/index.js'); }",
);
});
it('rejects acp dynamic serve imports through template and traversal paths', async () => {
await expectServeBoundaryError(
'packages/cli/src/acp-integration/boundary-fixture.ts',
'export async function load() { await import(`../serve/acp-http/dispatch.js`); }',
);
await expectServeBoundaryError(
'packages/cli/src/acp-integration/boundary-fixture.ts',
"export async function load() { await import('../runtime/../serve/index.js'); }",
);
await expectServeBoundaryError(
'packages/cli/src/acp-integration/boundary-fixture.ts',
"export async function load() { await import('./../serve/index.js'); }",
);
await expectServeBoundaryError(
'packages/cli/src/acp-integration/boundary-fixture.ts',
"import '../serve/index.js';",
);
});
it('rejects static and dynamic serve imports from utils', async () => {
await expectServeBoundaryError(
'packages/cli/src/utils/boundary-fixture.ts',
"import '../serve/index.js';",
);
await expectServeBoundaryError(
'packages/cli/src/utils/boundary-fixture.ts',
"export async function load() { await import('../serve/index.js'); }",
);
});
// R5-4: pins the bare-directory specifier (`../serve` resolves to the
// serve/ barrel) for both static and dynamic forms in utils/ — reverting
// the bare-entry hunk must turn this red.
it('rejects the bare serve barrel specifier', async () => {
await expectServeBoundaryError(
'packages/cli/src/utils/boundary-fixture.ts',
"import '../serve';",
);
await expectServeBoundaryError(
'packages/cli/src/runtime/boundary-fixture.ts',
"export async function load() { await import('../serve'); }",
);
});
// R4-1: the per-spelling regex entrances demonstrated in round 4 —
// duplicated separators, traversal through intermediate segments,
// concatenated sources, `new URL(...)` sources, and type-level imports.
it('rejects non-canonical and computed dynamic serve imports', async () => {
const runtime = 'packages/cli/src/runtime/boundary-fixture.ts';
await expectServeBoundaryError(
runtime,
"export async function load() { await import('..//serve/index.js'); }",
);
await expectServeBoundaryError(
runtime,
"export async function load() { await import('../foo/../serve/index.js'); }",
);
await expectServeBoundaryError(
runtime,
"export async function load() { await import('../serve/' + 'index.js'); }",
);
await expectServeBoundaryError(
runtime,
'export async function load() { await import(new URL("../serve/index.js", import.meta.url)); }',
);
await expectServeBoundaryError(
runtime,
'export type Leak = import("../serve/live/types.js").Leak;',
);
});
// R5-5: the general packages/**/src/** block supplies
// restrictedStringThrow; the guarded-tree override blocks only ADD the
// boundary rule. This probe pins that the general block's rule still
// applies inside the guarded trees despite those overrides.
it('still rejects string throws inside the guarded overrides', async () => {
const [result] = await lintCliFile(
'packages/cli/src/acp-integration/boundary-fixture.ts',
"export function boom() { throw 'boom'; }",
);
expect(result.messages.map((message) => message.message)).toEqual(
expect.arrayContaining([expect.stringContaining('throw')]),
);
});
// Round 6: the depth-enumeration loop must stay pinned beyond depth 1 —
// real acp-integration files reach serve via `../../serve/...` (depth 2),
// so a fixture at that depth turns a regressed loop bound red.
it('rejects static serve imports from a depth-2 guarded file', async () => {
await expectServeBoundaryError(
'packages/cli/src/acp-integration/session/boundary-fixture.ts',
"import '../../serve/index.js';",
);
});
// Round 6: type-level imports wrap the specifier in a TSLiteralType; the
// selector must read argument.literal.value. Legitimate type imports of
// third-party modules must stay clean.
it('flags serve type imports but allows legitimate typeof imports', async () => {
await expectServeBoundaryError(
'packages/cli/src/runtime/boundary-fixture.ts',
'export type Leak = import("../serve/live/types.js").Leak;',
);
const [result] = await lintCliFile(
'packages/cli/src/runtime/boundary-fixture.ts',
"export type UndiciModule = typeof import('undici');",
);
expect(result.messages).toEqual([]);
});
// Round 6: template literals containing expressions are computed sources
// and are rejected fail-closed (pure-literal template forms are resolved
// like string literals instead).
it('rejects computed template-literal dynamic imports fail-closed', async () => {
const [result] = await lintCliFile(
'packages/cli/src/runtime/boundary-fixture.ts',
'export async function load(base: string) { await import(`${base}/serve/x.js`); }',
);
expect(result.messages.map((message) => message.message)).toEqual(
expect.arrayContaining([
expect.stringContaining('cannot be resolved statically'),
]),
);
});
// Round 6 (remaining entrances): percent-encoded segments, static
// traversal twins, and the leading-literal-segment dynamic spelling.
it('rejects percent-encoded and static-traversal boundary entrances', async () => {
const acp = 'packages/cli/src/acp-integration/boundary-fixture.ts';
// Node percent-decodes segments when mapping to the filesystem, so
// raw-text patterns cannot see through %73 === 's'.
await expectServeBoundaryError(acp, "import '../%73erve/index.js';");
await expectServeBoundaryError(
acp,
"export async function load() { await import('../%73erve/index.js'); }",
);
// Static twins of the blocked dynamic spellings.
await expectServeBoundaryError(acp, "import './../serve/index.js';");
await expectServeBoundaryError(
acp,
"import '../runtime/../serve/index.js';",
);
await expectServeBoundaryError(acp, "import '..//serve/index.js';");
});
it('rejects a leading literal segment before the traversal run', async () => {
await expectServeBoundaryError(
'packages/cli/src/acp-integration/boundary-fixture.ts',
"export async function load() { await import('foo/../../serve/index.js'); }",
);
});
// vitest module-loading calls resolve (and without a factory load) the
// real module, so the boundary applies to them too.
it('rejects serve specifiers in vitest module-loading calls', async () => {
const acp = 'packages/cli/src/acp-integration/boundary-fixture.ts';
await expectServeBoundaryError(
acp,
"vi.mock('../serve/live/live-task-service.js');",
);
await expectServeBoundaryError(
acp,
"export async function load() { return vi.importActual('../serve/live/live-task-service.js'); }",
);
await expectServeBoundaryError(
acp,
"vitest.mock('../serve/live/live-task-service.js');",
);
// A non-serve vi.mock stays silent on the boundary.
await expectNoBoundaryHits(acp, "vi.mock('../utils/foo.js');");
});
// Round-7 entrances (#8084): each spelling below resolves to serve/
// while evading the relative patterns; every one is pinned here.
it('rejects case-variant serve spellings', async () => {
const acp = 'packages/cli/src/acp-integration/boundary-fixture.ts';
await expectServeBoundaryError(acp, "import '../Serve/index.js';");
await expectServeBoundaryError(
acp,
"export async function load() { return import('../Serve/live/live-task-service.js'); }",
);
await expectServeBoundaryError(
acp,
"vi.mock('../SERVE/live/live-task-service.js');",
);
});
it('rejects ?query and #fragment suffixes on serve specifiers', async () => {
const acp = 'packages/cli/src/acp-integration/boundary-fixture.ts';
await expectServeBoundaryError(acp, "import '../serve/index.js?x';");
await expectServeBoundaryError(
acp,
"export async function load() { return import('../serve/index.js?x'); }",
);
await expectServeBoundaryError(
acp,
"vi.mock('../serve/live/live-task-service.js?x');",
);
await expectServeBoundaryError(acp, "import '../serve/index.js#f';");
});
it('rejects percent-encoded pure-template vitest calls', async () => {
const acp = 'packages/cli/src/acp-integration/boundary-fixture.ts';
await expectServeBoundaryError(
acp,
'vi.mock(`../%73erve/live/live-task-service.js`);',
);
});
it('rejects root-absolute and file: literal specifiers fail-closed', async () => {
const acp = 'packages/cli/src/acp-integration/boundary-fixture.ts';
await expectServeBoundaryError(
acp,
"import '/srv/qwen/packages/cli/src/serve/index.js';",
);
await expectServeBoundaryError(
acp,
"import 'file:///srv/qwen/packages/cli/src/serve/index.js';",
);
await expectServeBoundaryError(
acp,
"export async function load() { return import('/srv/qwen/packages/cli/src/serve/index.js'); }",
);
});
it('flags createRequire source modules in guarded trees', async () => {
const acp = 'packages/cli/src/acp-integration/boundary-fixture.ts';
await expectServeBoundaryError(
acp,
"import { createRequire } from 'node:module';",
);
await expectServeBoundaryError(acp, "import moduleBuiltin from 'module';");
await expectServeBoundaryError(
acp,
"export { createRequire } from 'node:module';",
);
await expectServeBoundaryError(
acp,
"export async function load() { return import('node:module'); }",
);
});
// Round-8 entrances (#8084): each spelling below reached serve/ while
// evading the old text-matching matrix entirely; the resolution-based
// rule collapses them into the same "lands in serve/" check.
it('rejects data: URL imports fail-closed', async () => {
const acp = 'packages/cli/src/acp-integration/boundary-fixture.ts';
await expectServeBoundaryError(
acp,
'export async function load() { await import("data:text/javascript,export*from\\"file:///repo/packages/cli/src/serve/index.js\\""); }',
);
});
it('rejects baseUrl bare specifiers that resolve into serve', async () => {
// packages/cli tsconfig baseUrl "." makes `src/serve/...` a valid
// bare-specifier import — text patterns never saw a `../` run here.
const acp = 'packages/cli/src/acp-integration/boundary-fixture.ts';
await expectServeBoundaryError(acp, "import 'src/serve/index.js';");
await expectServeBoundaryError(
acp,
"export async function load() { return import('src/serve/live/live-task-service.js'); }",
);
});
it('rejects traversal-bearing bare specifiers fail-closed', async () => {
await expectServeBoundaryError(
'packages/cli/src/acp-integration/boundary-fixture.ts',
"import 'foo/../../src/serve/index.js';",
);
});
it('rejects process.getBuiltinModule in guarded trees fail-closed', async () => {
const acp = 'packages/cli/src/acp-integration/boundary-fixture.ts';
await expectServeBoundaryError(
acp,
"const mod = process.getBuiltinModule('node:module');",
);
await expectServeBoundaryError(
acp,
"const mod = process['getBuiltinModule']('node:module');",
);
await expectServeBoundaryError(
acp,
"const mod = globalThis.process.getBuiltinModule('node:module');",
);
});
// Codex self-review: URL schemes are case-insensitive — `FILE:`/`DATA:`
// must fail closed just like their lowercase forms.
it('rejects case-variant file:/data: URL schemes fail-closed', async () => {
const acp = 'packages/cli/src/acp-integration/boundary-fixture.ts';
await expectServeBoundaryError(
acp,
"import 'FILE:///repo/packages/cli/src/serve/index.js';",
);
await expectServeBoundaryError(
acp,
"export async function load() { await import('DATA:text/javascript,export default 1'); }",
);
});
// The URL parser strips surrounding whitespace, so ` DATA:...` loads the
// same way — scheme detection must trim before matching.
it('rejects whitespace-padded URL scheme spellings fail-closed', async () => {
await expectServeBoundaryError(
'packages/cli/src/acp-integration/boundary-fixture.ts',
"export async function load() { await import(' DATA:text/javascript,export default 1'); }",
);
});
it('rejects control-character and symlinked serve paths', async () => {
const utils = 'packages/cli/src/utils/boundary-fixture.ts';
await expectServeBoundaryError(
utils,
"export async function load() { await import('../ser\\tve/index.js'); }",
);
const link = path.join(repoRoot, 'packages/cli/src/utils/serve-link.js');
rmSync(link, { force: true });
try {
symlinkSync('../serve/index.ts', link);
await expectServeBoundaryError(
utils,
"export async function load() { await import('./serve-link.js'); }",
);
} finally {
rmSync(link, { force: true });
}
});
// Codex self-review: vitest loaders reached through an alias evade the
// `vi.`/`vitest.` identifier match; the member/bare-name matchers must
// still catch them when the specifier resolves into serve/.
it('rejects aliased vitest module-loading calls into serve', async () => {
const acp = 'packages/cli/src/acp-integration/boundary-fixture.ts';
await expectServeBoundaryError(
acp,
"import { vi as v } from 'vitest';\nv.mock('../serve/live/live-task-service.js');",
);
await expectServeBoundaryError(
acp,
"import { importActual } from 'vitest';\nexport async function load() { return importActual('../serve/live/live-task-service.js'); }",
);
// R8-2: doMock/importMock were matched by the guard but had zero
// fixture coverage — narrowing the alternation stayed green.
await expectServeBoundaryError(
acp,
"import { vi } from 'vitest';\nvi.doMock('../serve/live/live-task-service.js');",
);
await expectServeBoundaryError(
acp,
"import { vi } from 'vitest';\nvi.importMock('../serve/live/live-task-service.js');",
);
});
// Codex self-review: child_process.spawn's first argument is an
// executable resolved via PATH/cwd, not a module — it must NOT be
// treated as an import source (would false-positive legitimate code).
it('does not treat child_process.spawn arguments as import sources', async () => {
await expectNoBoundaryHits(
ACP_FIXTURE,
"import { spawn } from 'node:child_process';\nexport function run() { return spawn(process.execPath, ['--version']); }",
);
});
// R5-7: third-party packages whose name contains `serve` must not be
// caught by the boundary (the old `**/serve*` globs matched them).
it('allows third-party serve-named packages', async () => {
const code = [
"import handler from 'serve';",
"import scoped from '@scope/serve';",
"import sub from '@scope/serve/handler.js';",
'',
].join('\n');
// R10-3: filter on the rule itself, not the serveBoundary text — a
// regression routing serve-named bare specifiers to failClosed must
// also turn this pin red ('serve/ internals' is absent from the
// failClosed message).
await expectNoBoundaryHits(ACP_FIXTURE, code);
});
// R9-5: the re-export / Worker / fork / require visitors had no fixture
// pins — deleting any of them left the suite green. The bare `fork`
// spelling also covers R9-4 (the destructured child_process import
// evaded the member-only guard).
it('pins re-export, Worker, fork and require entrances', async () => {
const runtime = 'packages/cli/src/runtime/boundary-fixture.ts';
await expectServeBoundaryError(
runtime,
"export * from '../serve/index.js';",
);
await expectServeBoundaryError(
runtime,
"export { x } from '../serve/index.js';",
);
await expectServeBoundaryError(
runtime,
"new Worker('../serve/worker.js');",
);
await expectServeBoundaryError(runtime, "require('../serve/index.js');");
await expectServeBoundaryError(
runtime,
"import { fork } from 'node:child_process';\nfork('../serve/index.js');",
);
});
// R9-2: the new-URL-with-import.meta check sat in the CallExpression
// visitor (NewExpression nodes never dispatch there), so a standalone
// `new URL('../serve/...', import.meta.url)` reported nothing.
it('rejects standalone new URL(spec, import.meta.url) into serve', async () => {
await expectServeBoundaryError(
'packages/cli/src/runtime/boundary-fixture.ts',
"const u = new URL('../serve/worker.js', import.meta.url);",
);
});
// R9-7: no pin exercised the false branch of static-template
// concatenation — a pure template literal resolving OUTSIDE serve must
// stay allowed (breaking the concatenation fail-closes legitimate code).
it('allows pure template-literal imports that resolve outside serve', async () => {
await expectNoBoundaryHits(
ACP_FIXTURE,
'export async function load() { await import(`../utils/boundary-fixture.ts`); }',
);
});
// R13-2: resolution-based detections must report via the serveBoundary
// messageId — if inside-detection degrades into blanket fail-closed
// rejection the substring-based positive helper stays green, so pin the
// messageId directly.
it('reports resolution detections via the serveBoundary messageId', async () => {
for (const code of [
"import '../serve/index.js';",
"export async function load() { return import('src/serve/index.js'); }",
]) {
const [result] = await lintCliFile(ACP_FIXTURE, code);
expect(
result.messages.some(
(message) => message.messageId === 'serveBoundary',
),
).toBe(true);
}
});
// ── Round-11 review pins ─────────────────────────────────────────────
// R12-2 (round-9 ledger): the '#' fail-closed check used to sit AFTER
// stripUrlSuffixes, which splits on '#' — '#name' collapsed to '' and
// classified outside, so package-imports specifiers sailed through. The
// check now precedes suffix stripping; pin both entrances.
it('fails closed on package-imports (#) specifiers', async () => {
await expectServeBoundaryError(ACP_FIXTURE, "import '#s';");
await expectServeBoundaryError(
ACP_FIXTURE,
"export async function load() { return import('#serve-internals'); }",
);
});
// C0 controls at the specifier edges are stripped before Node's scheme
// detection — '\x01data:…' still loads a data: URL. Scheme detection
// must see the same edge-stripped form.
it('fails closed on C0-control-prefixed URL schemes', async () => {
await expectServeBoundaryError(
ACP_FIXTURE,
"export async function load() { await import('\\u0001data:text/javascript,export default 1'); }",
);
await expectServeBoundaryError(
ACP_FIXTURE,
"import '\\u0001file:///repo/packages/cli/src/serve/index.js';",
);
});
// String-code execution entrances embed import('…') the rule cannot
// resolve — fail closed like computed sources (eval/new Function have
// no shared no-eval guard in the config).
it('fails closed on string-code execution entrances', async () => {
for (const code of [
'eval("import(\'../serve/index.js\')");',
'(0, eval)("import(\'../serve/index.js\')");',
'globalThis.eval("import(\'../serve/index.js\')");',
'const load = new Function("return import(\'../serve/index.js\')");',
]) {
await expectServeBoundaryError(ACP_FIXTURE, code);
}
});
// file: URLs are "special", so Node's URL-based resolution normalizes
// backslashes to '/' — a specifier VALUE containing '\' resolves like
// the slash form even on posix.
it('rejects backslash-separated serve specifiers', async () => {
await expectServeBoundaryError(
RUNTIME_FIXTURE,
"import '..\\\\serve\\\\index.js';",
);
});
// Every getBuiltinModule arm: global.process, destructured bare
// identifier, Reflect.apply — plus the computed object-side and
// property-side spellings.
it('pins every getBuiltinModule spelling', async () => {
await expectServeBoundaryError(
ACP_FIXTURE,
"const mod = global.process.getBuiltinModule('node:module');",
);
await expectServeBoundaryError(
ACP_FIXTURE,
"const { getBuiltinModule } = process;\nconst mod = getBuiltinModule('node:module');",
);
await expectServeBoundaryError(
ACP_FIXTURE,
"const mod = Reflect.apply(process.getBuiltinModule, null, ['node:module']);",
);
await expectServeBoundaryError(
ACP_FIXTURE,
"const mod = globalThis['process'].getBuiltinModule('module');",
);
await expectServeBoundaryError(
ACP_FIXTURE,
"const mod = Reflect.apply(process['getBuiltinModule'], null, ['module']);",
);
});
// The root-absolute and file: branches must reach isInServeDir —
// 'inside' verdicts (serveBoundary), not just the fail-closed path.
it('reports absolute-path and file: imports into serve via serveBoundary', async () => {
const serveEntry = `${repoRoot}/packages/cli/src/serve/index.ts`;
for (const code of [
`import '${serveEntry}';`,
`import 'file://${serveEntry}';`,
]) {
const [result] = await lintCliFile(ACP_FIXTURE, code);
expect(
result.messages.some(
(message) => message.messageId === 'serveBoundary',
),
).toBe(true);
}
});
// The child_process.fork MEMBER arm and the template cooked-value
// choice each had zero pins (mutants survived).
it('pins the fork member arm and template cooked values', async () => {
await expectServeBoundaryError(
RUNTIME_FIXTURE,
"import * as child_process from 'node:child_process';\nchild_process.fork('../serve/index.js');",
);
await expectServeBoundaryError(
RUNTIME_FIXTURE,
'export async function load() { await import(`../\\x73erve/index.js`); }',
);
});
// fork/Worker arms are object-agnostic: namespace and default-import
// spellings must not evade the guard.
it('rejects namespace and default-import fork/Worker spellings into serve', async () => {
await expectServeBoundaryError(
RUNTIME_FIXTURE,
"import cp from 'node:child_process';\ncp.fork('../serve/index.js');",
);
await expectServeBoundaryError(
RUNTIME_FIXTURE,
"import wt from 'node:worker_threads';\nnew wt.Worker('../serve/worker.js');",
);
});
// Bare destructured vitest loader names (member forms were pinned in
// R8-2; bare mock/doMock/importMock had no pin).
it('pins bare destructured vitest loader spellings', async () => {
for (const name of ['mock', 'doMock', 'importMock']) {
await expectServeBoundaryError(
ACP_FIXTURE,
`import { ${name} } from 'vitest';\n${name}('../serve/live/live-task-service.js');`,
);
}
});
// The bare-'module' disjunct had no dynamic-entrance coverage (static
// import is intercepted earlier by the ImportDeclaration regex arm).
it('fails closed on dynamic bare-module specifiers', async () => {
await expectServeBoundaryError(
ACP_FIXTURE,
"export async function load() { return import('module'); }",
);
await expectServeBoundaryError(ACP_FIXTURE, "require('module');");
await expectServeBoundaryError(
ACP_FIXTURE,
"export { createRequire } from 'module';",
);
});
// new Worker(new URL(spec, import.meta.url)) belongs to the URL arm:
// boundary-clean targets produce ZERO diagnostics (no fail-closed on a
// fully static construct), serve targets exactly ONE serveBoundary.
it('lets the URL arm own new Worker(new URL(spec, import.meta.url))', async () => {
await expectNoBoundaryHits(
RUNTIME_FIXTURE,
"const w = new Worker(new URL('./worker.js', import.meta.url));",
);
const [result] = await lintCliFile(
RUNTIME_FIXTURE,
"const w = new Worker(new URL('../serve/worker.js', import.meta.url));",
);
const hits = result.messages.filter(
(message) => message.ruleId === RULE_ID,
);
expect(hits).toHaveLength(1);
expect(hits[0].messageId).toBe('serveBoundary');
});
// The outside-serve (allow) verdict of the checkSource arms had zero
// negative pins — mutating any arm to unconditional fail-closed stayed
// green.
it('allows URL/Worker/fork/require targets that resolve outside serve', async () => {
for (const code of [
"const u = new URL('../utils/foo.js', import.meta.url);",
"new Worker('../utils/worker.js');",
"require('../utils/foo.js');",
"import cp from 'node:child_process';\ncp.fork('../utils/foo.js');",
]) {
await expectNoBoundaryHits(RUNTIME_FIXTURE, code);
}
});
// import x = require('../serve/…') — tsc under NodeNext emits a working
// createRequire shim, so the spelling loads at runtime.
it('rejects import-equals-require into serve', async () => {
await expectServeBoundaryError(
ACP_FIXTURE,
"import x = require('../serve/index.js');",
);
});
// ── Round-12 review pins ─────────────────────────────────────────────
// Symlink canonicalization must be symmetric: the baseUrl arm realpath's
// the candidate AND the comparison side is canonicalized, so a
// committable symlink inside the baseUrl tree pointing into serve/ is
// caught (tsc/esbuild follow it), while a link pointing outside stays
// allowed.
it('catches baseUrl symlinks that point into serve', async () => {
const cliDir = path.join(repoRoot, 'packages/cli');
const intoServe = path.join(cliDir, 'serve-alias-fixture');
const outOfServe = path.join(cliDir, 'utils-alias-fixture');
let created = false;
try {
symlinkSync(path.join(cliDir, 'src/serve'), intoServe);
symlinkSync(path.join(cliDir, 'src/utils'), outOfServe);
created = true;
} catch {
// Platforms without unprivileged symlink support: nothing to pin.
}
if (!created) return;
try {
await expectServeBoundaryError(
ACP_FIXTURE,
"import 'serve-alias-fixture/index.ts';",
);
await expectNoBoundaryHits(
ACP_FIXTURE,
"import 'utils-alias-fixture/foo.ts';",
);
} finally {
rmSync(intoServe, { force: true });
rmSync(outOfServe, { force: true });
}
});
// Callee identity is shape-tolerant: nested member objects, computed
// template-literal properties, and renamed bindings must not evade the
// loader/fork/eval/getBuiltinModule arms.
it('catches shape-variant callee spellings', async () => {
for (const code of [
// nested member objects evade Identifier-only object checks
"globalThis.vi.mock('../serve/live/live-task-service.js');",
"x.cp.fork('../serve/index.js');",
// expression-free template-literal properties
"vi[`mock`]('../serve/live/live-task-service.js');",
"cp[`fork`]('../serve/index.js');",
'globalThis[`eval`]("import(\'../serve/index.js\')");',
"process[`getBuiltinModule`]('module');",
"Reflect[`apply`](process.getBuiltinModule, null, ['module']);",
]) {
await expectServeBoundaryError(ACP_FIXTURE, code);
}
});
it('catches renamed loader bindings and Reflect indirection', async () => {
for (const code of [
"import { Worker as W } from 'node:worker_threads';\nnew W('../serve/worker.js');",
"import { fork as f } from 'node:child_process';\nf('../serve/index.js');",
"Reflect.construct(Worker, ['../serve/worker.js']);",
"Reflect.apply(require, null, ['../serve/index.js']);",
"Reflect.apply(fork, null, ['../serve/index.js']);",
]) {
await expectServeBoundaryError(ACP_FIXTURE, code);
}
});
it('catches call/apply/bind indirection on guarded loaders', async () => {
for (const code of [
"(0, require)('../serve/index.js');",
"require.call(null, '../serve/index.js');",
"require.apply(null, ['../serve/index.js']);",
"fork.bind(null)('../serve/index.js');",
"process.getBuiltinModule.call(process, 'node:module');",
"process.getBuiltinModule.apply(process, ['node:module']);",
]) {
await expectServeBoundaryError(ACP_FIXTURE, code);
}
});
// The string-code execution class: call-without-new, member spellings,
// .constructor chains, the node:vm surface, and Worker's eval option —
// all compile/run arbitrary string code that can import() anything.
it('fails closed on the string-code execution class', async () => {
for (const code of [
'const f = Function("return import(\'../serve/index.js\')");',
'new globalThis.Function("return import(\'../serve/index.js\')")();',
"globalThis.Function('x')();",
"Function('x').bind(null)();",
'eval.call(null, "import(\'../serve/index.js\')");',
'eval.apply(null, ["import(\'../serve/index.js\')"]);',
'({}).constructor.constructor("return import(\'../serve/index.js\')")()();',
'(function(){}).constructor("return import(\'../serve/index.js\')");',
'[].constructor.constructor("return import(\'../serve/index.js\')")()();',
"import vm from 'node:vm';\nvm.runInThisContext('x');",
"import vm from 'node:vm';\nvm.runInNewContext('x');",
"import vm from 'node:vm';\nvm.compileFunction('x');",
"import { runInContext } from 'node:vm';\nrunInContext('x', {});",
"import vm from 'node:vm';\nnew vm.Script('x');",
"new Worker('x', { eval: true });",
"new Worker('x', options);",
]) {
await expectServeBoundaryError(ACP_FIXTURE, code);
}
// eval: false is statically verifiable — the specifier path applies.
await expectServeBoundaryError(
ACP_FIXTURE,
"new Worker('../serve/worker.js', { eval: false });",
);
await expectNoBoundaryHits(
ACP_FIXTURE,
"new Worker('../utils/worker.js', { eval: false });",
);
// messageId-specific: the eval:true form reports failClosed (arg0 is
// code, never a specifier), whatever the first argument looks like.
const [evalTrue] = await lintCliFile(
ACP_FIXTURE,
'new Worker("import(\'../serve/worker.js\')", { eval: true });',
);
expect(
evalTrue.messages.some(
(message) =>
message.ruleId === RULE_ID && message.messageId === 'failClosed',
),
).toBe(true);
});
// The URL arm resolves only the import.meta.url base; any other
// import.meta member is statically unresolvable — fail closed, never
// assume the module base.
it('fails closed on non-url import.meta bases', async () => {
await expectServeBoundaryError(
ACP_FIXTURE,
"const u = new URL('../serve/index.js', import.meta.resolve);",
);
await expectServeBoundaryError(
ACP_FIXTURE,
"const w = new Worker(new URL('../serve/worker.js', import.meta.resolve));",
);
// messageId-specific: an unresolvable base reports failClosed, not
// serveBoundary — the specifier never resolves.
for (const code of [
"const u = new URL('../serve/index.js', import.meta.env);",
"const w = new Worker(new URL('./worker.js', import.meta.env));",
]) {
const [result] = await lintCliFile(ACP_FIXTURE, code);
expect(
result.messages.some(
(message) =>
message.ruleId === RULE_ID && message.messageId === 'failClosed',
),
).toBe(true);
}
});
// stripUrlSuffixes must also protect the bare-directory and baseUrl
// spellings, not just full-file specifiers.
it('strips query/fragment suffixes from bare serve spellings', async () => {
await expectServeBoundaryError(RUNTIME_FIXTURE, "import '../serve?foo';");
await expectServeBoundaryError(
ACP_FIXTURE,
"import 'src/serve/index.js?v=1';",
);
});
// The outside-serve (allow) verdict needs pins for the export and
// import-equals arms too — otherwise mutating them to unconditional
// fail-closed stays green.
it('allows exports and import-equals that resolve outside serve', async () => {
for (const code of [
"export * from '../utils/foo.js';",
"export { x } from '../utils/foo.js';",
"import x = require('../utils/foo.js');",
]) {
await expectNoBoundaryHits(ACP_FIXTURE, code);
}
});
});
<EFBFBD>٩<EFBFBD><EFBFBD>hof<EFBFBD>