From 18772ed9f14b442e93d955127e3e4c71b26925d1 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 14 Aug 2026 17:20:16 +0900 Subject: [PATCH 01/26] refactor(cli): keep acp-integration off serve internals (#8084) The dependency direction set in #8084 regressed: native Live Voice (a5c637b749) added four acp-integration imports of serve/live modules, because nothing in the repo enforces the boundary the issue defines. Ownership, measured by consumer rather than by directory: - capture-screen-context, live-task-tools, live-speak-to-user and live-backend-instructions each have exactly one production consumer, acp-integration/session/Session.ts, and import nothing from serve/. They move to acp-integration/live/ with their tests. - conversations/session-source is shared by acpAgent and four serve modules, has no imports, and takes its reader as a parameter, so it moves to runtime/live-session-source.ts alongside the other neutral contracts. Renamed because every symbol in it is Live-specific. Adds a no-restricted-imports rule for acp-integration/** so the next feature spanning both surfaces gets a lint error pointing at runtime/, rather than silently reopening the criterion. No behavior change: moves, import rewrites, and the lint block. --- eslint.config.js | 20 +++++++++++++++++++ packages/cli/src/acp-integration/acpAgent.ts | 2 +- .../live/capture-screen-context.test.ts | 0 .../live/capture-screen-context.ts | 0 .../live/live-backend-instructions.test.ts | 0 .../live/live-backend-instructions.ts | 0 .../live/live-speak-to-user.test.ts | 0 .../live/live-speak-to-user.ts | 0 .../live/live-task-tools.test.ts | 0 .../live/live-task-tools.ts | 0 .../acp-integration/session/Session.test.ts | 4 ++-- .../src/acp-integration/session/Session.ts | 8 ++++---- .../live-session-source.test.ts} | 2 +- .../live-session-source.ts} | 0 packages/cli/src/serve/acp-http/dispatch.ts | 2 +- .../serve/live/live-session-coordinator.ts | 4 ++-- .../src/serve/live/live-task-service.test.ts | 2 +- .../cli/src/serve/live/live-task-service.ts | 2 +- .../serve/multi-workspace-sessions.test.ts | 2 +- packages/cli/src/serve/routes/session.ts | 2 +- 20 files changed, 35 insertions(+), 15 deletions(-) rename packages/cli/src/{serve => acp-integration}/live/capture-screen-context.test.ts (100%) rename packages/cli/src/{serve => acp-integration}/live/capture-screen-context.ts (100%) rename packages/cli/src/{serve => acp-integration}/live/live-backend-instructions.test.ts (100%) rename packages/cli/src/{serve => acp-integration}/live/live-backend-instructions.ts (100%) rename packages/cli/src/{serve => acp-integration}/live/live-speak-to-user.test.ts (100%) rename packages/cli/src/{serve => acp-integration}/live/live-speak-to-user.ts (100%) rename packages/cli/src/{serve => acp-integration}/live/live-task-tools.test.ts (100%) rename packages/cli/src/{serve => acp-integration}/live/live-task-tools.ts (100%) rename packages/cli/src/{serve/conversations/session-source.test.ts => runtime/live-session-source.test.ts} (98%) rename packages/cli/src/{serve/conversations/session-source.ts => runtime/live-session-source.ts} (100%) diff --git a/eslint.config.js b/eslint.config.js index cda07f41d5e..e6df47700bd 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -72,6 +72,26 @@ export default tseslint.config( 'import/namespace': 'off', // Disabled due to https://github.com/import-js/eslint-plugin-import/issues/2866 }, }, + { + // ACP integration and the daemon are separate runtime surfaces that happen + // to share a package directory. ACP may consume neutral contracts under + // `runtime/`, but never `serve/` implementation modules — see #8084. + files: ['packages/cli/src/acp-integration/**/*.{ts,tsx}'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['**/serve/*', '**/serve/**'], + message: + 'acp-integration must not import serve/ internals. Put shared, lifecycle-free logic in packages/cli/src/runtime/ instead (#8084).', + }, + ], + }, + ], + }, + }, { // General overrides and rules for the project (TS/TSX files) files: [ diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 52ace9ca899..544e54031ad 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -213,7 +213,7 @@ import { type PermissionRuleSet, } from '../config/permission-settings.js'; import { createLoadedSettingsAdapter } from '../config/loadedSettingsAdapter.js'; -import { isCompatibleLiveSessionSource } from '../serve/conversations/session-source.js'; +import { isCompatibleLiveSessionSource } from '../runtime/live-session-source.js'; import type { ApprovalModeValue } from './session/types.js'; import { z } from 'zod'; import type { CliArgs } from '../config/config.js'; diff --git a/packages/cli/src/serve/live/capture-screen-context.test.ts b/packages/cli/src/acp-integration/live/capture-screen-context.test.ts similarity index 100% rename from packages/cli/src/serve/live/capture-screen-context.test.ts rename to packages/cli/src/acp-integration/live/capture-screen-context.test.ts diff --git a/packages/cli/src/serve/live/capture-screen-context.ts b/packages/cli/src/acp-integration/live/capture-screen-context.ts similarity index 100% rename from packages/cli/src/serve/live/capture-screen-context.ts rename to packages/cli/src/acp-integration/live/capture-screen-context.ts diff --git a/packages/cli/src/serve/live/live-backend-instructions.test.ts b/packages/cli/src/acp-integration/live/live-backend-instructions.test.ts similarity index 100% rename from packages/cli/src/serve/live/live-backend-instructions.test.ts rename to packages/cli/src/acp-integration/live/live-backend-instructions.test.ts diff --git a/packages/cli/src/serve/live/live-backend-instructions.ts b/packages/cli/src/acp-integration/live/live-backend-instructions.ts similarity index 100% rename from packages/cli/src/serve/live/live-backend-instructions.ts rename to packages/cli/src/acp-integration/live/live-backend-instructions.ts diff --git a/packages/cli/src/serve/live/live-speak-to-user.test.ts b/packages/cli/src/acp-integration/live/live-speak-to-user.test.ts similarity index 100% rename from packages/cli/src/serve/live/live-speak-to-user.test.ts rename to packages/cli/src/acp-integration/live/live-speak-to-user.test.ts diff --git a/packages/cli/src/serve/live/live-speak-to-user.ts b/packages/cli/src/acp-integration/live/live-speak-to-user.ts similarity index 100% rename from packages/cli/src/serve/live/live-speak-to-user.ts rename to packages/cli/src/acp-integration/live/live-speak-to-user.ts diff --git a/packages/cli/src/serve/live/live-task-tools.test.ts b/packages/cli/src/acp-integration/live/live-task-tools.test.ts similarity index 100% rename from packages/cli/src/serve/live/live-task-tools.test.ts rename to packages/cli/src/acp-integration/live/live-task-tools.test.ts diff --git a/packages/cli/src/serve/live/live-task-tools.ts b/packages/cli/src/acp-integration/live/live-task-tools.ts similarity index 100% rename from packages/cli/src/serve/live/live-task-tools.ts rename to packages/cli/src/acp-integration/live/live-task-tools.ts diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 0d423e91be2..7d0e561064a 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -54,8 +54,8 @@ import * as nonInteractiveCliCommands from '../../nonInteractiveCliCommands.js'; import { CommandKind } from '../../ui/commands/types.js'; import { buildAcpModelOptions } from '../../utils/acpModelUtils.js'; import { CHANNEL_PROMPT_META_KEY } from '@qwen-code/channel-base'; -import { CAPTURE_SCREEN_CONTEXT_TOOL_NAME } from '../../serve/live/capture-screen-context.js'; -import { SPEAK_TO_USER_TOOL_NAME } from '../../serve/live/live-speak-to-user.js'; +import { CAPTURE_SCREEN_CONTEXT_TOOL_NAME } from '../live/capture-screen-context.js'; +import { SPEAK_TO_USER_TOOL_NAME } from '../live/live-speak-to-user.js'; import { collectHistoryReplayUpdates, createReplayCumulativeUsage, diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 711d7f22812..4cf2dd2edd4 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -209,19 +209,19 @@ import { normalizeChannelDeliveryText } from '../../runtime/channel-delivery.js' import { CAPTURE_SCREEN_CONTEXT_TOOL_NAME, CaptureScreenContextTool, -} from '../../serve/live/capture-screen-context.js'; +} from '../live/capture-screen-context.js'; import { createLiveTaskTools, type LiveTaskTool, -} from '../../serve/live/live-task-tools.js'; +} from '../live/live-task-tools.js'; import { SPEAK_TO_USER_TOOL_NAME, SpeakToUserTool, -} from '../../serve/live/live-speak-to-user.js'; +} from '../live/live-speak-to-user.js'; import { LIVE_BACKEND_END_INSTRUCTIONS, LIVE_BACKEND_START_INSTRUCTIONS, -} from '../../serve/live/live-backend-instructions.js'; +} from '../live/live-backend-instructions.js'; import { readVoiceModel } from '../../services/voice-settings.js'; import { MAX_AUDIO_BYTES, diff --git a/packages/cli/src/serve/conversations/session-source.test.ts b/packages/cli/src/runtime/live-session-source.test.ts similarity index 98% rename from packages/cli/src/serve/conversations/session-source.test.ts rename to packages/cli/src/runtime/live-session-source.test.ts index c8f3020d311..630a997d21a 100644 --- a/packages/cli/src/serve/conversations/session-source.test.ts +++ b/packages/cli/src/runtime/live-session-source.test.ts @@ -8,7 +8,7 @@ import { describe, expect, it } from 'vitest'; import { isReservedLiveSessionSource, readLoadableLiveConversationMetadata, -} from './session-source.js'; +} from './live-session-source.js'; describe('readLoadableLiveConversationMetadata', () => { const records = new Map([ diff --git a/packages/cli/src/serve/conversations/session-source.ts b/packages/cli/src/runtime/live-session-source.ts similarity index 100% rename from packages/cli/src/serve/conversations/session-source.ts rename to packages/cli/src/runtime/live-session-source.ts diff --git a/packages/cli/src/serve/acp-http/dispatch.ts b/packages/cli/src/serve/acp-http/dispatch.ts index 354196904af..37c2d5a7066 100644 --- a/packages/cli/src/serve/acp-http/dispatch.ts +++ b/packages/cli/src/serve/acp-http/dispatch.ts @@ -55,7 +55,7 @@ import { restoreRetryAfterSeconds } from '@qwen-code/acp-bridge/sessionRestoreTi import { isReservedLiveSessionSource, readLoadableLiveConversationMetadata, -} from '../conversations/session-source.js'; +} from '../../runtime/live-session-source.js'; import { translateAndCheckAbsoluteWorkspacePath, canonicalizeWorkspace, diff --git a/packages/cli/src/serve/live/live-session-coordinator.ts b/packages/cli/src/serve/live/live-session-coordinator.ts index c4560b705ba..f3058a3ec2d 100644 --- a/packages/cli/src/serve/live/live-session-coordinator.ts +++ b/packages/cli/src/serve/live/live-session-coordinator.ts @@ -42,10 +42,10 @@ import type { LiveProviderCredential } from './provider-credentials.js'; import { isCompatibleLiveSessionSource, LIVE_SESSION_SOURCE_PREFIX, -} from '../conversations/session-source.js'; +} from '../../runtime/live-session-source.js'; import type { LiveProviderReadiness, LiveSessionLocator } from './types.js'; -export { LIVE_SESSION_SOURCE_PREFIX } from '../conversations/session-source.js'; +export { LIVE_SESSION_SOURCE_PREFIX } from '../../runtime/live-session-source.js'; const MAX_COORDINATOR_REQUEST_CHARS = 32_000; const MAX_COORDINATOR_RESULT_CHARS = 48_000; diff --git a/packages/cli/src/serve/live/live-task-service.test.ts b/packages/cli/src/serve/live/live-task-service.test.ts index b2d871b1ded..f99bc5b81ab 100644 --- a/packages/cli/src/serve/live/live-task-service.test.ts +++ b/packages/cli/src/serve/live/live-task-service.test.ts @@ -16,7 +16,7 @@ import type { WorkspaceRuntime, } from '../workspace-registry.js'; import { LiveTaskService } from './live-task-service.js'; -import { LIVE_SESSION_SOURCE_PREFIX } from '../conversations/session-source.js'; +import { LIVE_SESSION_SOURCE_PREFIX } from '../../runtime/live-session-source.js'; const persistedSessions = vi.hoisted(() => new Map()); const persistedSessionOwners = vi.hoisted(() => new Map()); diff --git a/packages/cli/src/serve/live/live-task-service.ts b/packages/cli/src/serve/live/live-task-service.ts index 3c1ce7e27eb..de9486cdcd9 100644 --- a/packages/cli/src/serve/live/live-task-service.ts +++ b/packages/cli/src/serve/live/live-task-service.ts @@ -37,7 +37,7 @@ import { listWorkspaceSessionsForResponse } from '../server/session-list.js'; import { isCompatibleLiveSessionSource, readLoadableLiveConversationMetadata, -} from '../conversations/session-source.js'; +} from '../../runtime/live-session-source.js'; const DEFAULT_LIST_LIMIT = 20; const DEFAULT_READ_TURN_LIMIT = 3; diff --git a/packages/cli/src/serve/multi-workspace-sessions.test.ts b/packages/cli/src/serve/multi-workspace-sessions.test.ts index 896ce17ae85..886b78e3b6a 100644 --- a/packages/cli/src/serve/multi-workspace-sessions.test.ts +++ b/packages/cli/src/serve/multi-workspace-sessions.test.ts @@ -40,7 +40,7 @@ import { } from './workspace-registry.js'; import type { WorkspaceRuntimeProvenance } from './managed-scratch-workspace.js'; import type { ConversationWorkspace } from './conversations/conversation-workspace.js'; -import { LIVE_SESSION_SOURCE_PREFIX } from './conversations/session-source.js'; +import { LIVE_SESSION_SOURCE_PREFIX } from '../runtime/live-session-source.js'; import { createSessionOrganizationService } from './session-organization-helpers.js'; import { serializeWorkspaceTranscriptResponseForTesting, diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index f9dbed0b88b..5b582811160 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -39,7 +39,7 @@ import { parseSessionSource } from '@qwen-code/acp-bridge'; import { isReservedLiveSessionSource, readLoadableLiveConversationMetadata, -} from '../conversations/session-source.js'; +} from '../../runtime/live-session-source.js'; import type { Application, Request, RequestHandler, Response } from 'express'; import { writeStderrLine } from '../../utils/stdioHelpers.js'; import { parseCallerSuppliedSessionId } from '../../config/session-id.js'; From 0691cb95bd3958ee89fa3a5a8b6356be56623579 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Fri, 14 Aug 2026 23:52:55 +0800 Subject: [PATCH 02/26] fix(cli): harden the acp/serve boundary guard (round 2) - Flag the bare '../serve' directory specifier, which resolves to the serve/ barrel and skipped the trailing-segment group patterns (also added to the utils/ guard for symmetry). - Extend the same boundary to runtime/, the layer the rule directs authors to, so the #8084 coupling cannot reform one hop away. - Cover dynamic imports: no-restricted-imports never visits ImportExpression, so a no-restricted-syntax selector now enforces the boundary for await import('../serve/...') too. The acp-integration block moves after the general TS block (flat config lets the last matching block win per rule) and restates its no-restricted-syntax selectors so the override drops nothing. - Document that CI lint is the enforcement point; no fixture test pins the block. Verified: synthetic fixtures for all three violation shapes are rejected; full npm run lint passes with no live violations. --- eslint.config.js | 68 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 61 insertions(+), 7 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 84a3f66a3f1..6585bcf5945 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -73,19 +73,19 @@ export default tseslint.config( }, }, { - // ACP integration and the daemon are separate runtime surfaces that happen - // to share a package directory. ACP may consume neutral contracts under - // `runtime/`, but never `serve/` implementation modules — see #8084. - files: ['packages/cli/src/acp-integration/**/*.{ts,tsx}'], + // `runtime/` is the neutral layer acp-integration is directed to; it must + // not import `serve/` internals itself, or the #8084 boundary reforms + // transitively one hop away. + files: ['packages/cli/src/runtime/**/*.{ts,tsx}'], rules: { 'no-restricted-imports': [ 'error', { patterns: [ { - group: ['**/serve/*', '**/serve/**'], + group: ['**/serve', '**/serve/*', '**/serve/**'], message: - 'acp-integration must not import serve/ internals. Put shared, lifecycle-free logic in packages/cli/src/runtime/ instead (#8084).', + 'packages/cli/src/runtime must not import serve/ internals (#8084).', }, ], }, @@ -104,7 +104,9 @@ export default tseslint.config( { patterns: [ { - group: ['**/serve/*', '**/serve/**'], + // `**/serve` also covers the bare directory specifier, which + // resolves to the serve/ barrel. + group: ['**/serve', '**/serve/*', '**/serve/**'], message: 'packages/cli/src/utils must not import serve/. Move lifecycle-free logic down into utils/ instead (#9146).', }, @@ -217,6 +219,58 @@ export default tseslint.config( 'default-case': 'error', }, }, + { + // ACP integration and the daemon are separate runtime surfaces that happen + // to share a package directory. ACP may consume neutral contracts under + // `runtime/`, but never `serve/` implementation modules — see #8084. + // Enforcement point is `npm run lint` in CI; no fixture test pins this + // block — accepted trade-off, the boundary lives in config, not code. + // + // Positioned after the general TS block on purpose: flat config lets the + // last matching block win per rule, and that block also configures + // `no-restricted-syntax` — placing this one earlier would silently lose + // the dynamic-import guard below. Its two selectors are restated here so + // this override does not drop them for acp-integration files. + files: ['packages/cli/src/acp-integration/**/*.{ts,tsx}'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + // `**/serve` also covers the bare directory specifier, which + // resolves to the serve/ barrel (createServeApp, runQwenServe…). + group: ['**/serve', '**/serve/*', '**/serve/**'], + message: + 'acp-integration must not import serve/ internals. Put shared, lifecycle-free logic in packages/cli/src/runtime/ instead (#8084).', + }, + ], + }, + ], + 'no-restricted-syntax': [ + 'error', + { + selector: 'CallExpression[callee.name="require"]', + message: 'Avoid using require(). Use ES6 imports instead.', + }, + { + selector: 'ThrowStatement > Literal:not([value=/^\\w+Error:/])', + message: + 'Do not throw string literals or non-Error objects. Throw new Error("...") instead.', + }, + // no-restricted-imports only visits static import/export declarations; + // the same boundary applies to dynamic `await import('../serve/…')`. + // The selector regex spells `/` as `\x2f` because esquery cannot parse + // a literal slash inside its attribute-regex syntax. + { + selector: + "ImportExpression[source.value=/^(?:\\.\\.\\x2f)+serve(?:\\x2f|$)/]", + message: + 'acp-integration must not dynamically import serve/ internals. Put shared, lifecycle-free logic in packages/cli/src/runtime/ instead (#8084).', + }, + ], + }, + }, { files: [ 'packages/web-shell/client/**/*.{ts,tsx}', From ac8313788d211064dc98e0ba82dfdae1fae24336 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sat, 15 Aug 2026 10:20:14 +0800 Subject: [PATCH 03/26] test(cli): pin serve boundary lint rules --- eslint.config.js | 135 +++++++++++++------- scripts/tests/eslint-boundary-rules.test.js | 53 ++++++++ 2 files changed, 142 insertions(+), 46 deletions(-) create mode 100644 scripts/tests/eslint-boundary-rules.test.js diff --git a/eslint.config.js b/eslint.config.js index 6585bcf5945..9d02618cb89 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -17,6 +17,51 @@ import storybook from 'eslint-plugin-storybook'; import checkFile from 'eslint-plugin-check-file'; import { legacyFilenames } from './eslint.legacy-filenames.mjs'; +const serveImportPatterns = ['**/serve', '**/serve/*', '**/serve/**']; +const relativeServeImportPatterns = [ + '../serve', + '../serve/*', + '../serve/**', + '../../serve', + '../../serve/*', + '../../serve/**', +]; +const serveDynamicImportPathPattern = + String.raw`^(?:\.\.\x2f)+(?:[^\x2f]+\x2f\.\.\x2f)*serve(?:\x2f|$)`; + +const restrictedServeImports = (message) => ({ + patterns: [ + { + // `**/serve` also covers the bare directory specifier, which resolves to + // the serve/ barrel. + group: [...serveImportPatterns, ...relativeServeImportPatterns], + message, + }, + ], +}); + +const restrictedServeDynamicImports = (message) => [ + { + selector: `ImportExpression[source.value=/${serveDynamicImportPathPattern}/]`, + message, + }, + { + selector: `ImportExpression[source.quasis.0.value.cooked=/${serveDynamicImportPathPattern}/]`, + message, + }, +]; + +const restrictedRequire = { + selector: 'CallExpression[callee.name="require"]', + message: 'Avoid using require(). Use ES6 imports instead.', +}; + +const restrictedStringThrow = { + selector: 'ThrowStatement > Literal:not([value=/^\\w+Error:/])', + message: + 'Do not throw string literals or non-Error objects. Throw new Error("...") instead.', +}; + export default tseslint.config( { // Global ignores @@ -80,15 +125,9 @@ export default tseslint.config( rules: { 'no-restricted-imports': [ 'error', - { - patterns: [ - { - group: ['**/serve', '**/serve/*', '**/serve/**'], - message: - 'packages/cli/src/runtime must not import serve/ internals (#8084).', - }, - ], - }, + restrictedServeImports( + 'packages/cli/src/runtime must not import serve/ internals (#8084).', + ), ], }, }, @@ -101,17 +140,9 @@ export default tseslint.config( rules: { 'no-restricted-imports': [ 'error', - { - patterns: [ - { - // `**/serve` also covers the bare directory specifier, which - // resolves to the serve/ barrel. - group: ['**/serve', '**/serve/*', '**/serve/**'], - message: - 'packages/cli/src/utils must not import serve/. Move lifecycle-free logic down into utils/ instead (#9146).', - }, - ], - }, + restrictedServeImports( + 'packages/cli/src/utils must not import serve/. Move lifecycle-free logic down into utils/ instead (#9146).', + ), ], }, }, @@ -219,6 +250,36 @@ export default tseslint.config( 'default-case': 'error', }, }, + { + // Positioned after the general TS block so the dynamic-import guard is not + // overwritten by the shared `no-restricted-syntax` rule. + files: ['packages/cli/src/runtime/**/*.{ts,tsx}'], + rules: { + 'no-restricted-syntax': [ + 'error', + restrictedRequire, + restrictedStringThrow, + ...restrictedServeDynamicImports( + 'packages/cli/src/runtime must not dynamically import serve/ internals (#8084).', + ), + ], + }, + }, + { + // Positioned after the general TS block so the dynamic-import guard is not + // overwritten by the shared `no-restricted-syntax` rule. + files: ['packages/cli/src/utils/**/*.{ts,tsx}'], + rules: { + 'no-restricted-syntax': [ + 'error', + restrictedRequire, + restrictedStringThrow, + ...restrictedServeDynamicImports( + 'packages/cli/src/utils must not dynamically import serve/. Move lifecycle-free logic down into utils/ instead (#9146).', + ), + ], + }, + }, { // ACP integration and the daemon are separate runtime surfaces that happen // to share a package directory. ACP may consume neutral contracts under @@ -235,39 +296,21 @@ export default tseslint.config( rules: { 'no-restricted-imports': [ 'error', - { - patterns: [ - { - // `**/serve` also covers the bare directory specifier, which - // resolves to the serve/ barrel (createServeApp, runQwenServe…). - group: ['**/serve', '**/serve/*', '**/serve/**'], - message: - 'acp-integration must not import serve/ internals. Put shared, lifecycle-free logic in packages/cli/src/runtime/ instead (#8084).', - }, - ], - }, + restrictedServeImports( + 'acp-integration must not import serve/ internals. Put shared, lifecycle-free logic in packages/cli/src/runtime/ instead (#8084).', + ), ], 'no-restricted-syntax': [ 'error', - { - selector: 'CallExpression[callee.name="require"]', - message: 'Avoid using require(). Use ES6 imports instead.', - }, - { - selector: 'ThrowStatement > Literal:not([value=/^\\w+Error:/])', - message: - 'Do not throw string literals or non-Error objects. Throw new Error("...") instead.', - }, + restrictedRequire, + restrictedStringThrow, // no-restricted-imports only visits static import/export declarations; // the same boundary applies to dynamic `await import('../serve/…')`. // The selector regex spells `/` as `\x2f` because esquery cannot parse // a literal slash inside its attribute-regex syntax. - { - selector: - "ImportExpression[source.value=/^(?:\\.\\.\\x2f)+serve(?:\\x2f|$)/]", - message: - 'acp-integration must not dynamically import serve/ internals. Put shared, lifecycle-free logic in packages/cli/src/runtime/ instead (#8084).', - }, + ...restrictedServeDynamicImports( + 'acp-integration must not dynamically import serve/ internals. Put shared, lifecycle-free logic in packages/cli/src/runtime/ instead (#8084).', + ), ], }, }, diff --git a/scripts/tests/eslint-boundary-rules.test.js b/scripts/tests/eslint-boundary-rules.test.js new file mode 100644 index 00000000000..bd278888881 --- /dev/null +++ b/scripts/tests/eslint-boundary-rules.test.js @@ -0,0 +1,53 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ESLint } from 'eslint'; +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 lintCliFile = (filePath, code) => + eslint.lintText(code, { filePath: path.join(repoRoot, filePath) }); + +const expectServeBoundaryError = async (filePath, code) => { + const [result] = await lintCliFile(filePath, code); + expect(result.messages.map((message) => message.message)).toEqual( + expect.arrayContaining([expect.stringContaining('serve')]), + ); +}; + +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'); }", + ); + }); +}); From ec6347707d77c7573136886d7fa13c1b36fc0367 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sat, 15 Aug 2026 11:27:23 +0800 Subject: [PATCH 04/26] test(cli): close serve boundary lint gaps --- eslint.config.js | 13 +++--------- scripts/tests/eslint-boundary-rules.test.js | 22 +++++++++++++++++++++ 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 9d02618cb89..ebf19dc4255 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -27,7 +27,7 @@ const relativeServeImportPatterns = [ '../../serve/**', ]; const serveDynamicImportPathPattern = - String.raw`^(?:\.\.\x2f)+(?:[^\x2f]+\x2f\.\.\x2f)*serve(?:\x2f|$)`; + String.raw`^(?:\.\x2f)*(?:\.\.\x2f)+(?:[^\x2f]+\x2f(?:\.\.\x2f)*)*serve(?:\x2f|$)`; const restrictedServeImports = (message) => ({ patterns: [ @@ -223,15 +223,8 @@ export default tseslint.config( 'no-duplicate-case': 'error', 'no-restricted-syntax': [ 'error', - { - selector: 'CallExpression[callee.name="require"]', - message: 'Avoid using require(). Use ES6 imports instead.', - }, - { - selector: 'ThrowStatement > Literal:not([value=/^\\w+Error:/])', - message: - 'Do not throw string literals or non-Error objects. Throw new Error("...") instead.', - }, + restrictedRequire, + restrictedStringThrow, ], 'no-unsafe-finally': 'error', 'no-console': 'error', diff --git a/scripts/tests/eslint-boundary-rules.test.js b/scripts/tests/eslint-boundary-rules.test.js index bd278888881..f44b622afe2 100644 --- a/scripts/tests/eslint-boundary-rules.test.js +++ b/scripts/tests/eslint-boundary-rules.test.js @@ -49,5 +49,27 @@ describe('eslint cli serve boundary rules', () => { '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'); }", + ); }); }); From 78ead131be4757bdc41f388a150493976f58fa16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E8=89=AF?= <1204183885@qq.com> Date: Sat, 15 Aug 2026 07:28:15 +0000 Subject: [PATCH 05/26] fix(lint): close serve-boundary entrances and harden the guard - reject computed dynamic-import sources (concatenation, new URL) and type-level imports fail-closed; rounds 2-5 each demonstrated a new per-spelling regex entrance, so non-literal forms are blocked outright (R4-1) - rewrite the boundary patterns without nested quantifiers; the previous shape backtracked exponentially (~4x per two ../ segments, lint-time ReDoS) (R5-2) - build the three guarded override blocks no-restricted-syntax arrays from one shared helper so flat config last-wins cannot silently drop selectors (R5-3) - pin the bare-directory barrel specifier in fixtures (R5-4) and add a string-throw probe pinning the restated selectors in the overrides (R5-5) - replace the **/serve* static globs with enumerated relative depths so third-party serve-named packages are never flagged (R5-7) --- eslint.config.js | 131 ++++++++++++-------- scripts/tests/eslint-boundary-rules.test.js | 79 ++++++++++++ 2 files changed, 157 insertions(+), 53 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index ebf19dc4255..39ca8be5261 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -17,36 +17,64 @@ import storybook from 'eslint-plugin-storybook'; import checkFile from 'eslint-plugin-check-file'; import { legacyFilenames } from './eslint.legacy-filenames.mjs'; -const serveImportPatterns = ['**/serve', '**/serve/*', '**/serve/**']; -const relativeServeImportPatterns = [ - '../serve', - '../serve/*', - '../serve/**', - '../../serve', - '../../serve/*', - '../../serve/**', -]; -const serveDynamicImportPathPattern = - String.raw`^(?:\.\x2f)*(?:\.\.\x2f)+(?:[^\x2f]+\x2f(?:\.\.\x2f)*)*serve(?:\x2f|$)`; +// Static-import guard: relative specifiers only, enumerated by depth. The +// earlier `**/serve*` globs also matched third-party package names (`serve`, +// `@scope/serve`, `@scope/serve/subpath`), so bare/scoped specifiers must +// never reach the boundary check; imports deeper than six levels below src/ +// do not occur in the guarded trees. `../serve` (bare directory) resolves to +// the serve/ barrel and stays covered. +const relativeServeImportPatterns = []; +for (let depth = 1; depth <= 6; depth++) { + const prefix = '../'.repeat(depth); + relativeServeImportPatterns.push( + `${prefix}serve`, + `${prefix}serve/*`, + `${prefix}serve/**`, + ); +} const restrictedServeImports = (message) => ({ - patterns: [ - { - // `**/serve` also covers the bare directory specifier, which resolves to - // the serve/ barrel. - group: [...serveImportPatterns, ...relativeServeImportPatterns], - message, - }, - ], + patterns: [{ group: relativeServeImportPatterns, message }], }); +// Dynamic-import guard. Relative spellings are matched with linear-time +// patterns — the previous nested-quantifier shape backtracked exponentially +// (ReDoS: ~4x cost per two added `../` segments). Non-literal sources and +// type-level imports cannot be resolved statically, and rounds 2-5 each +// demonstrated a new spelling entrance, so those are rejected fail-closed. +const serveDynamicImportPatterns = [ + // Canonical and duplicated-separator spellings: ../serve, ./../serve, + // ..//serve, ../../serve/x, ... + String.raw`^(?:\.\x2f+|\.\.\x2f+)+serve(?:\x2f|$)`, + // Spellings routing through intermediate or traversal segments: + // ../runtime/../serve/x, ../foo/../../../serve/x, ..//foo//serve, ... + String.raw`^(?:\.\x2f+|\.\.\x2f+)+(?:[^\x2f]+\x2f+)+serve(?:\x2f|$)`, +]; + const restrictedServeDynamicImports = (message) => [ + ...serveDynamicImportPatterns.flatMap((pattern) => [ + { + selector: `ImportExpression[source.value=/${pattern}/]`, + message, + }, + { + selector: `ImportExpression[source.quasis.0.value.cooked=/${pattern}/]`, + message, + }, + { + selector: `TSImportType[argument.value=/${pattern}/]`, + message, + }, + ]), + // Fail-closed: concatenation, `new URL(...)`, and other computed sources + // cannot be proven safe statically (see R4-1 entrances). { - selector: `ImportExpression[source.value=/${serveDynamicImportPathPattern}/]`, + selector: + 'ImportExpression:not([source.type="Literal"]):not([source.type="TemplateLiteral"])', message, }, { - selector: `ImportExpression[source.quasis.0.value.cooked=/${serveDynamicImportPathPattern}/]`, + selector: 'TSImportType:not([argument.type="Literal"])', message, }, ]; @@ -62,6 +90,17 @@ const restrictedStringThrow = { 'Do not throw string literals or non-Error objects. Throw new Error("...") instead.', }; +// The three guarded trees (runtime/, utils/, acp-integration/) share one +// no-restricted-syntax shape. Flat config replaces rule options wholesale, +// so the array is built in one place — a future selector added here reaches +// every guarded tree instead of needing hand-replication in each block. +const serveGuardSyntaxRules = (message) => [ + 'error', + restrictedRequire, + restrictedStringThrow, + ...restrictedServeDynamicImports(message), +]; + export default tseslint.config( { // Global ignores @@ -248,14 +287,9 @@ export default tseslint.config( // overwritten by the shared `no-restricted-syntax` rule. files: ['packages/cli/src/runtime/**/*.{ts,tsx}'], rules: { - 'no-restricted-syntax': [ - 'error', - restrictedRequire, - restrictedStringThrow, - ...restrictedServeDynamicImports( - 'packages/cli/src/runtime must not dynamically import serve/ internals (#8084).', - ), - ], + 'no-restricted-syntax': serveGuardSyntaxRules( + 'packages/cli/src/runtime must not dynamically import serve/ internals (#8084).', + ), }, }, { @@ -263,28 +297,24 @@ export default tseslint.config( // overwritten by the shared `no-restricted-syntax` rule. files: ['packages/cli/src/utils/**/*.{ts,tsx}'], rules: { - 'no-restricted-syntax': [ - 'error', - restrictedRequire, - restrictedStringThrow, - ...restrictedServeDynamicImports( - 'packages/cli/src/utils must not dynamically import serve/. Move lifecycle-free logic down into utils/ instead (#9146).', - ), - ], + 'no-restricted-syntax': serveGuardSyntaxRules( + 'packages/cli/src/utils must not dynamically import serve/. Move lifecycle-free logic down into utils/ instead (#9146).', + ), }, }, { // ACP integration and the daemon are separate runtime surfaces that happen // to share a package directory. ACP may consume neutral contracts under // `runtime/`, but never `serve/` implementation modules — see #8084. - // Enforcement point is `npm run lint` in CI; no fixture test pins this - // block — accepted trade-off, the boundary lives in config, not code. + // Enforcement point is `npm run lint` in CI; fixture coverage lives in + // scripts/tests/eslint-boundary-rules.test.js (serve-boundary cases plus + // a string-throw probe that pins the restated selectors below). // // Positioned after the general TS block on purpose: flat config lets the // last matching block win per rule, and that block also configures // `no-restricted-syntax` — placing this one earlier would silently lose - // the dynamic-import guard below. Its two selectors are restated here so - // this override does not drop them for acp-integration files. + // the dynamic-import guard below. The shared selectors are restated via + // serveGuardSyntaxRules so this override does not drop them. files: ['packages/cli/src/acp-integration/**/*.{ts,tsx}'], rules: { 'no-restricted-imports': [ @@ -293,18 +323,13 @@ export default tseslint.config( 'acp-integration must not import serve/ internals. Put shared, lifecycle-free logic in packages/cli/src/runtime/ instead (#8084).', ), ], - 'no-restricted-syntax': [ - 'error', - restrictedRequire, - restrictedStringThrow, - // no-restricted-imports only visits static import/export declarations; - // the same boundary applies to dynamic `await import('../serve/…')`. - // The selector regex spells `/` as `\x2f` because esquery cannot parse - // a literal slash inside its attribute-regex syntax. - ...restrictedServeDynamicImports( - 'acp-integration must not dynamically import serve/ internals. Put shared, lifecycle-free logic in packages/cli/src/runtime/ instead (#8084).', - ), - ], + // no-restricted-imports only visits static import/export declarations; + // the same boundary applies to dynamic `await import('../serve/…')`. + // The selector regex spells `/` as `\x2f` because esquery cannot parse + // a literal slash inside its attribute-regex syntax. + 'no-restricted-syntax': serveGuardSyntaxRules( + 'acp-integration must not dynamically import serve/ internals. Put shared, lifecycle-free logic in packages/cli/src/runtime/ instead (#8084).', + ), }, }, { diff --git a/scripts/tests/eslint-boundary-rules.test.js b/scripts/tests/eslint-boundary-rules.test.js index f44b622afe2..22c1077b764 100644 --- a/scripts/tests/eslint-boundary-rules.test.js +++ b/scripts/tests/eslint-boundary-rules.test.js @@ -72,4 +72,83 @@ describe('eslint cli serve boundary rules', () => { "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 override blocks restate restrictedStringThrow; flat config's + // last-wins semantics mean dropping the restatement would silently legalize + // string throws in exactly these trees. This probe pins it. + 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')]), + ); + }); + + // 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'); + const [result] = await lintCliFile( + 'packages/cli/src/acp-integration/boundary-fixture.ts', + code, + ); + const boundaryHits = result.messages.filter((message) => + message.message.includes('serve/ internals'), + ); + expect(boundaryHits).toEqual([]); + }); }); From 17d16d51d6d058cd7ec4a1bafffa56fdd95d3de3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E8=89=AF?= <1204183885@qq.com> Date: Sat, 15 Aug 2026 09:29:39 +0000 Subject: [PATCH 06/26] fix(lint): correct TSImportType selector path and computed-template handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - read the type-import specifier at argument.literal.value: @typescript-eslint wraps it in a TSLiteralType, so argument.value was dead code and the old fail-closed TSImportType selector over-matched every type-level import (37 errors in files this PR never touches) (round-6 Critical) - reject computed template literals (templates containing expressions) fail-closed; pure-literal templates stay covered by the quasis pattern selectors — the old blanket TemplateLiteral exemption contradicted the fail-closed comment above it (round-6 Critical) - give the fail-closed selectors a distinct message: computed sources cannot be checked against the boundary, which is not the same policy as importing serve/ (round-6 suggestion) - pin the depth-enumeration loop beyond depth 1 with a depth-2 fixture, pin the fixed type-import selector with a negative typeof-import control, and pin the computed-template fail-closed path (round-6 suggestion) --- eslint.config.js | 28 +++++++++++--- scripts/tests/eslint-boundary-rules.test.js | 41 +++++++++++++++++++++ 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 39ca8be5261..9223914f08c 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -51,6 +51,14 @@ const serveDynamicImportPatterns = [ String.raw`^(?:\.\x2f+|\.\.\x2f+)+(?:[^\x2f]+\x2f+)+serve(?:\x2f|$)`, ]; +// Computed dynamic-import sources cannot be statically checked against the +// serve/ boundary. Distinct from the boundary message on purpose: a +// developer hitting this did not necessarily import serve/. +const FAIL_CLOSED_DYNAMIC_IMPORT_MESSAGE = + 'Dynamically computed import sources cannot be checked against the serve/ ' + + 'boundary. Use a string-literal specifier so the boundary rule can see ' + + 'the target (#8084, #9146).'; + const restrictedServeDynamicImports = (message) => [ ...serveDynamicImportPatterns.flatMap((pattern) => [ { @@ -62,20 +70,28 @@ const restrictedServeDynamicImports = (message) => [ message, }, { - selector: `TSImportType[argument.value=/${pattern}/]`, + // @typescript-eslint wraps the specifier in a TSLiteralType: the string + // lives at argument.literal.value, NOT argument.value (probe-verified; + // the old path was dead code). + selector: `TSImportType[argument.literal.value=/${pattern}/]`, message, }, ]), - // Fail-closed: concatenation, `new URL(...)`, and other computed sources - // cannot be proven safe statically (see R4-1 entrances). + // Fail-closed: concatenation, `new URL(...)`, template literals containing + // expressions, and any other computed source cannot be proven safe + // statically (rounds 2-6 each demonstrated a new entrance). Pure template + // literals (no expressions) are fully described by their first quasi and + // stay covered by the pattern selectors above. These hits get their own + // message: the policy is "computed sources cannot be checked", not "you + // imported serve/". { selector: 'ImportExpression:not([source.type="Literal"]):not([source.type="TemplateLiteral"])', - message, + message: FAIL_CLOSED_DYNAMIC_IMPORT_MESSAGE, }, { - selector: 'TSImportType:not([argument.type="Literal"])', - message, + selector: 'ImportExpression[source.type="TemplateLiteral"][source.expressions.0]', + message: FAIL_CLOSED_DYNAMIC_IMPORT_MESSAGE, }, ]; diff --git a/scripts/tests/eslint-boundary-rules.test.js b/scripts/tests/eslint-boundary-rules.test.js index 22c1077b764..1674eb2f608 100644 --- a/scripts/tests/eslint-boundary-rules.test.js +++ b/scripts/tests/eslint-boundary-rules.test.js @@ -133,6 +133,47 @@ describe('eslint cli serve boundary rules', () => { ); }); + // 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 (the pure-literal template form is caught + // by the quasis pattern selectors 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('computed import sources cannot be checked'), + ]), + ); + }); + // 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 () => { From 4abaca138dfeb1d0650acd843a5bd52a4aa3a3b9 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sat, 15 Aug 2026 09:42:12 +0000 Subject: [PATCH 07/26] fix(lint): close the remaining round-6 serve-boundary entrances MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complements the previous commit (which fixed the TSImportType selector path and computed-template fail-closed) with the R4-1 entrances it left open, each pinned by a fixture: - percent-encoded segments (`../%73erve/index.js`): Node percent-decodes segments when mapping the resolved URL to the filesystem, so raw-text patterns cannot see through them — any `%` in a guarded-tree specifier is now rejected with a dedicated message. - static traversal twins: the pattern regexes now run over static ImportDeclaration/ExportNamedDeclaration/ExportAllDeclaration sources too, closing `import './../serve/x'`, `import '../runtime/../serve/x'`, and `import '..//serve/x'`, whose dynamic twins were already blocked. - leading literal segment: a traversal-anywhere pattern catches `import('foo/../../../serve/x')` past the dot-slash anchor. - vitest module-loading calls (vi.mock/doMock/importActual/importMock) resolve and load the real module, so they get the same patterns plus fail-closed coverage for computed arguments. --- eslint.config.js | 64 +++++++++++++++++++++ scripts/tests/eslint-boundary-rules.test.js | 50 ++++++++++++++++ 2 files changed, 114 insertions(+) diff --git a/eslint.config.js b/eslint.config.js index 9223914f08c..db225b409bb 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -49,6 +49,10 @@ const serveDynamicImportPatterns = [ // Spellings routing through intermediate or traversal segments: // ../runtime/../serve/x, ../foo/../../../serve/x, ..//foo//serve, ... String.raw`^(?:\.\x2f+|\.\.\x2f+)+(?:[^\x2f]+\x2f+)+serve(?:\x2f|$)`, + // A traversal run landing on serve from ANY position — covers a leading + // literal segment (foo/../../../serve/x) that the dot-slash-anchored + // patterns miss (round-6 entrance). + String.raw`(?:^|\x2f)(?:\.\.\x2f+)+serve(?:\x2f|$)`, ]; // Computed dynamic-import sources cannot be statically checked against the @@ -59,6 +63,20 @@ const FAIL_CLOSED_DYNAMIC_IMPORT_MESSAGE = 'boundary. Use a string-literal specifier so the boundary rule can see ' + 'the target (#8084, #9146).'; +// Node percent-decodes path segments when mapping the resolved URL to the +// filesystem (`../%73erve/index.js` loads serve/), while these selectors +// match raw specifier text — so any `%` in a guarded-tree specifier is +// rejected outright instead of pattern-matched (round-6 entrance). +const SERVE_BOUNDARY_PERCENT_MESSAGE = + 'Percent-encoded path segments bypass the serve/ boundary check; use the ' + + 'canonical specifier spelling (#8084).'; + +// vitest's module-loading call APIs resolve (and, without a factory, load) +// the real module — same boundary, CallExpression shape (round-6 +// suggestion: vi.mock/doMock/importActual/importMock). +const vitestModuleLoadingCallSelector = + 'CallExpression[callee.object.name="vi"][callee.property.name=/^(?:mock|doMock|importActual|importMock)$/]'; + const restrictedServeDynamicImports = (message) => [ ...serveDynamicImportPatterns.flatMap((pattern) => [ { @@ -69,6 +87,22 @@ const restrictedServeDynamicImports = (message) => [ selector: `ImportExpression[source.quasis.0.value.cooked=/${pattern}/]`, message, }, + // Static spellings must pass the same regexes: the depth-enumerated + // no-restricted-imports globs only see canonical forms, while + // `./../serve`, `..//serve`, and traversal-through-intermediate static + // imports resolve to serve/ just the same (round-6 entrances). + { + selector: `ImportDeclaration[source.value=/${pattern}/]`, + message, + }, + { + selector: `ExportNamedDeclaration[source.value=/${pattern}/]`, + message, + }, + { + selector: `ExportAllDeclaration[source.value=/${pattern}/]`, + message, + }, { // @typescript-eslint wraps the specifier in a TSLiteralType: the string // lives at argument.literal.value, NOT argument.value (probe-verified; @@ -76,7 +110,29 @@ const restrictedServeDynamicImports = (message) => [ selector: `TSImportType[argument.literal.value=/${pattern}/]`, message, }, + // vitest module-loading calls (see vitestModuleLoadingCallSelector). + { + selector: `${vitestModuleLoadingCallSelector}[arguments.0.value=/${pattern}/]`, + message, + }, + { + selector: `${vitestModuleLoadingCallSelector}[arguments.0.quasis.0.value.cooked=/${pattern}/]`, + message, + }, ]), + // Percent-encoded segments (see SERVE_BOUNDARY_PERCENT_MESSAGE). + ...[ + 'ImportExpression[source.value=/%/]', + 'ImportExpression[source.quasis.0.value.cooked=/%/]', + 'ImportDeclaration[source.value=/%/]', + 'ExportNamedDeclaration[source.value=/%/]', + 'ExportAllDeclaration[source.value=/%/]', + 'TSImportType[argument.literal.value=/%/]', + `${vitestModuleLoadingCallSelector}[arguments.0.value=/%/]`, + ].map((selector) => ({ + selector, + message: SERVE_BOUNDARY_PERCENT_MESSAGE, + })), // Fail-closed: concatenation, `new URL(...)`, template literals containing // expressions, and any other computed source cannot be proven safe // statically (rounds 2-6 each demonstrated a new entrance). Pure template @@ -93,6 +149,14 @@ const restrictedServeDynamicImports = (message) => [ selector: 'ImportExpression[source.type="TemplateLiteral"][source.expressions.0]', message: FAIL_CLOSED_DYNAMIC_IMPORT_MESSAGE, }, + { + selector: `${vitestModuleLoadingCallSelector}:not([arguments.0.type="Literal"]):not([arguments.0.type="TemplateLiteral"])`, + message: FAIL_CLOSED_DYNAMIC_IMPORT_MESSAGE, + }, + { + selector: `${vitestModuleLoadingCallSelector}[arguments.0.type="TemplateLiteral"][arguments.0.expressions.0]`, + message: FAIL_CLOSED_DYNAMIC_IMPORT_MESSAGE, + }, ]; const restrictedRequire = { diff --git a/scripts/tests/eslint-boundary-rules.test.js b/scripts/tests/eslint-boundary-rules.test.js index 1674eb2f608..9f67f5c046e 100644 --- a/scripts/tests/eslint-boundary-rules.test.js +++ b/scripts/tests/eslint-boundary-rules.test.js @@ -174,6 +174,56 @@ describe('eslint cli serve boundary rules', () => { ); }); + // 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'); }", + ); + + // A non-serve vi.mock stays silent on the boundary. + const [result] = await lintCliFile(acp, "vi.mock('../utils/foo.js');"); + const boundaryHits = result.messages.filter((message) => + message.message.includes('serve'), + ); + expect(boundaryHits).toEqual([]); + }); + // 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 () => { From 6f7a13d4e512921ac592079edb8a3ccecfb83a53 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sat, 15 Aug 2026 21:30:57 +0800 Subject: [PATCH 08/26] fix(lint): cover vitest serve-boundary calls --- eslint.config.js | 2 +- scripts/tests/eslint-boundary-rules.test.js | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/eslint.config.js b/eslint.config.js index db225b409bb..ab3efa58ba3 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -75,7 +75,7 @@ const SERVE_BOUNDARY_PERCENT_MESSAGE = // the real module — same boundary, CallExpression shape (round-6 // suggestion: vi.mock/doMock/importActual/importMock). const vitestModuleLoadingCallSelector = - 'CallExpression[callee.object.name="vi"][callee.property.name=/^(?:mock|doMock|importActual|importMock)$/]'; + 'CallExpression[callee.object.name=/^(?:vi|vitest)$/][callee.property.name=/^(?:mock|doMock|importActual|importMock)$/]'; const restrictedServeDynamicImports = (message) => [ ...serveDynamicImportPatterns.flatMap((pattern) => [ diff --git a/scripts/tests/eslint-boundary-rules.test.js b/scripts/tests/eslint-boundary-rules.test.js index 9f67f5c046e..23055973661 100644 --- a/scripts/tests/eslint-boundary-rules.test.js +++ b/scripts/tests/eslint-boundary-rules.test.js @@ -215,6 +215,10 @@ describe('eslint cli serve boundary rules', () => { 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. const [result] = await lintCliFile(acp, "vi.mock('../utils/foo.js');"); From 0c8c334c1116a8791028e28f34fbeb9c7761fe84 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sat, 15 Aug 2026 14:38:39 +0000 Subject: [PATCH 09/26] fix(lint): close the round-7 serve-boundary entrance classes R4-1 round-7 interim hardening (the durable specifier-resolving custom rule remains tracked separately): - case-variant spellings (../Serve/...): every pattern, percent and quasis attribute regex now carries the i flag, covering the dynamic, static, vi.*/vitest.* and TSImportType arms. - ?query/#fragment suffixes: rejected alongside % in all eight specifier shapes (bundlers/Node strip them when resolving, so '../serve?x' reaches the same module as '../serve'). - percent-encoded pure-template vitest calls: added the missing arguments.0.quasis.0.value.cooked twin to the reject list. - root-absolute and file: literal specifiers: fail-closed rejected in every literal shape (guarded trees sweep verified clean of both). - createRequire: its source modules ('module'/'node:module') are flagged in guarded trees, since the alias escapes the callee-name="require" arm and Node >=22 require(esm) loads serve/. Each entrance class is pinned by a fixture case (18/18 green through the real ESLint API); the three guarded trees lint clean with the new arms. --- eslint.config.js | 86 ++++++++++++++++----- scripts/tests/eslint-boundary-rules.test.js | 74 ++++++++++++++++++ 2 files changed, 139 insertions(+), 21 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index ab3efa58ba3..dd444cfb918 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -64,12 +64,31 @@ const FAIL_CLOSED_DYNAMIC_IMPORT_MESSAGE = 'the target (#8084, #9146).'; // Node percent-decodes path segments when mapping the resolved URL to the -// filesystem (`../%73erve/index.js` loads serve/), while these selectors -// match raw specifier text — so any `%` in a guarded-tree specifier is -// rejected outright instead of pattern-matched (round-6 entrance). +// filesystem (`../%73erve/index.js` loads serve/), and bundlers/Node strip +// `?query`/`#fragment` suffixes when resolving (`../serve?x` resolves to +// the same module as `../serve`) — while these selectors match raw +// specifier text. Any `%`, `?` or `#` in a guarded-tree specifier is +// therefore rejected outright instead of pattern-matched (round-6/7 +// entrances). const SERVE_BOUNDARY_PERCENT_MESSAGE = - 'Percent-encoded path segments bypass the serve/ boundary check; use the ' + - 'canonical specifier spelling (#8084).'; + 'Percent-encoded segments and ?query/#fragment suffixes bypass the ' + + 'serve/ boundary check; use the canonical specifier spelling (#8084).'; + +// Root-absolute and file: specifiers carry no `../` run, so none of the +// relative patterns can see them, yet Node loads both into serve/ +// (round-7 entrance). Literal forms are fail-closed rejected: a guarded +// tree has no legitimate business importing by absolute path or URL. +const SERVE_BOUNDARY_ABSOLUTE_MESSAGE = + 'Root-absolute and file: specifiers bypass the serve/ boundary check; ' + + 'use a relative specifier instead (#8084).'; + +// createRequire aliases require() under any name, escaping the +// CallExpression[callee.name="require"] arm; Node >=22 require(esm) loads +// serve/ through it (round-7 entrance). Flag the createRequire source +// module itself in guarded trees. +const SERVE_BOUNDARY_CREATE_REQUIRE_MESSAGE = + 'createRequire aliases require() and bypasses the serve/ boundary; ' + + 'import modules statically instead (#8084).'; // vitest's module-loading call APIs resolve (and, without a factory, load) // the real module — same boundary, CallExpression shape (round-6 @@ -80,11 +99,11 @@ const vitestModuleLoadingCallSelector = const restrictedServeDynamicImports = (message) => [ ...serveDynamicImportPatterns.flatMap((pattern) => [ { - selector: `ImportExpression[source.value=/${pattern}/]`, + selector: `ImportExpression[source.value=/${pattern}/i]`, message, }, { - selector: `ImportExpression[source.quasis.0.value.cooked=/${pattern}/]`, + selector: `ImportExpression[source.quasis.0.value.cooked=/${pattern}/i]`, message, }, // Static spellings must pass the same regexes: the depth-enumerated @@ -92,47 +111,72 @@ const restrictedServeDynamicImports = (message) => [ // `./../serve`, `..//serve`, and traversal-through-intermediate static // imports resolve to serve/ just the same (round-6 entrances). { - selector: `ImportDeclaration[source.value=/${pattern}/]`, + selector: `ImportDeclaration[source.value=/${pattern}/i]`, message, }, { - selector: `ExportNamedDeclaration[source.value=/${pattern}/]`, + selector: `ExportNamedDeclaration[source.value=/${pattern}/i]`, message, }, { - selector: `ExportAllDeclaration[source.value=/${pattern}/]`, + selector: `ExportAllDeclaration[source.value=/${pattern}/i]`, message, }, { // @typescript-eslint wraps the specifier in a TSLiteralType: the string // lives at argument.literal.value, NOT argument.value (probe-verified; // the old path was dead code). - selector: `TSImportType[argument.literal.value=/${pattern}/]`, + selector: `TSImportType[argument.literal.value=/${pattern}/i]`, message, }, // vitest module-loading calls (see vitestModuleLoadingCallSelector). { - selector: `${vitestModuleLoadingCallSelector}[arguments.0.value=/${pattern}/]`, + selector: `${vitestModuleLoadingCallSelector}[arguments.0.value=/${pattern}/i]`, message, }, { - selector: `${vitestModuleLoadingCallSelector}[arguments.0.quasis.0.value.cooked=/${pattern}/]`, + selector: `${vitestModuleLoadingCallSelector}[arguments.0.quasis.0.value.cooked=/${pattern}/i]`, message, }, ]), - // Percent-encoded segments (see SERVE_BOUNDARY_PERCENT_MESSAGE). + // Percent/query/fragment spellings (see SERVE_BOUNDARY_PERCENT_MESSAGE). ...[ - 'ImportExpression[source.value=/%/]', - 'ImportExpression[source.quasis.0.value.cooked=/%/]', - 'ImportDeclaration[source.value=/%/]', - 'ExportNamedDeclaration[source.value=/%/]', - 'ExportAllDeclaration[source.value=/%/]', - 'TSImportType[argument.literal.value=/%/]', - `${vitestModuleLoadingCallSelector}[arguments.0.value=/%/]`, + 'ImportExpression[source.value=/[%?#]/i]', + 'ImportExpression[source.quasis.0.value.cooked=/[%?#]/i]', + 'ImportDeclaration[source.value=/[%?#]/i]', + 'ExportNamedDeclaration[source.value=/[%?#]/i]', + 'ExportAllDeclaration[source.value=/[%?#]/i]', + 'TSImportType[argument.literal.value=/[%?#]/i]', + `${vitestModuleLoadingCallSelector}[arguments.0.value=/[%?#]/i]`, + `${vitestModuleLoadingCallSelector}[arguments.0.quasis.0.value.cooked=/[%?#]/i]`, ].map((selector) => ({ selector, message: SERVE_BOUNDARY_PERCENT_MESSAGE, })), + // Root-absolute / file: literal spellings (round-7 entrance) — see + // SERVE_BOUNDARY_ABSOLUTE_MESSAGE. Computed forms already fail closed. + ...[ + 'ImportExpression[source.value=/^(?:\\x2f|file:)/i]', + 'ImportExpression[source.quasis.0.value.cooked=/^(?:\\x2f|file:)/i]', + 'ImportDeclaration[source.value=/^(?:\\x2f|file:)/i]', + 'ExportNamedDeclaration[source.value=/^(?:\\x2f|file:)/i]', + 'ExportAllDeclaration[source.value=/^(?:\\x2f|file:)/i]', + 'TSImportType[argument.literal.value=/^(?:\\x2f|file:)/i]', + `${vitestModuleLoadingCallSelector}[arguments.0.value=/^(?:\\x2f|file:)/i]`, + `${vitestModuleLoadingCallSelector}[arguments.0.quasis.0.value.cooked=/^(?:\\x2f|file:)/i]`, + ].map((selector) => ({ + selector, + message: SERVE_BOUNDARY_ABSOLUTE_MESSAGE, + })), + // createRequire source modules (round-7 entrance) — see + // SERVE_BOUNDARY_CREATE_REQUIRE_MESSAGE. + ...[ + "ImportDeclaration[source.value=/^(?:node:)?module$/]", + "ImportExpression[source.value=/^(?:node:)?module$/]", + ].map((selector) => ({ + selector, + message: SERVE_BOUNDARY_CREATE_REQUIRE_MESSAGE, + })), // Fail-closed: concatenation, `new URL(...)`, template literals containing // expressions, and any other computed source cannot be proven safe // statically (rounds 2-6 each demonstrated a new entrance). Pure template diff --git a/scripts/tests/eslint-boundary-rules.test.js b/scripts/tests/eslint-boundary-rules.test.js index 23055973661..7f9b0f962e2 100644 --- a/scripts/tests/eslint-boundary-rules.test.js +++ b/scripts/tests/eslint-boundary-rules.test.js @@ -228,6 +228,80 @@ describe('eslint cli serve boundary rules', () => { expect(boundaryHits).toEqual([]); }); + // 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';", + ); + }); + // 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 () => { From 181569488dd06f1b2abfa8e5b2ece4f4b9c41000 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E8=89=AF?= <1204183885@qq.com> Date: Sun, 16 Aug 2026 10:01:00 +0000 Subject: [PATCH 10/26] refactor(lint): resolve the serve boundary by resolution, not text (#8084) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R4-1 round-8 decision (maintainer-approved option a): replace the spelling-by-spelling regex/glob matrix with a local resolution-based ESLint rule (eslint-rules/no-serve-boundary-cross.js). Eight review rounds each demonstrated a new spelling escaping the text matrix (data: URLs, percent-encoding, traversal through a leading literal segment, baseUrl bare specifiers, createRequire/getBuiltinModule, TSImportType, aliased vitest loaders, Worker/fork), because every spelling is just another way to NAME the same target. The new rule resolves each import-like specifier against the importing file and reports anything landing inside packages/cli/src/serve/: - relative specifiers resolved against the importing file - baseUrl bare specifiers resolved against packages/cli (tsconfig baseUrl makes `src/serve/...` reachable — the round-8 entrance text never saw) - file: URLs resolved to concrete paths (case-insensitive, whitespace-trimmed scheme detection, since the URL parser normalizes both) - vitest loaders matched alias-proof (v.mock / destructured importActual); only specifiers resolving INTO serve/ report - child_process.fork checked; spawn deliberately not (first arg is an executable, not a module) - fail-closed on statically-unresolvable sources: computed sources, data: URLs, traversal-bearing bare specifiers, node:module imports, process.getBuiltinModule - case-insensitive path comparison (Serve/ loads serve/ on case-insensitive filesystems) Fixture suite reworked to resolution semantics: several round-4..7 fixture depths corrected to spellings that genuinely resolve into src/serve (the old depths resolved to packages/cli/serve, outside src/serve, and were only caught by text matching); new pins for every round-8 entrance and for the Codex self-review Criticals (aliased loaders, uppercase/whitespace URL schemes, spawn not an import source). 26/26 pass; guarded trees and the full cli src lint clean (zero false positives). Removed: relativeServeImportPatterns, restrictedServeImports, serveDynamicImportPatterns, serveGuardSyntaxRules and the per-spelling selector/percent/absolute/createRequire special cases. --- eslint-rules/no-serve-boundary-cross.js | 334 ++++++++++++++++++++ eslint.config.js | 277 ++-------------- scripts/tests/eslint-boundary-rules.test.js | 126 ++++++-- 3 files changed, 468 insertions(+), 269 deletions(-) create mode 100644 eslint-rules/no-serve-boundary-cross.js diff --git a/eslint-rules/no-serve-boundary-cross.js b/eslint-rules/no-serve-boundary-cross.js new file mode 100644 index 00000000000..82d2bee26ba --- /dev/null +++ b/eslint-rules/no-serve-boundary-cross.js @@ -0,0 +1,334 @@ +/** + * @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 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]; +} + +/** Decode percent-encoded segments (Node decodes when mapping to fs). */ +function decodeSpecifier(specifier) { + try { + return decodeURIComponent(specifier); + } catch { + return undefined; + } +} + +/** 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] ?? {}; + const serveDir = options.serveDir; + const baseUrlDir = options.baseUrlDir; + 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'; + + // URL schemes are case-insensitive, and the URL parser strips + // leading/trailing whitespace (`import(' DATA:...')` still loads) — + // detect schemes on the trimmed, lowercased form. Non-URL specifiers + // (relative/bare) are NOT trimmed: Node resolves those verbatim. + const lower = raw.trim().toLowerCase(); + + // node: builtins never touch serve/ — except the `module` builtin, + // which hands out createRequire (handled by the ImportDeclaration + // visitor); harmless here because node: URLs resolve to builtins. + if (lower.startsWith('node:')) return 'outside'; + + // 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; anything not provably + // outside serve is fail-closed (a guarded tree does not import by + // URL). + if (lower.startsWith('file:')) { + try { + const resolved = path.resolve( + fileURLToPath(stripUrlSuffixes(raw.trim())), + ); + return isInServeDir(resolved, serveDir) ? 'inside' : 'unknown'; + } catch { + return 'unknown'; + } + } + + // Root-absolute paths map straight to the filesystem — fail closed + // unless provably outside serve. + if (raw.startsWith('/')) { + const decoded = decodeSpecifier(stripUrlSuffixes(raw)); + if (decoded === undefined) return 'unknown'; + return isInServeDir(path.resolve(decoded), serveDir) + ? 'inside' + : 'unknown'; + } + + const cleaned = decodeSpecifier(stripUrlSuffixes(raw)); + if (cleaned === undefined) return 'unknown'; + + // Relative specifiers resolve against the importing file. + if (cleaned.startsWith('./') || cleaned.startsWith('../')) { + const resolved = path.resolve(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. + if (cleaned.includes('../')) return 'unknown'; + if (baseUrlDir) { + const resolved = path.resolve(baseUrlDir, cleaned); + if (isInServeDir(resolved, serveDir)) return 'inside'; + } + return 'outside'; + } + + function reportInside(node) { + context.report({ node, messageId: 'serveBoundary' }); + } + + function reportUnknown(node) { + context.report({ node, messageId: 'failClosed' }); + } + + /** 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); + } + + /** new URL(spec, import.meta.url) — resolves against this module. */ + function isNewUrlWithImportMeta(node) { + return ( + node.type === 'NewExpression' && + node.callee.type === 'Identifier' && + node.callee.name === 'URL' && + node.arguments.length >= 2 && + node.arguments[1].type === 'MetaProperty' + ); + } + + /** Member-call shape: obj.prop(...); pass objectNames null to match + * ANY object identifier (alias-proof — the caller asserts safety). */ + function memberCall(node, objectNames, propertyPattern) { + const callee = node.callee; + return ( + callee.type === 'MemberExpression' && + callee.object.type === 'Identifier' && + (objectNames === null || objectNames.includes(callee.object.name)) && + callee.property.type === 'Identifier' && + propertyPattern.test(callee.property.name) + ); + } + + 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); + return; + } + 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); + }, + CallExpression(node) { + const callee = node.callee; + + // vi.mock / vi.doMock / vi.importActual / vi.importMock — vitest + // resolves (and, without a factory, loads) the real module. The + // object name is deliberately NOT matched: aliased spellings + // (`import { vi as v } from 'vitest'; v.mock(...)`, destructured + // `importActual(...)`) 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(node, null, /^(?:mock|doMock|importActual|importMock)$/) && + 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' && + /^(?:mock|doMock|importActual|importMock)$/.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). + if (memberCall(node, ['process'], /^getBuiltinModule$/)) { + 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, [...]). + if ( + memberCall(node, ['child_process'], /^fork$/) && + node.arguments.length > 0 + ) { + checkSource(node.arguments[0]); + return; + } + + // new URL('../serve/...', import.meta.url) — Worker/asset loads + // (round-8 entrance). + if (isNewUrlWithImportMeta(node) && node.arguments.length > 0) { + checkSource(node.arguments[0]); + } + }, + NewExpression(node) { + // new Worker('../serve/...') — string-literal module paths resolve + // relative to the importing module. + if ( + node.callee.type === 'Identifier' && + node.callee.name === 'Worker' && + node.arguments.length > 0 + ) { + checkSource(node.arguments[0]); + } + }, + }; + }, +}; diff --git a/eslint.config.js b/eslint.config.js index dd444cfb918..d98f984ecd0 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -16,192 +16,27 @@ import globals from 'globals'; import storybook from 'eslint-plugin-storybook'; import checkFile from 'eslint-plugin-check-file'; import { legacyFilenames } from './eslint.legacy-filenames.mjs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import noServeBoundaryCross from './eslint-rules/no-serve-boundary-cross.js'; -// Static-import guard: relative specifiers only, enumerated by depth. The -// earlier `**/serve*` globs also matched third-party package names (`serve`, -// `@scope/serve`, `@scope/serve/subpath`), so bare/scoped specifiers must -// never reach the boundary check; imports deeper than six levels below src/ -// do not occur in the guarded trees. `../serve` (bare directory) resolves to -// the serve/ barrel and stays covered. -const relativeServeImportPatterns = []; -for (let depth = 1; depth <= 6; depth++) { - const prefix = '../'.repeat(depth); - relativeServeImportPatterns.push( - `${prefix}serve`, - `${prefix}serve/*`, - `${prefix}serve/**`, - ); -} - -const restrictedServeImports = (message) => ({ - patterns: [{ group: relativeServeImportPatterns, message }], -}); - -// Dynamic-import guard. Relative spellings are matched with linear-time -// patterns — the previous nested-quantifier shape backtracked exponentially -// (ReDoS: ~4x cost per two added `../` segments). Non-literal sources and -// type-level imports cannot be resolved statically, and rounds 2-5 each -// demonstrated a new spelling entrance, so those are rejected fail-closed. -const serveDynamicImportPatterns = [ - // Canonical and duplicated-separator spellings: ../serve, ./../serve, - // ..//serve, ../../serve/x, ... - String.raw`^(?:\.\x2f+|\.\.\x2f+)+serve(?:\x2f|$)`, - // Spellings routing through intermediate or traversal segments: - // ../runtime/../serve/x, ../foo/../../../serve/x, ..//foo//serve, ... - String.raw`^(?:\.\x2f+|\.\.\x2f+)+(?:[^\x2f]+\x2f+)+serve(?:\x2f|$)`, - // A traversal run landing on serve from ANY position — covers a leading - // literal segment (foo/../../../serve/x) that the dot-slash-anchored - // patterns miss (round-6 entrance). - String.raw`(?:^|\x2f)(?:\.\.\x2f+)+serve(?:\x2f|$)`, -]; - -// Computed dynamic-import sources cannot be statically checked against the -// serve/ boundary. Distinct from the boundary message on purpose: a -// developer hitting this did not necessarily import serve/. -const FAIL_CLOSED_DYNAMIC_IMPORT_MESSAGE = - 'Dynamically computed import sources cannot be checked against the serve/ ' + - 'boundary. Use a string-literal specifier so the boundary rule can see ' + - 'the target (#8084, #9146).'; - -// Node percent-decodes path segments when mapping the resolved URL to the -// filesystem (`../%73erve/index.js` loads serve/), and bundlers/Node strip -// `?query`/`#fragment` suffixes when resolving (`../serve?x` resolves to -// the same module as `../serve`) — while these selectors match raw -// specifier text. Any `%`, `?` or `#` in a guarded-tree specifier is -// therefore rejected outright instead of pattern-matched (round-6/7 -// entrances). -const SERVE_BOUNDARY_PERCENT_MESSAGE = - 'Percent-encoded segments and ?query/#fragment suffixes bypass the ' + - 'serve/ boundary check; use the canonical specifier spelling (#8084).'; - -// Root-absolute and file: specifiers carry no `../` run, so none of the -// relative patterns can see them, yet Node loads both into serve/ -// (round-7 entrance). Literal forms are fail-closed rejected: a guarded -// tree has no legitimate business importing by absolute path or URL. -const SERVE_BOUNDARY_ABSOLUTE_MESSAGE = - 'Root-absolute and file: specifiers bypass the serve/ boundary check; ' + - 'use a relative specifier instead (#8084).'; - -// createRequire aliases require() under any name, escaping the -// CallExpression[callee.name="require"] arm; Node >=22 require(esm) loads -// serve/ through it (round-7 entrance). Flag the createRequire source -// module itself in guarded trees. -const SERVE_BOUNDARY_CREATE_REQUIRE_MESSAGE = - 'createRequire aliases require() and bypasses the serve/ boundary; ' + - 'import modules statically instead (#8084).'; - -// vitest's module-loading call APIs resolve (and, without a factory, load) -// the real module — same boundary, CallExpression shape (round-6 -// suggestion: vi.mock/doMock/importActual/importMock). -const vitestModuleLoadingCallSelector = - 'CallExpression[callee.object.name=/^(?:vi|vitest)$/][callee.property.name=/^(?:mock|doMock|importActual|importMock)$/]'; - -const restrictedServeDynamicImports = (message) => [ - ...serveDynamicImportPatterns.flatMap((pattern) => [ - { - selector: `ImportExpression[source.value=/${pattern}/i]`, - message, - }, - { - selector: `ImportExpression[source.quasis.0.value.cooked=/${pattern}/i]`, - message, - }, - // Static spellings must pass the same regexes: the depth-enumerated - // no-restricted-imports globs only see canonical forms, while - // `./../serve`, `..//serve`, and traversal-through-intermediate static - // imports resolve to serve/ just the same (round-6 entrances). - { - selector: `ImportDeclaration[source.value=/${pattern}/i]`, - message, - }, - { - selector: `ExportNamedDeclaration[source.value=/${pattern}/i]`, - message, - }, - { - selector: `ExportAllDeclaration[source.value=/${pattern}/i]`, - message, - }, - { - // @typescript-eslint wraps the specifier in a TSLiteralType: the string - // lives at argument.literal.value, NOT argument.value (probe-verified; - // the old path was dead code). - selector: `TSImportType[argument.literal.value=/${pattern}/i]`, - message, - }, - // vitest module-loading calls (see vitestModuleLoadingCallSelector). - { - selector: `${vitestModuleLoadingCallSelector}[arguments.0.value=/${pattern}/i]`, - message, - }, - { - selector: `${vitestModuleLoadingCallSelector}[arguments.0.quasis.0.value.cooked=/${pattern}/i]`, - message, - }, - ]), - // Percent/query/fragment spellings (see SERVE_BOUNDARY_PERCENT_MESSAGE). - ...[ - 'ImportExpression[source.value=/[%?#]/i]', - 'ImportExpression[source.quasis.0.value.cooked=/[%?#]/i]', - 'ImportDeclaration[source.value=/[%?#]/i]', - 'ExportNamedDeclaration[source.value=/[%?#]/i]', - 'ExportAllDeclaration[source.value=/[%?#]/i]', - 'TSImportType[argument.literal.value=/[%?#]/i]', - `${vitestModuleLoadingCallSelector}[arguments.0.value=/[%?#]/i]`, - `${vitestModuleLoadingCallSelector}[arguments.0.quasis.0.value.cooked=/[%?#]/i]`, - ].map((selector) => ({ - selector, - message: SERVE_BOUNDARY_PERCENT_MESSAGE, - })), - // Root-absolute / file: literal spellings (round-7 entrance) — see - // SERVE_BOUNDARY_ABSOLUTE_MESSAGE. Computed forms already fail closed. - ...[ - 'ImportExpression[source.value=/^(?:\\x2f|file:)/i]', - 'ImportExpression[source.quasis.0.value.cooked=/^(?:\\x2f|file:)/i]', - 'ImportDeclaration[source.value=/^(?:\\x2f|file:)/i]', - 'ExportNamedDeclaration[source.value=/^(?:\\x2f|file:)/i]', - 'ExportAllDeclaration[source.value=/^(?:\\x2f|file:)/i]', - 'TSImportType[argument.literal.value=/^(?:\\x2f|file:)/i]', - `${vitestModuleLoadingCallSelector}[arguments.0.value=/^(?:\\x2f|file:)/i]`, - `${vitestModuleLoadingCallSelector}[arguments.0.quasis.0.value.cooked=/^(?:\\x2f|file:)/i]`, - ].map((selector) => ({ - selector, - message: SERVE_BOUNDARY_ABSOLUTE_MESSAGE, - })), - // createRequire source modules (round-7 entrance) — see - // SERVE_BOUNDARY_CREATE_REQUIRE_MESSAGE. - ...[ - "ImportDeclaration[source.value=/^(?:node:)?module$/]", - "ImportExpression[source.value=/^(?:node:)?module$/]", - ].map((selector) => ({ - selector, - message: SERVE_BOUNDARY_CREATE_REQUIRE_MESSAGE, - })), - // Fail-closed: concatenation, `new URL(...)`, template literals containing - // expressions, and any other computed source cannot be proven safe - // statically (rounds 2-6 each demonstrated a new entrance). Pure template - // literals (no expressions) are fully described by their first quasi and - // stay covered by the pattern selectors above. These hits get their own - // message: the policy is "computed sources cannot be checked", not "you - // imported serve/". - { - selector: - 'ImportExpression:not([source.type="Literal"]):not([source.type="TemplateLiteral"])', - message: FAIL_CLOSED_DYNAMIC_IMPORT_MESSAGE, - }, - { - selector: 'ImportExpression[source.type="TemplateLiteral"][source.expressions.0]', - message: FAIL_CLOSED_DYNAMIC_IMPORT_MESSAGE, - }, - { - selector: `${vitestModuleLoadingCallSelector}:not([arguments.0.type="Literal"]):not([arguments.0.type="TemplateLiteral"])`, - message: FAIL_CLOSED_DYNAMIC_IMPORT_MESSAGE, - }, - { - selector: `${vitestModuleLoadingCallSelector}[arguments.0.type="TemplateLiteral"][arguments.0.expressions.0]`, - message: FAIL_CLOSED_DYNAMIC_IMPORT_MESSAGE, - }, -]; +// Resolution-based serve boundary guard (#8084, #9144 round-8 decision): +// a local rule that RESOLVES each import-like specifier against the +// importing file and reports anything landing inside src/serve/, +// fail-closed on sources it cannot check statically. It replaces the +// spelling-by-spelling regex/glob matrix (depth-enumerated globs, +// per-shape selector regexes, percent/query/fragment/absolute/createRequire +// special cases) — eight review rounds each demonstrated a new spelling +// escaping that matrix, because every spelling is just another way to name +// the same target. +const serveBoundaryPlugin = { + rules: { 'no-serve-boundary-cross': noServeBoundaryCross }, +}; +const configDir = path.dirname(fileURLToPath(import.meta.url)); +const serveBoundaryOptions = { + serveDir: path.resolve(configDir, 'packages/cli/src/serve'), + baseUrlDir: path.resolve(configDir, 'packages/cli'), +}; const restrictedRequire = { selector: 'CallExpression[callee.name="require"]', @@ -283,30 +118,23 @@ export default tseslint.config( { // `runtime/` is the neutral layer acp-integration is directed to; it must // not import `serve/` internals itself, or the #8084 boundary reforms - // transitively one hop away. + // transitively one hop away. Enforced by the resolution-based local rule + // (see serveBoundaryPlugin above). files: ['packages/cli/src/runtime/**/*.{ts,tsx}'], + plugins: { 'qwen-boundary': serveBoundaryPlugin }, rules: { - 'no-restricted-imports': [ - 'error', - restrictedServeImports( - 'packages/cli/src/runtime must not import serve/ internals (#8084).', - ), - ], + 'qwen-boundary/no-serve-boundary-cross': ['error', serveBoundaryOptions], }, - }, - { + }, { // `utils/` is the layer every other directory imports, so it must not // import back into one. The daemon direction is clean and enforced here; // the remaining `ui/`, `config/`, `i18n/` and `nonInteractive/` edges are // tracked in #9146 and will be added to this group as they are resolved. + // Enforced by the resolution-based local rule (see serveBoundaryPlugin). files: ['packages/cli/src/utils/**/*.{ts,tsx}'], + plugins: { 'qwen-boundary': serveBoundaryPlugin }, rules: { - 'no-restricted-imports': [ - 'error', - restrictedServeImports( - 'packages/cli/src/utils must not import serve/. Move lifecycle-free logic down into utils/ instead (#9146).', - ), - ], + 'qwen-boundary/no-serve-boundary-cross': ['error', serveBoundaryOptions], }, }, { @@ -405,55 +233,16 @@ export default tseslint.config( radix: 'error', 'default-case': 'error', }, - }, - { - // Positioned after the general TS block so the dynamic-import guard is not - // overwritten by the shared `no-restricted-syntax` rule. - files: ['packages/cli/src/runtime/**/*.{ts,tsx}'], - rules: { - 'no-restricted-syntax': serveGuardSyntaxRules( - 'packages/cli/src/runtime must not dynamically import serve/ internals (#8084).', - ), - }, - }, - { - // Positioned after the general TS block so the dynamic-import guard is not - // overwritten by the shared `no-restricted-syntax` rule. - files: ['packages/cli/src/utils/**/*.{ts,tsx}'], - rules: { - 'no-restricted-syntax': serveGuardSyntaxRules( - 'packages/cli/src/utils must not dynamically import serve/. Move lifecycle-free logic down into utils/ instead (#9146).', - ), - }, - }, - { + }, { // ACP integration and the daemon are separate runtime surfaces that happen // to share a package directory. ACP may consume neutral contracts under // `runtime/`, but never `serve/` implementation modules — see #8084. - // Enforcement point is `npm run lint` in CI; fixture coverage lives in - // scripts/tests/eslint-boundary-rules.test.js (serve-boundary cases plus - // a string-throw probe that pins the restated selectors below). - // - // Positioned after the general TS block on purpose: flat config lets the - // last matching block win per rule, and that block also configures - // `no-restricted-syntax` — placing this one earlier would silently lose - // the dynamic-import guard below. The shared selectors are restated via - // serveGuardSyntaxRules so this override does not drop them. + // Enforced by the resolution-based local rule (see serveBoundaryPlugin); + // fixture coverage lives in scripts/tests/eslint-boundary-rules.test.js. files: ['packages/cli/src/acp-integration/**/*.{ts,tsx}'], + plugins: { 'qwen-boundary': serveBoundaryPlugin }, rules: { - 'no-restricted-imports': [ - 'error', - restrictedServeImports( - 'acp-integration must not import serve/ internals. Put shared, lifecycle-free logic in packages/cli/src/runtime/ instead (#8084).', - ), - ], - // no-restricted-imports only visits static import/export declarations; - // the same boundary applies to dynamic `await import('../serve/…')`. - // The selector regex spells `/` as `\x2f` because esquery cannot parse - // a literal slash inside its attribute-regex syntax. - 'no-restricted-syntax': serveGuardSyntaxRules( - 'acp-integration must not dynamically import serve/ internals. Put shared, lifecycle-free logic in packages/cli/src/runtime/ instead (#8084).', - ), + 'qwen-boundary/no-serve-boundary-cross': ['error', serveBoundaryOptions], }, }, { diff --git a/scripts/tests/eslint-boundary-rules.test.js b/scripts/tests/eslint-boundary-rules.test.js index 7f9b0f962e2..7f2fa6bf5bd 100644 --- a/scripts/tests/eslint-boundary-rules.test.js +++ b/scripts/tests/eslint-boundary-rules.test.js @@ -101,7 +101,7 @@ describe('eslint cli serve boundary rules', () => { await expectServeBoundaryError( runtime, - "export async function load() { await import('../foo/../../../serve/index.js'); }", + "export async function load() { await import('../foo/../serve/index.js'); }", ); await expectServeBoundaryError( @@ -160,8 +160,8 @@ describe('eslint cli serve boundary rules', () => { }); // Round 6: template literals containing expressions are computed sources - // and are rejected fail-closed (the pure-literal template form is caught - // by the quasis pattern selectors instead). + // 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', @@ -169,7 +169,7 @@ describe('eslint cli serve boundary rules', () => { ); expect(result.messages.map((message) => message.message)).toEqual( expect.arrayContaining([ - expect.stringContaining('computed import sources cannot be checked'), + expect.stringContaining('cannot be resolved statically'), ]), ); }); @@ -199,7 +199,7 @@ describe('eslint cli serve boundary rules', () => { 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'); }", + "export async function load() { await import('foo/../../serve/index.js'); }", ); }); @@ -209,15 +209,15 @@ describe('eslint cli serve boundary rules', () => { const acp = 'packages/cli/src/acp-integration/boundary-fixture.ts'; await expectServeBoundaryError( acp, - "vi.mock('../../serve/live/live-task-service.js');", + "vi.mock('../serve/live/live-task-service.js');", ); await expectServeBoundaryError( acp, - "export async function load() { return vi.importActual('../../serve/live/live-task-service.js'); }", + "export async function load() { return vi.importActual('../serve/live/live-task-service.js'); }", ); await expectServeBoundaryError( acp, - "vitest.mock('../../serve/live/live-task-service.js');", + "vitest.mock('../serve/live/live-task-service.js');", ); // A non-serve vi.mock stays silent on the boundary. @@ -232,74 +232,150 @@ describe('eslint cli serve boundary rules', () => { // 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, - "import '../../Serve/index.js';", + "export async function load() { return import('../Serve/live/live-task-service.js'); }", ); await expectServeBoundaryError( acp, - "export async function load() { return import('../../Serve/live/live-task-service.js'); }", + "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, - "vi.mock('../../SERVE/live/live-task-service.js');", + "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 ?query and #fragment suffixes on serve specifiers', async () => { + it('rejects percent-encoded pure-template vitest calls', async () => { const acp = 'packages/cli/src/acp-integration/boundary-fixture.ts'; await expectServeBoundaryError( acp, - "import '../../serve/index.js?x';", + '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, - "export async function load() { return import('../../serve/index.js?x'); }", + "import '/srv/qwen/packages/cli/src/serve/index.js';", ); await expectServeBoundaryError( acp, - "vi.mock('../../serve/live/live-task-service.js?x');", + "import 'file:///srv/qwen/packages/cli/src/serve/index.js';", ); await expectServeBoundaryError( acp, - "import '../../serve/index.js#f';", + "export async function load() { return import('/srv/qwen/packages/cli/src/serve/index.js'); }", ); }); - it('rejects percent-encoded pure-template vitest calls', async () => { + it('flags createRequire source modules in guarded trees', async () => { const acp = 'packages/cli/src/acp-integration/boundary-fixture.ts'; await expectServeBoundaryError( acp, - 'vi.mock(`../../%73erve/live/live-task-service.js`);', + "import { createRequire } from 'node:module';", ); + await expectServeBoundaryError(acp, "import moduleBuiltin from 'module';"); }); - it('rejects root-absolute and file: literal specifiers fail-closed', async () => { + // 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, - "import '/srv/qwen/packages/cli/src/serve/index.js';", + '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, - "import 'file:///srv/qwen/packages/cli/src/serve/index.js';", + "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 () => { + await expectServeBoundaryError( + 'packages/cli/src/acp-integration/boundary-fixture.ts', + "const mod = 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, - "export async function load() { return import('/srv/qwen/packages/cli/src/serve/index.js'); }", + "import 'FILE:///repo/packages/cli/src/serve/index.js';", + ); + await expectServeBoundaryError( + acp, + "export async function load() { await import('DATA:text/javascript,export default 1'); }", ); }); - it('flags createRequire source modules in guarded trees', async () => { + // 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'); }", + ); + }); + + // 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 { createRequire } from 'node:module';", + "import { vi as v } from 'vitest';\nv.mock('../serve/live/live-task-service.js');", ); await expectServeBoundaryError( acp, - "import moduleBuiltin from 'module';", + "import { importActual } from 'vitest';\nexport async function load() { return importActual('../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 () => { + const [result] = await lintCliFile( + 'packages/cli/src/acp-integration/boundary-fixture.ts', + "import { spawn } from 'node:child_process';\nexport function run() { return spawn(process.execPath, ['--version']); }", ); + const boundaryHits = result.messages.filter((message) => + message.message.includes('serve'), + ); + expect(boundaryHits).toEqual([]); }); // R5-7: third-party packages whose name contains `serve` must not be From 02e502a57ef06f2dd3a4291e98fdc4e26f5c28fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E8=89=AF?= <1204183885@qq.com> Date: Sun, 16 Aug 2026 10:59:52 +0000 Subject: [PATCH 11/26] fix(lint): drop the dead serveGuardSyntaxRules helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resolution-rule commit removed the mechanism but left the serveGuardSyntaxRules helper behind — unused (no-unused-vars) and referencing the already-deleted restrictedServeDynamicImports (no-undef), which failed CI's repo-wide eslint. The guarded trees inherit restrictedRequire + restrictedStringThrow from the general TS block, so nothing is lost. --- eslint.config.js | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index d98f984ecd0..8cdbd7df140 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -49,17 +49,6 @@ const restrictedStringThrow = { 'Do not throw string literals or non-Error objects. Throw new Error("...") instead.', }; -// The three guarded trees (runtime/, utils/, acp-integration/) share one -// no-restricted-syntax shape. Flat config replaces rule options wholesale, -// so the array is built in one place — a future selector added here reaches -// every guarded tree instead of needing hand-replication in each block. -const serveGuardSyntaxRules = (message) => [ - 'error', - restrictedRequire, - restrictedStringThrow, - ...restrictedServeDynamicImports(message), -]; - export default tseslint.config( { // Global ignores From 3d8be1fcd12d90471a008f7c6cd3d569fd32e44d Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sun, 16 Aug 2026 22:33:01 +0800 Subject: [PATCH 12/26] fix(lint): close serve boundary resolver gaps --- eslint-rules/no-serve-boundary-cross.js | 79 +++++++++++++++++---- scripts/tests/eslint-boundary-rules.test.js | 40 ++++++++++- 2 files changed, 104 insertions(+), 15 deletions(-) diff --git a/eslint-rules/no-serve-boundary-cross.js b/eslint-rules/no-serve-boundary-cross.js index 82d2bee26ba..278e2f2ba08 100644 --- a/eslint-rules/no-serve-boundary-cross.js +++ b/eslint-rules/no-serve-boundary-cross.js @@ -31,6 +31,7 @@ */ 'use strict'; +import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -55,6 +56,15 @@ function decodeSpecifier(specifier) { } } +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; @@ -113,11 +123,12 @@ export default { // leading/trailing whitespace (`import(' DATA:...')` still loads) — // detect schemes on the trimmed, lowercased form. Non-URL specifiers // (relative/bare) are NOT trimmed: Node resolves those verbatim. - const lower = raw.trim().toLowerCase(); + const normalized = raw.replace(/[\t\n\r]/g, ''); + const lower = normalized.trim().toLowerCase(); + + if (lower === 'module' || lower === 'node:module') return 'unknown'; - // node: builtins never touch serve/ — except the `module` builtin, - // which hands out createRequire (handled by the ImportDeclaration - // visitor); harmless here because node: URLs resolve to builtins. + // Other node: builtins never touch serve/. if (lower.startsWith('node:')) return 'outside'; // data: URLs can embed imports of arbitrary files — a guarded tree @@ -129,8 +140,8 @@ export default { // URL). if (lower.startsWith('file:')) { try { - const resolved = path.resolve( - fileURLToPath(stripUrlSuffixes(raw.trim())), + const resolved = resolvePath( + fileURLToPath(stripUrlSuffixes(normalized.trim())), ); return isInServeDir(resolved, serveDir) ? 'inside' : 'unknown'; } catch { @@ -140,23 +151,25 @@ export default { // Root-absolute paths map straight to the filesystem — fail closed // unless provably outside serve. - if (raw.startsWith('/')) { - const decoded = decodeSpecifier(stripUrlSuffixes(raw)); + if (normalized.startsWith('/')) { + const decoded = decodeSpecifier(stripUrlSuffixes(normalized)); if (decoded === undefined) return 'unknown'; - return isInServeDir(path.resolve(decoded), serveDir) + return isInServeDir(resolvePath(decoded), serveDir) ? 'inside' : 'unknown'; } - const cleaned = decodeSpecifier(stripUrlSuffixes(raw)); + const cleaned = decodeSpecifier(stripUrlSuffixes(normalized)); if (cleaned === undefined) return 'unknown'; // Relative specifiers resolve against the importing file. if (cleaned.startsWith('./') || cleaned.startsWith('../')) { - const resolved = path.resolve(fileDir, cleaned); + const resolved = resolvePath(path.join(fileDir, cleaned)); return isInServeDir(resolved, serveDir) ? 'inside' : 'outside'; } + if (cleaned.startsWith('#')) return 'unknown'; + // 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 @@ -214,12 +227,34 @@ export default { * ANY object identifier (alias-proof — the caller asserts safety). */ function memberCall(node, objectNames, propertyPattern) { const callee = node.callee; + const property = + callee.type === 'MemberExpression' && !callee.computed + ? callee.property.type === 'Identifier' + ? callee.property.name + : undefined + : callee.type === 'MemberExpression' && + callee.computed && + callee.property.type === 'Literal' && + typeof callee.property.value === 'string' + ? callee.property.value + : undefined; return ( callee.type === 'MemberExpression' && callee.object.type === 'Identifier' && (objectNames === null || objectNames.includes(callee.object.name)) && - callee.property.type === 'Identifier' && - propertyPattern.test(callee.property.name) + property !== undefined && + propertyPattern.test(property) + ); + } + + function isProcessObject(node) { + return ( + (node.type === 'Identifier' && node.name === 'process') || + (node.type === 'MemberExpression' && + node.property.type === 'Identifier' && + node.property.name === 'process' && + node.object.type === 'Identifier' && + (node.object.name === 'globalThis' || node.object.name === 'global')) ); } @@ -293,7 +328,23 @@ export default { // process.getBuiltinModule(...) hands out module objects // (createRequire) without any import statement (round-8 entrance). - if (memberCall(node, ['process'], /^getBuiltinModule$/)) { + if ( + memberCall(node, ['process'], /^getBuiltinModule$/) || + (callee.type === 'MemberExpression' && + isProcessObject(callee.object) && + ((callee.property.type === 'Identifier' && + callee.property.name === 'getBuiltinModule') || + (callee.computed && + callee.property.type === 'Literal' && + callee.property.value === 'getBuiltinModule'))) || + (callee.type === 'Identifier' && + callee.name === 'getBuiltinModule') || + (memberCall(node, ['Reflect'], /^apply$/) && + node.arguments[0]?.type === 'MemberExpression' && + isProcessObject(node.arguments[0].object) && + node.arguments[0].property.type === 'Identifier' && + node.arguments[0].property.name === 'getBuiltinModule') + ) { reportUnknown(node); return; } diff --git a/scripts/tests/eslint-boundary-rules.test.js b/scripts/tests/eslint-boundary-rules.test.js index 7f2fa6bf5bd..a8bf8ff8be7 100644 --- a/scripts/tests/eslint-boundary-rules.test.js +++ b/scripts/tests/eslint-boundary-rules.test.js @@ -5,6 +5,7 @@ */ 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'; @@ -288,6 +289,14 @@ describe('eslint cli serve boundary rules', () => { "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 @@ -320,10 +329,19 @@ describe('eslint cli serve boundary rules', () => { }); it('rejects process.getBuiltinModule in guarded trees fail-closed', async () => { + const acp = 'packages/cli/src/acp-integration/boundary-fixture.ts'; await expectServeBoundaryError( - 'packages/cli/src/acp-integration/boundary-fixture.ts', + 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:` @@ -349,6 +367,26 @@ describe('eslint cli serve boundary rules', () => { ); }); + 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/. From 3cc4298717e702a9587d77e275e0c72cc12e083e Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sun, 16 Aug 2026 14:48:54 +0000 Subject: [PATCH 13/26] fix(lint): address serve boundary review suggestions - R9-2: move the new-URL-with-import.meta check into the NewExpression visitor with the real MemberExpression base shape; the CallExpression placement was unreachable and standalone new URL(...) reported nothing - R9-3: report module-builtin entrances via the moduleBuiltin messageId instead of the self-contradicting failClosed remediation text - R9-4: match the destructured fork(...) spelling, not just child_process.fork(...) - R9-5/R8-2: fixture pins for re-exports, Worker, fork, require, vi.doMock and vi.importMock entrances - R9-7: pin the false branch of static-template concatenation (pure template literals resolving outside serve stay allowed) - R10-3: filter the third-party serve-named package pin by ruleId so a failClosed false positive also turns it red - R13-2: pin resolution detections on the serveBoundary messageId so inside-detection degrading to blanket fail-closed cannot ship green --- eslint-rules/no-serve-boundary-cross.js | 49 ++++++------ scripts/tests/eslint-boundary-rules.test.js | 85 ++++++++++++++++++++- 2 files changed, 109 insertions(+), 25 deletions(-) diff --git a/eslint-rules/no-serve-boundary-cross.js b/eslint-rules/no-serve-boundary-cross.js index 278e2f2ba08..9b1366ae6d1 100644 --- a/eslint-rules/no-serve-boundary-cross.js +++ b/eslint-rules/no-serve-boundary-cross.js @@ -186,8 +186,8 @@ export default { context.report({ node, messageId: 'serveBoundary' }); } - function reportUnknown(node) { - context.report({ node, messageId: 'failClosed' }); + function reportUnknown(node, messageId = 'failClosed') { + context.report({ node, messageId }); } /** Check a Literal/TemplateLiteral/computed source node. */ @@ -212,17 +212,6 @@ export default { else if (verdict === 'unknown') reportUnknown(sourceNode); } - /** new URL(spec, import.meta.url) — resolves against this module. */ - function isNewUrlWithImportMeta(node) { - return ( - node.type === 'NewExpression' && - node.callee.type === 'Identifier' && - node.callee.name === 'URL' && - node.arguments.length >= 2 && - node.arguments[1].type === 'MetaProperty' - ); - } - /** Member-call shape: obj.prop(...); pass objectNames null to match * ANY object identifier (alias-proof — the caller asserts safety). */ function memberCall(node, objectNames, propertyPattern) { @@ -264,7 +253,7 @@ export default { // 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); + reportUnknown(node.source, 'moduleBuiltin'); return; } checkSource(node.source); @@ -345,7 +334,7 @@ export default { node.arguments[0].property.type === 'Identifier' && node.arguments[0].property.name === 'getBuiltinModule') ) { - reportUnknown(node); + reportUnknown(node, 'moduleBuiltin'); return; } @@ -354,20 +343,19 @@ export default { // 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, [...]). + // legitimate code like spawn(process.execPath, [...]). The bare + // identifier covers the destructured spelling + // (`import { fork } from 'node:child_process'`); only specifiers + // resolving INTO serve/ report, so a non-serve fork target is + // never flagged. if ( - memberCall(node, ['child_process'], /^fork$/) && + (memberCall(node, ['child_process'], /^fork$/) || + (callee.type === 'Identifier' && callee.name === 'fork')) && node.arguments.length > 0 ) { checkSource(node.arguments[0]); return; } - - // new URL('../serve/...', import.meta.url) — Worker/asset loads - // (round-8 entrance). - if (isNewUrlWithImportMeta(node) && node.arguments.length > 0) { - checkSource(node.arguments[0]); - } }, NewExpression(node) { // new Worker('../serve/...') — string-literal module paths resolve @@ -376,6 +364,21 @@ export default { node.callee.type === 'Identifier' && node.callee.name === 'Worker' && node.arguments.length > 0 + ) { + checkSource(node.arguments[0]); + 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. + if ( + node.callee.type === 'Identifier' && + node.callee.name === 'URL' && + node.arguments.length >= 2 && + node.arguments[1].type === 'MemberExpression' && + node.arguments[1].object.type === 'MetaProperty' ) { checkSource(node.arguments[0]); } diff --git a/scripts/tests/eslint-boundary-rules.test.js b/scripts/tests/eslint-boundary-rules.test.js index a8bf8ff8be7..683e28e5bae 100644 --- a/scripts/tests/eslint-boundary-rules.test.js +++ b/scripts/tests/eslint-boundary-rules.test.js @@ -400,6 +400,16 @@ describe('eslint cli serve boundary rules', () => { 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 @@ -429,9 +439,80 @@ describe('eslint cli serve boundary rules', () => { 'packages/cli/src/acp-integration/boundary-fixture.ts', code, ); - const boundaryHits = result.messages.filter((message) => - message.message.includes('serve/ internals'), + // 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). + const boundaryHits = result.messages.filter( + (message) => + message.ruleId === 'qwen-boundary/no-serve-boundary-cross', + ); + expect(boundaryHits).toEqual([]); + }); + + // 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 () => { + const [result] = await lintCliFile( + 'packages/cli/src/acp-integration/boundary-fixture.ts', + 'export async function load() { await import(`../utils/boundary-fixture.ts`); }', + ); + const boundaryHits = result.messages.filter( + (message) => + message.ruleId === 'qwen-boundary/no-serve-boundary-cross', ); expect(boundaryHits).toEqual([]); }); + + // 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 () => { + const acp = 'packages/cli/src/acp-integration/boundary-fixture.ts'; + for (const code of [ + "import '../serve/index.js';", + "export async function load() { return import('src/serve/index.js'); }", + ]) { + const [result] = await lintCliFile(acp, code); + expect( + result.messages.some( + (message) => message.messageId === 'serveBoundary', + ), + ).toBe(true); + } + }); }); From cf05dd516cef2b73e67a64fe89712cb7adfb927b Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sun, 16 Aug 2026 17:39:01 +0000 Subject: [PATCH 14/26] fix(lint): close round-11 serve boundary gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical fixes: - '#name' package-imports specifiers sailed through: stripUrlSuffixes splits on '#' before the fail-closed check saw it, so the branch was dead code and '#s' classified outside. Check '#' before suffix stripping (fixture pins both entrances). - scheme detection used JS trim(), which keeps non-whitespace C0 controls — '\x01data:…' slipped past while Node's URL parser strips C0-or-space at the edges and loaded it. Detect schemes on the WHATWG-normalized form (fixtures added). - eval("(0,eval)"/globalThis.eval spellings included) and new Function can embed import('…') the rule cannot resolve — fail closed like computed sources; no-eval/no-new-func are not enabled in the shared config and the guarded trees contain no such calls. - backslashes normalize to '/' under Node's URL-based ESM resolution (file: URLs are special), so '..\\serve\\x.js' loaded serve/ on posix while the rule saw a bare specifier. Normalize backslashes before classification (fixture added). Hardening + pins: - fork/Worker arms match object-agnostically (namespace/default-import spellings no longer evade); Worker skips new URL(spec, import.meta.url) arguments so the URL arm owns them (no more fail-closed false positive on the canonical construct, no double report on serve targets). - new TSImportEqualsDeclaration visitor: import x = require('../serve/…') emits a working createRequire shim under tsc NodeNext. - isProcessObject accepts computed properties (globalThis['process']) and the Reflect.apply arm accepts computed getBuiltinModule; the three getBuiltinModule arms collapse into one via a shared property matcher. - vitest loader names lifted into a module-level constant; corrected two stale comments (fail-closed branches; R5-5 probe description). - fixtures: root-absolute/file: inside verdicts (repoRoot, messageId), fork member arm, template cooked values, bare vitest loaders, dynamic bare-'module' entrances, outside-serve negatives for URL/Worker/fork/ require. Suite 44/44. --- eslint-rules/no-serve-boundary-cross.js | 174 ++++++++++---- scripts/tests/eslint-boundary-rules.test.js | 238 ++++++++++++++++++-- 2 files changed, 351 insertions(+), 61 deletions(-) diff --git a/eslint-rules/no-serve-boundary-cross.js b/eslint-rules/no-serve-boundary-cross.js index 9b1366ae6d1..5cd010bdb37 100644 --- a/eslint-rules/no-serve-boundary-cross.js +++ b/eslint-rules/no-serve-boundary-cross.js @@ -47,6 +47,9 @@ 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 { @@ -119,29 +122,41 @@ export default { function classifySpecifier(raw) { if (typeof raw !== 'string' || raw.length === 0) return 'unknown'; - // URL schemes are case-insensitive, and the URL parser strips - // leading/trailing whitespace (`import(' DATA:...')` still loads) — - // detect schemes on the trimmed, lowercased form. Non-URL specifiers - // (relative/bare) are NOT trimmed: Node resolves those verbatim. - const normalized = raw.replace(/[\t\n\r]/g, ''); - const lower = normalized.trim().toLowerCase(); + // 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( + /^[\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; anything not provably - // outside serve is fail-closed (a guarded tree does not import by - // URL). + // 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(normalized.trim())), + fileURLToPath(stripUrlSuffixes(trimmed)), ); return isInServeDir(resolved, serveDir) ? 'inside' : 'unknown'; } catch { @@ -150,16 +165,17 @@ export default { } // Root-absolute paths map straight to the filesystem — fail closed - // unless provably outside serve. - if (normalized.startsWith('/')) { - const decoded = decodeSpecifier(stripUrlSuffixes(normalized)); + // 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(normalized)); + const cleaned = decodeSpecifier(stripUrlSuffixes(trimmed)); if (cleaned === undefined) return 'unknown'; // Relative specifiers resolve against the importing file. @@ -168,8 +184,6 @@ export default { return isInServeDir(resolved, serveDir) ? 'inside' : 'outside'; } - if (cleaned.startsWith('#')) return 'unknown'; - // 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 @@ -240,10 +254,26 @@ export default { return ( (node.type === 'Identifier' && node.name === 'process') || (node.type === 'MemberExpression' && - node.property.type === 'Identifier' && - node.property.name === 'process' && node.object.type === 'Identifier' && - (node.object.name === 'globalThis' || node.object.name === 'global')) + (node.object.name === 'globalThis' || + node.object.name === 'global') && + ((node.property.type === 'Identifier' && + node.property.name === 'process') || + (node.computed && + node.property.type === 'Literal' && + node.property.value === 'process'))) + ); + } + + /** getBuiltinModule as a property name — identifier or computed + * string-literal spelling. */ + function builtinModuleProperty(memberExpr) { + return ( + (memberExpr.property.type === 'Identifier' && + memberExpr.property.name === 'getBuiltinModule') || + (memberExpr.computed && + memberExpr.property.type === 'Literal' && + memberExpr.property.value === 'getBuiltinModule') ); } @@ -272,9 +302,42 @@ export default { 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) { const callee = node.callee; + // eval("import('...')") executes string code that can load any + // module — the source is visible but unresolvable, so fail closed + // like computed sources. Covers the `(0, eval)(...)` and + // `globalThis.eval(...)` spellings; no-eval is not enabled in the + // shared config and the guarded trees contain no eval calls. + const evalCallee = + callee.type === 'SequenceExpression' + ? callee.expressions[callee.expressions.length - 1] + : callee; + if ( + (evalCallee.type === 'Identifier' && evalCallee.name === 'eval') || + (evalCallee.type === 'MemberExpression' && + evalCallee.object.type === 'Identifier' && + (evalCallee.object.name === 'globalThis' || + evalCallee.object.name === 'global') && + ((evalCallee.property.type === 'Identifier' && + evalCallee.property.name === 'eval') || + (evalCallee.computed && + evalCallee.property.type === 'Literal' && + evalCallee.property.value === 'eval'))) + ) { + if (node.arguments.length > 0) reportUnknown(node); + return; + } + // vi.mock / vi.doMock / vi.importActual / vi.importMock — vitest // resolves (and, without a factory, loads) the real module. The // object name is deliberately NOT matched: aliased spellings @@ -284,7 +347,7 @@ export default { // method names. Only specifiers resolving INTO serve/ report, so // this cannot false-positive on other packages' modules. if ( - memberCall(node, null, /^(?:mock|doMock|importActual|importMock)$/) && + memberCall(node, null, vitestLoaderNames) && node.arguments.length > 0 ) { checkSource(node.arguments[0]); @@ -308,7 +371,7 @@ export default { // is never flagged. if ( callee.type === 'Identifier' && - /^(?:mock|doMock|importActual|importMock)$/.test(callee.name) && + vitestLoaderNames.test(callee.name) && node.arguments.length > 0 ) { checkSource(node.arguments[0]); @@ -317,22 +380,22 @@ export default { // process.getBuiltinModule(...) hands out module objects // (createRequire) without any import statement (round-8 entrance). + // isProcessObject covers `process`, `globalThis.process` and + // `global.process` including the computed property spellings; + // builtinModuleProperty covers identifier and computed property + // names; the bare identifier is the destructured spelling; + // Reflect.apply(process.getBuiltinModule, ...) unwraps to the + // same member shape. if ( - memberCall(node, ['process'], /^getBuiltinModule$/) || (callee.type === 'MemberExpression' && isProcessObject(callee.object) && - ((callee.property.type === 'Identifier' && - callee.property.name === 'getBuiltinModule') || - (callee.computed && - callee.property.type === 'Literal' && - callee.property.value === 'getBuiltinModule'))) || + builtinModuleProperty(callee)) || (callee.type === 'Identifier' && callee.name === 'getBuiltinModule') || (memberCall(node, ['Reflect'], /^apply$/) && node.arguments[0]?.type === 'MemberExpression' && isProcessObject(node.arguments[0].object) && - node.arguments[0].property.type === 'Identifier' && - node.arguments[0].property.name === 'getBuiltinModule') + builtinModuleProperty(node.arguments[0])) ) { reportUnknown(node, 'moduleBuiltin'); return; @@ -343,13 +406,14 @@ export default { // 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 bare - // identifier covers the destructured spelling - // (`import { fork } from 'node:child_process'`); only specifiers - // resolving INTO serve/ report, so a non-serve fork target is - // never flagged. + // 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), and the bare + // identifier covers destructured `fork`; only specifiers resolving + // INTO serve/ report, so a non-serve fork target is never flagged. if ( - (memberCall(node, ['child_process'], /^fork$/) || + (memberCall(node, null, /^fork$/) || (callee.type === 'Identifier' && callee.name === 'fork')) && node.arguments.length > 0 ) { @@ -358,14 +422,46 @@ export default { } }, NewExpression(node) { - // new Worker('../serve/...') — string-literal module paths resolve - // relative to the importing module. + // new Function(body) compiles arbitrary string code that can + // import() anything — the body is visible but unresolvable, so + // fail closed like computed sources (eval's sibling). if ( node.callee.type === 'Identifier' && - node.callee.name === 'Worker' && + node.callee.name === 'Function' && node.arguments.length > 0 ) { - checkSource(node.arguments[0]); + reportUnknown(node); + return; + } + + // new Worker('../serve/...') / new wt.Worker('...') — string + // module paths resolve relative to the importing module + // (worker_threads does the same). Object-agnostic member match + // covers namespace/default-import spellings. + if ( + (node.callee.type === 'Identifier' + ? node.callee.name === 'Worker' + : node.callee.type === 'MemberExpression' && + (node.callee.property.type === 'Identifier' + ? node.callee.property.name === 'Worker' + : node.callee.computed && + node.callee.property.type === 'Literal' && + node.callee.property.value === 'Worker')) && + node.arguments.length > 0 + ) { + // 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'; + if (!handledByUrlArm) checkSource(arg); return; } diff --git a/scripts/tests/eslint-boundary-rules.test.js b/scripts/tests/eslint-boundary-rules.test.js index 683e28e5bae..d1ab697a32c 100644 --- a/scripts/tests/eslint-boundary-rules.test.js +++ b/scripts/tests/eslint-boundary-rules.test.js @@ -17,6 +17,11 @@ const repoRoot = path.resolve( 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 UTILS_FIXTURE = 'packages/cli/src/utils/boundary-fixture.ts'; + const lintCliFile = (filePath, code) => eslint.lintText(code, { filePath: path.join(repoRoot, filePath) }); @@ -27,6 +32,17 @@ const expectServeBoundaryError = async (filePath, code) => { ); }; +/** 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( @@ -121,9 +137,10 @@ describe('eslint cli serve boundary rules', () => { ); }); - // R5-5: the override blocks restate restrictedStringThrow; flat config's - // last-wins semantics mean dropping the restatement would silently legalize - // string throws in exactly these trees. This probe pins it. + // 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', @@ -435,19 +452,11 @@ describe('eslint cli serve boundary rules', () => { "import sub from '@scope/serve/handler.js';", '', ].join('\n'); - const [result] = await lintCliFile( - 'packages/cli/src/acp-integration/boundary-fixture.ts', - code, - ); // 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). - const boundaryHits = result.messages.filter( - (message) => - message.ruleId === 'qwen-boundary/no-serve-boundary-cross', - ); - expect(boundaryHits).toEqual([]); + await expectNoBoundaryHits(ACP_FIXTURE, code); }); // R9-5: the re-export / Worker / fork / require visitors had no fixture @@ -464,7 +473,10 @@ describe('eslint cli serve boundary rules', () => { runtime, "export { x } from '../serve/index.js';", ); - await expectServeBoundaryError(runtime, "new Worker('../serve/worker.js');"); + await expectServeBoundaryError( + runtime, + "new Worker('../serve/worker.js');", + ); await expectServeBoundaryError(runtime, "require('../serve/index.js');"); await expectServeBoundaryError( runtime, @@ -486,15 +498,10 @@ describe('eslint cli serve boundary rules', () => { // 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 () => { - const [result] = await lintCliFile( - 'packages/cli/src/acp-integration/boundary-fixture.ts', + await expectNoBoundaryHits( + ACP_FIXTURE, 'export async function load() { await import(`../utils/boundary-fixture.ts`); }', ); - const boundaryHits = result.messages.filter( - (message) => - message.ruleId === 'qwen-boundary/no-serve-boundary-cross', - ); - expect(boundaryHits).toEqual([]); }); // R13-2: resolution-based detections must report via the serveBoundary @@ -502,12 +509,106 @@ describe('eslint cli serve boundary rules', () => { // rejection the substring-based positive helper stays green, so pin the // messageId directly. it('reports resolution detections via the serveBoundary messageId', async () => { - const acp = 'packages/cli/src/acp-integration/boundary-fixture.ts'; for (const code of [ "import '../serve/index.js';", "export async function load() { return import('src/serve/index.js'); }", ]) { - const [result] = await lintCliFile(acp, code); + 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', @@ -515,4 +616,97 @@ describe('eslint cli serve boundary rules', () => { ).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');", + ); + }); }); From 2d59792f8004642dc7af7134fc650edbb067d13d Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Mon, 17 Aug 2026 02:45:06 +0800 Subject: [PATCH 15/26] fix(cli): restore live session source import --- packages/cli/src/serve/live/live-task-service.ts | 2 +- packages/cli/src/serve/routes/session.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/serve/live/live-task-service.ts b/packages/cli/src/serve/live/live-task-service.ts index 0b15214d628..914314062a5 100644 --- a/packages/cli/src/serve/live/live-task-service.ts +++ b/packages/cli/src/serve/live/live-task-service.ts @@ -37,7 +37,7 @@ import { listWorkspaceSessionsForResponse } from '../server/session-list.js'; import { isCompatibleLiveSessionSource, readLoadableLiveConversationMetadata, -} from '../conversations/session-source.js'; +} from '../../runtime/live-session-source.js'; import { conversationRuntimeUnavailableError } from '../conversations/conversation-runtime-errors.js'; const DEFAULT_LIST_LIMIT = 20; diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 246b25f8010..ad6236fee10 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -40,7 +40,7 @@ import { parseSessionSource } from '@qwen-code/acp-bridge'; import { isReservedLiveSessionSource, readLoadableLiveConversationMetadata, -} from '../conversations/session-source.js'; +} from '../../runtime/live-session-source.js'; import type { ConversationRuntimeActivityGate } from '../conversations/conversation-runtime-activity.js'; import { ConversationRuntimeOwnershipError } from '../conversations/conversation-runtime-errors.js'; import type { Application, Request, RequestHandler, Response } from 'express'; From 3da80b9db10f7272b505fd559f34acc5f0433111 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sun, 16 Aug 2026 19:28:48 +0000 Subject: [PATCH 16/26] fix(lint): clear the two lint errors breaking CI on the boundary rule Follow-up to the round-11 batch, which landed without running the repo lint: - the C0-edge-strip regex legitimately contains control-character ranges (it mirrors the WHATWG URL parser), so disable no-control-regex on that line with a rationale comment instead of rewriting the range. - drop the unused UTILS_FIXTURE constant from the boundary tests (no fixture lints a utils/ file). eslint clean on both files, boundary suite 44/44, prettier clean. --- eslint-rules/no-serve-boundary-cross.js | 4 ++++ scripts/tests/eslint-boundary-rules.test.js | 1 - 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/eslint-rules/no-serve-boundary-cross.js b/eslint-rules/no-serve-boundary-cross.js index 5cd010bdb37..f4f08bf5757 100644 --- a/eslint-rules/no-serve-boundary-cross.js +++ b/eslint-rules/no-serve-boundary-cross.js @@ -131,6 +131,10 @@ export default { // 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, '', ); diff --git a/scripts/tests/eslint-boundary-rules.test.js b/scripts/tests/eslint-boundary-rules.test.js index d1ab697a32c..0e21d791712 100644 --- a/scripts/tests/eslint-boundary-rules.test.js +++ b/scripts/tests/eslint-boundary-rules.test.js @@ -20,7 +20,6 @@ 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 UTILS_FIXTURE = 'packages/cli/src/utils/boundary-fixture.ts'; const lintCliFile = (filePath, code) => eslint.lintText(code, { filePath: path.join(repoRoot, filePath) }); From c4758e34c1f4607ecc0f1fc95d3220b7e1cb17f9 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Mon, 17 Aug 2026 06:35:48 +0800 Subject: [PATCH 17/26] fix(lint): close bounded serve boundary gaps --- eslint-rules/no-serve-boundary-cross.js | 115 ++++++++++++++------ scripts/tests/eslint-boundary-rules.test.js | 43 ++++++++ 2 files changed, 125 insertions(+), 33 deletions(-) diff --git a/eslint-rules/no-serve-boundary-cross.js b/eslint-rules/no-serve-boundary-cross.js index f4f08bf5757..438b9d702b3 100644 --- a/eslint-rules/no-serve-boundary-cross.js +++ b/eslint-rules/no-serve-boundary-cross.js @@ -74,6 +74,23 @@ function staticTemplateValue(template) { return template.quasis.map((quasi) => quasi.value.cooked ?? '').join(''); } +function staticMemberPropertyName(memberExpr) { + if (!memberExpr.computed && memberExpr.property.type === 'Identifier') { + return memberExpr.property.name; + } + if ( + memberExpr.computed && + memberExpr.property.type === 'Literal' && + typeof memberExpr.property.value === 'string' + ) { + return memberExpr.property.value; + } + if (memberExpr.computed && memberExpr.property.type === 'TemplateLiteral') { + return staticTemplateValue(memberExpr.property); + } + return undefined; +} + export default { meta: { type: 'problem', @@ -107,8 +124,12 @@ export default { create(context) { const options = context.options[0] ?? {}; - const serveDir = options.serveDir; - const baseUrlDir = options.baseUrlDir; + 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)); @@ -194,7 +215,7 @@ export default { // attributed to any package — fail closed. if (cleaned.includes('../')) return 'unknown'; if (baseUrlDir) { - const resolved = path.resolve(baseUrlDir, cleaned); + const resolved = resolvePath(path.join(baseUrlDir, cleaned)); if (isInServeDir(resolved, serveDir)) return 'inside'; } return 'outside'; @@ -234,21 +255,12 @@ export default { * ANY object identifier (alias-proof — the caller asserts safety). */ function memberCall(node, objectNames, propertyPattern) { const callee = node.callee; - const property = - callee.type === 'MemberExpression' && !callee.computed - ? callee.property.type === 'Identifier' - ? callee.property.name - : undefined - : callee.type === 'MemberExpression' && - callee.computed && - callee.property.type === 'Literal' && - typeof callee.property.value === 'string' - ? callee.property.value - : undefined; + if (callee.type !== 'MemberExpression') return false; + const property = staticMemberPropertyName(callee); return ( - callee.type === 'MemberExpression' && - callee.object.type === 'Identifier' && - (objectNames === null || objectNames.includes(callee.object.name)) && + (objectNames === null || + (callee.object.type === 'Identifier' && + objectNames.includes(callee.object.name))) && property !== undefined && propertyPattern.test(property) ); @@ -261,23 +273,39 @@ export default { node.object.type === 'Identifier' && (node.object.name === 'globalThis' || node.object.name === 'global') && - ((node.property.type === 'Identifier' && - node.property.name === 'process') || - (node.computed && - node.property.type === 'Literal' && - node.property.value === 'process'))) + staticMemberPropertyName(node) === 'process') ); } /** getBuiltinModule as a property name — identifier or computed * string-literal spelling. */ function builtinModuleProperty(memberExpr) { + return staticMemberPropertyName(memberExpr) === 'getBuiltinModule'; + } + + function isImportMetaUrl(node) { + return ( + node?.type === 'MemberExpression' && + node.object.type === 'MetaProperty' && + staticMemberPropertyName(node) === 'url' + ); + } + + function hasWorkerEvalOption(node) { + const options = node.arguments[1]; return ( - (memberExpr.property.type === 'Identifier' && - memberExpr.property.name === 'getBuiltinModule') || - (memberExpr.computed && - memberExpr.property.type === 'Literal' && - memberExpr.property.value === 'getBuiltinModule') + options?.type === 'ObjectExpression' && + options.properties.some( + (property) => + property.type === 'Property' && + ((property.key.type === 'Identifier' && + property.key.name === 'eval') || + (property.computed && + property.key.type === 'Literal' && + property.key.value === 'eval')) && + property.value.type === 'Literal' && + property.value.value === true, + ) ); } @@ -405,6 +433,20 @@ export default { return; } + // Function(...) is new Function(...) without `new`. + if ( + ((callee.type === 'Identifier' && callee.name === 'Function') || + (callee.type === 'MemberExpression' && + callee.object.type === 'Identifier' && + (callee.object.name === 'globalThis' || + callee.object.name === 'global') && + staticMemberPropertyName(callee) === 'Function')) && + node.arguments.length > 0 + ) { + 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 @@ -430,8 +472,13 @@ export default { // import() anything — the body is visible but unresolvable, so // fail closed like computed sources (eval's sibling). if ( - node.callee.type === 'Identifier' && - node.callee.name === 'Function' && + ((node.callee.type === 'Identifier' && + node.callee.name === 'Function') || + (node.callee.type === 'MemberExpression' && + node.callee.object.type === 'Identifier' && + (node.callee.object.name === 'globalThis' || + node.callee.object.name === 'global') && + staticMemberPropertyName(node.callee) === 'Function')) && node.arguments.length > 0 ) { reportUnknown(node); @@ -463,9 +510,9 @@ export default { arg.callee.type === 'Identifier' && arg.callee.name === 'URL' && arg.arguments.length >= 2 && - arg.arguments[1].type === 'MemberExpression' && - arg.arguments[1].object.type === 'MetaProperty'; - if (!handledByUrlArm) checkSource(arg); + isImportMetaUrl(arg.arguments[1]); + if (hasWorkerEvalOption(node)) reportUnknown(arg); + else if (!handledByUrlArm) checkSource(arg); return; } @@ -480,7 +527,9 @@ export default { node.arguments[1].type === 'MemberExpression' && node.arguments[1].object.type === 'MetaProperty' ) { - checkSource(node.arguments[0]); + if (isImportMetaUrl(node.arguments[1])) + checkSource(node.arguments[0]); + else reportUnknown(node.arguments[1]); } }, }; diff --git a/scripts/tests/eslint-boundary-rules.test.js b/scripts/tests/eslint-boundary-rules.test.js index 0e21d791712..d7513701c4b 100644 --- a/scripts/tests/eslint-boundary-rules.test.js +++ b/scripts/tests/eslint-boundary-rules.test.js @@ -31,6 +31,13 @@ const expectServeBoundaryError = async (filePath, code) => { ); }; +const expectBoundaryMessage = async (filePath, code, messageId) => { + const [result] = await lintCliFile(filePath, code); + expect( + result.messages.some((message) => message.messageId === messageId), + ).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). */ @@ -236,6 +243,14 @@ describe('eslint cli serve boundary rules', () => { acp, "vitest.mock('../serve/live/live-task-service.js');", ); + await expectServeBoundaryError( + acp, + "globalThis.vi.mock('../serve/live/live-task-service.js');", + ); + await expectServeBoundaryError( + acp, + "vi[`mock`]('../serve/live/live-task-service.js');", + ); // A non-serve vi.mock stays silent on the boundary. const [result] = await lintCliFile(acp, "vi.mock('../utils/foo.js');"); @@ -401,6 +416,21 @@ describe('eslint cli serve boundary rules', () => { } finally { rmSync(link, { force: true }); } + + const bareLink = path.join( + repoRoot, + 'packages/cli/src/acp-integration/serve-link.js', + ); + rmSync(bareLink, { force: true }); + try { + symlinkSync('../serve/index.ts', bareLink); + await expectServeBoundaryError( + ACP_FIXTURE, + "import 'src/acp-integration/serve-link.js';", + ); + } finally { + rmSync(bareLink, { force: true }); + } }); // Codex self-review: vitest loaders reached through an alias evade the @@ -557,6 +587,8 @@ describe('eslint cli serve boundary rules', () => { 'eval("import(\'../serve/index.js\')");', '(0, eval)("import(\'../serve/index.js\')");', 'globalThis.eval("import(\'../serve/index.js\')");', + 'Function("return import(\'../serve/index.js\')");', + 'globalThis.Function("return import(\'../serve/index.js\')");', 'const load = new Function("return import(\'../serve/index.js\')");', ]) { await expectServeBoundaryError(ACP_FIXTURE, code); @@ -684,6 +716,17 @@ describe('eslint cli serve boundary rules', () => { ); expect(hits).toHaveLength(1); expect(hits[0].messageId).toBe('serveBoundary'); + + await expectBoundaryMessage( + RUNTIME_FIXTURE, + "const w = new Worker(new URL('./worker.js', import.meta.env));", + 'failClosed', + ); + await expectBoundaryMessage( + RUNTIME_FIXTURE, + 'new Worker("import(\'../serve/worker.js\')", { eval: true });', + 'failClosed', + ); }); // The outside-serve (allow) verdict of the checkSource arms had zero From 7112ab4332354303ab3df57b0df518cf98680145 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sun, 16 Aug 2026 22:43:38 +0000 Subject: [PATCH 18/26] fix(lint): complete the round-12 boundary escape closures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the previous commit (which canonicalized the serve/baseUrl comparison sides, added staticMemberPropertyName, and closed the Function-call and Worker-eval-option shapes) with the remaining round-12 review surface — every demonstrated spelling probed before and after: - Callee identity is now shape-tolerant end to end: rightmost-segment object matching (nested member objects like globalThis.vi / x.cp no longer evade the object-agnostic arms), renamed loader bindings resolved from the import declarations (fork-as-f, Worker-as-W), Reflect.apply/construct unwrapped for guarded targets (fork included), Function.prototype.call/apply/bind indirection handled (.call unwraps with shifted args; .apply/.bind fail closed), and the SequenceExpression unwrap applied uniformly instead of eval-only. - The string-code execution class fails closed beyond Function/eval direct calls: .constructor property chains (({}).constructor.constructor, (function(){}).constructor, AsyncFunction variants), eval.call/apply, and the node:vm surface (runInThisContext / runInNewContext / runInContext / compileFunction / new vm.Script, scoped to vm imports). - The Worker eval option fails closed unless eval is statically false (a dynamic option or non-object second argument is unverifiable), and the URL arm's import.meta base restriction reports failClosed on the construct. - Fixtures pin each class: shape variants, renamed bindings, Reflect indirection, call/apply/bind, the string-code family (incl. messageId- specific failClosed pins for the eval:true Worker and non-url import.meta bases), bare-directory/baseUrl query-suffix spellings, and outside-serve allow pins for the export/import-equals arms. - expectServeBoundaryError now filters on the rule id (all three messageIds contain 'serve'); the divergent substring negative pins move to expectNoBoundaryHits. Suite 52/52; guarded trees lint clean (no false positives from the new arms); eslint + prettier clean. --- eslint-rules/no-serve-boundary-cross.js | 467 ++++++++++++++------ scripts/tests/eslint-boundary-rules.test.js | 242 +++++++--- 2 files changed, 506 insertions(+), 203 deletions(-) diff --git a/eslint-rules/no-serve-boundary-cross.js b/eslint-rules/no-serve-boundary-cross.js index 438b9d702b3..a995f240b8b 100644 --- a/eslint-rules/no-serve-boundary-cross.js +++ b/eslint-rules/no-serve-boundary-cross.js @@ -74,23 +74,6 @@ function staticTemplateValue(template) { return template.quasis.map((quasi) => quasi.value.cooked ?? '').join(''); } -function staticMemberPropertyName(memberExpr) { - if (!memberExpr.computed && memberExpr.property.type === 'Identifier') { - return memberExpr.property.name; - } - if ( - memberExpr.computed && - memberExpr.property.type === 'Literal' && - typeof memberExpr.property.value === 'string' - ) { - return memberExpr.property.value; - } - if (memberExpr.computed && memberExpr.property.type === 'TemplateLiteral') { - return staticTemplateValue(memberExpr.property); - } - return undefined; -} - export default { meta: { type: 'problem', @@ -124,6 +107,11 @@ export default { 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; @@ -212,10 +200,13 @@ export default { // 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. + // 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.join(baseUrlDir, cleaned)); + const resolved = resolvePath(path.resolve(baseUrlDir, cleaned)); if (isInServeDir(resolved, serveDir)) return 'inside'; } return 'outside'; @@ -251,63 +242,81 @@ export default { 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 identifier (alias-proof — the caller asserts safety). */ - function memberCall(node, objectNames, propertyPattern) { - const callee = node.callee; + * ANY object shape (alias-proof — the caller asserts safety). */ + function memberCall(callee, objectNames, propertyPattern) { if (callee.type !== 'MemberExpression') return false; - const property = staticMemberPropertyName(callee); - return ( - (objectNames === null || - (callee.object.type === 'Identifier' && - objectNames.includes(callee.object.name))) && - property !== undefined && - propertyPattern.test(property) - ); + 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 ( - (node.type === 'Identifier' && node.name === 'process') || - (node.type === 'MemberExpression' && - node.object.type === 'Identifier' && - (node.object.name === 'globalThis' || - node.object.name === 'global') && - staticMemberPropertyName(node) === 'process') + (objectName === 'globalThis' || objectName === 'global') && + staticPropertyName(node.property, node.computed) === 'process' ); } - /** getBuiltinModule as a property name — identifier or computed - * string-literal spelling. */ + /** getBuiltinModule as a property name — any statically resolvable + * spelling. */ function builtinModuleProperty(memberExpr) { - return staticMemberPropertyName(memberExpr) === 'getBuiltinModule'; - } - - function isImportMetaUrl(node) { return ( - node?.type === 'MemberExpression' && - node.object.type === 'MetaProperty' && - staticMemberPropertyName(node) === 'url' + staticPropertyName(memberExpr.property, memberExpr.computed) === + 'getBuiltinModule' ); } - function hasWorkerEvalOption(node) { - const options = node.arguments[1]; - return ( - options?.type === 'ObjectExpression' && - options.properties.some( - (property) => - property.type === 'Property' && - ((property.key.type === 'Identifier' && - property.key.name === 'eval') || - (property.computed && - property.key.type === 'Literal' && - property.key.value === 'eval')) && - property.value.type === 'Literal' && - property.value.value === true, - ) - ); - } + // 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) { @@ -318,6 +327,30 @@ export default { 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) { @@ -343,43 +376,125 @@ export default { } }, CallExpression(node) { - const callee = node.callee; - - // eval("import('...')") executes string code that can load any - // module — the source is visible but unresolvable, so fail closed - // like computed sources. Covers the `(0, eval)(...)` and - // `globalThis.eval(...)` spellings; no-eval is not enabled in the - // shared config and the guarded trees contain no eval calls. - const evalCallee = - callee.type === 'SequenceExpression' - ? callee.expressions[callee.expressions.length - 1] - : callee; + // `(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 ( - (evalCallee.type === 'Identifier' && evalCallee.name === 'eval') || - (evalCallee.type === 'MemberExpression' && - evalCallee.object.type === 'Identifier' && - (evalCallee.object.name === 'globalThis' || - evalCallee.object.name === 'global') && - ((evalCallee.property.type === 'Identifier' && - evalCallee.property.name === 'eval') || - (evalCallee.computed && - evalCallee.property.type === 'Literal' && - evalCallee.property.value === 'eval'))) + node.arguments.length > 0 && + (calleeName === 'eval' || + calleeName === 'Function' || + calleeName === 'constructor') ) { - if (node.arguments.length > 0) reportUnknown(node); + 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 name is deliberately NOT matched: aliased spellings - // (`import { vi as v } from 'vitest'; v.mock(...)`, destructured - // `importActual(...)`) evade identifier checks (round-8 entrance), + // 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(node, null, vitestLoaderNames) && + memberCall(callee, null, vitestLoaderNames) && node.arguments.length > 0 ) { checkSource(node.arguments[0]); @@ -413,37 +528,47 @@ export default { // process.getBuiltinModule(...) hands out module objects // (createRequire) without any import statement (round-8 entrance). // isProcessObject covers `process`, `globalThis.process` and - // `global.process` including the computed property spellings; - // builtinModuleProperty covers identifier and computed property - // names; the bare identifier is the destructured spelling; - // Reflect.apply(process.getBuiltinModule, ...) unwraps to the - // same member shape. + // `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') || - (memberCall(node, ['Reflect'], /^apply$/) && - node.arguments[0]?.type === 'MemberExpression' && - isProcessObject(node.arguments[0].object) && - builtinModuleProperty(node.arguments[0])) + (callee.type === 'Identifier' && callee.name === 'getBuiltinModule') ) { reportUnknown(node, 'moduleBuiltin'); return; } - // Function(...) is new Function(...) without `new`. - if ( - ((callee.type === 'Identifier' && callee.name === 'Function') || - (callee.type === 'MemberExpression' && - callee.object.type === 'Identifier' && - (callee.object.name === 'globalThis' || - callee.object.name === 'global') && - staticMemberPropertyName(callee) === 'Function')) && - node.arguments.length > 0 - ) { - reportUnknown(node); + // 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; } @@ -455,12 +580,14 @@ export default { // 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), and the bare - // identifier covers destructured `fork`; only specifiers resolving - // INTO serve/ report, so a non-serve fork target is never flagged. + // 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(node, null, /^fork$/) || - (callee.type === 'Identifier' && callee.name === 'fork')) && + (memberCall(callee, null, /^fork$/) || + (callee.type === 'Identifier' && + (callee.name === 'fork' || forkAliases.has(callee.name)))) && node.arguments.length > 0 ) { checkSource(node.arguments[0]); @@ -468,38 +595,71 @@ export default { } }, NewExpression(node) { - // new Function(body) compiles arbitrary string code that can - // import() anything — the body is visible but unresolvable, so - // fail closed like computed sources (eval's sibling). + 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.callee.type === 'Identifier' && - node.callee.name === 'Function') || - (node.callee.type === 'MemberExpression' && - node.callee.object.type === 'Identifier' && - (node.callee.object.name === 'globalThis' || - node.callee.object.name === 'global') && - staticMemberPropertyName(node.callee) === 'Function')) && - node.arguments.length > 0 + node.arguments.length > 0 && + ((callee.type === 'Identifier' && callee.name === 'Function') || + (callee.type === 'MemberExpression' && + staticPropertyName(callee.property, callee.computed) === + 'Function')) ) { reportUnknown(node); return; } - // new Worker('../serve/...') / new wt.Worker('...') — string - // module paths resolve relative to the importing module - // (worker_threads does the same). Object-agnostic member match - // covers namespace/default-import spellings. + // new vm.Script(code) / new Script(code) — string-code + // compilation, same class as Function (vm import spellings). if ( - (node.callee.type === 'Identifier' - ? node.callee.name === 'Worker' - : node.callee.type === 'MemberExpression' && - (node.callee.property.type === 'Identifier' - ? node.callee.property.name === 'Worker' - : node.callee.computed && - node.callee.property.type === 'Literal' && - node.callee.property.value === 'Worker')) && - node.arguments.length > 0 + 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 @@ -510,26 +670,39 @@ export default { arg.callee.type === 'Identifier' && arg.callee.name === 'URL' && arg.arguments.length >= 2 && - isImportMetaUrl(arg.arguments[1]); - if (hasWorkerEvalOption(node)) reportUnknown(arg); - else if (!handledByUrlArm) checkSource(arg); + 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. + // argument against this module. Only import.meta.URL is a + // statically known base — import.meta. cannot be + // resolved, so fail closed instead of assuming the module base. if ( - node.callee.type === 'Identifier' && - node.callee.name === 'URL' && + callee.type === 'Identifier' && + callee.name === 'URL' && node.arguments.length >= 2 && node.arguments[1].type === 'MemberExpression' && node.arguments[1].object.type === 'MetaProperty' ) { - if (isImportMetaUrl(node.arguments[1])) + if ( + staticPropertyName( + node.arguments[1].property, + node.arguments[1].computed, + ) === 'url' + ) { checkSource(node.arguments[0]); - else reportUnknown(node.arguments[1]); + } else { + reportUnknown(node); + } } }, }; diff --git a/scripts/tests/eslint-boundary-rules.test.js b/scripts/tests/eslint-boundary-rules.test.js index d7513701c4b..b013505b6aa 100644 --- a/scripts/tests/eslint-boundary-rules.test.js +++ b/scripts/tests/eslint-boundary-rules.test.js @@ -24,20 +24,17 @@ 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.map((message) => message.message)).toEqual( - expect.arrayContaining([expect.stringContaining('serve')]), + expect(result.messages.some((message) => message.ruleId === RULE_ID)).toBe( + true, ); }; -const expectBoundaryMessage = async (filePath, code, messageId) => { - const [result] = await lintCliFile(filePath, code); - expect( - result.messages.some((message) => message.messageId === messageId), - ).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). */ @@ -243,21 +240,9 @@ describe('eslint cli serve boundary rules', () => { acp, "vitest.mock('../serve/live/live-task-service.js');", ); - await expectServeBoundaryError( - acp, - "globalThis.vi.mock('../serve/live/live-task-service.js');", - ); - await expectServeBoundaryError( - acp, - "vi[`mock`]('../serve/live/live-task-service.js');", - ); // A non-serve vi.mock stays silent on the boundary. - const [result] = await lintCliFile(acp, "vi.mock('../utils/foo.js');"); - const boundaryHits = result.messages.filter((message) => - message.message.includes('serve'), - ); - expect(boundaryHits).toEqual([]); + await expectNoBoundaryHits(acp, "vi.mock('../utils/foo.js');"); }); // Round-7 entrances (#8084): each spelling below resolves to serve/ @@ -416,21 +401,6 @@ describe('eslint cli serve boundary rules', () => { } finally { rmSync(link, { force: true }); } - - const bareLink = path.join( - repoRoot, - 'packages/cli/src/acp-integration/serve-link.js', - ); - rmSync(bareLink, { force: true }); - try { - symlinkSync('../serve/index.ts', bareLink); - await expectServeBoundaryError( - ACP_FIXTURE, - "import 'src/acp-integration/serve-link.js';", - ); - } finally { - rmSync(bareLink, { force: true }); - } }); // Codex self-review: vitest loaders reached through an alias evade the @@ -462,14 +432,10 @@ describe('eslint cli serve boundary rules', () => { // 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 () => { - const [result] = await lintCliFile( - 'packages/cli/src/acp-integration/boundary-fixture.ts', + await expectNoBoundaryHits( + ACP_FIXTURE, "import { spawn } from 'node:child_process';\nexport function run() { return spawn(process.execPath, ['--version']); }", ); - const boundaryHits = result.messages.filter((message) => - message.message.includes('serve'), - ); - expect(boundaryHits).toEqual([]); }); // R5-7: third-party packages whose name contains `serve` must not be @@ -587,8 +553,6 @@ describe('eslint cli serve boundary rules', () => { 'eval("import(\'../serve/index.js\')");', '(0, eval)("import(\'../serve/index.js\')");', 'globalThis.eval("import(\'../serve/index.js\')");', - 'Function("return import(\'../serve/index.js\')");', - 'globalThis.Function("return import(\'../serve/index.js\')");', 'const load = new Function("return import(\'../serve/index.js\')");', ]) { await expectServeBoundaryError(ACP_FIXTURE, code); @@ -716,17 +680,6 @@ describe('eslint cli serve boundary rules', () => { ); expect(hits).toHaveLength(1); expect(hits[0].messageId).toBe('serveBoundary'); - - await expectBoundaryMessage( - RUNTIME_FIXTURE, - "const w = new Worker(new URL('./worker.js', import.meta.env));", - 'failClosed', - ); - await expectBoundaryMessage( - RUNTIME_FIXTURE, - 'new Worker("import(\'../serve/worker.js\')", { eval: true });', - 'failClosed', - ); }); // The outside-serve (allow) verdict of the checkSource arms had zero @@ -751,4 +704,181 @@ describe('eslint cli serve boundary rules', () => { "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); + } + }); }); From 552bc7c8f68d2dd64e0b63d867470bcf7359157e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E8=89=AF?= <1204183885@qq.com> Date: Mon, 17 Aug 2026 01:51:19 +0000 Subject: [PATCH 19/26] fix(lint): close the round-12 reviewer escape classes (#8084) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- eslint-rules/no-serve-boundary-cross.js | 711 +--------------- scripts/tests/eslint-boundary-rules.test.js | 885 +------------------- 2 files changed, 2 insertions(+), 1594 deletions(-) diff --git a/eslint-rules/no-serve-boundary-cross.js b/eslint-rules/no-serve-boundary-cross.js index a995f240b8b..ad5c43e581c 100644 --- a/eslint-rules/no-serve-boundary-cross.js +++ b/eslint-rules/no-serve-boundary-cross.js @@ -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. 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); - } - } - }, - }; - }, -}; +þÙ©ý¹hoVú \ No newline at end of file diff --git a/scripts/tests/eslint-boundary-rules.test.js b/scripts/tests/eslint-boundary-rules.test.js index b013505b6aa..f337b390fd5 100644 --- a/scripts/tests/eslint-boundary-rules.test.js +++ b/scripts/tests/eslint-boundary-rules.test.js @@ -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); - } - }); -}); +þÙ©ý¹hofú \ No newline at end of file From c11b30ca3736b13fe74564d401ff36acdf8b98f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E8=89=AF?= <1204183885@qq.com> Date: Mon, 17 Aug 2026 02:30:39 +0000 Subject: [PATCH 20/26] fix(lint): repair corrupted files from the git-API blob upload The previous commit (552bc7c8f) was pushed via the GitHub git API with `-f content=@file` blob payloads that GitHub stored corrupted (9-byte binary blobs), breaking the eslint config load (SyntaxError) and the CI Test gate. Re-upload both files with JSON --input payloads whose blob SHAs match the local git objects byte-for-byte (8270a0d2 / 5a0ebaa2). No content change beyond restoring the intended files; suite 65/65. --- eslint-rules/no-serve-boundary-cross.js | 894 ++++++++++++++- scripts/tests/eslint-boundary-rules.test.js | 1135 ++++++++++++++++++- 2 files changed, 2027 insertions(+), 2 deletions(-) diff --git a/eslint-rules/no-serve-boundary-cross.js b/eslint-rules/no-serve-boundary-cross.js index ad5c43e581c..8270a0d2190 100644 --- a/eslint-rules/no-serve-boundary-cross.js +++ b/eslint-rules/no-serve-boundary-cross.js @@ -1 +1,893 @@ -þÙ©ý¹hoVú \ No newline at end of file +/** + * @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(); + + // Distinct verdict: the error is right but the generic failClosed + // advice ('use a plain string-literal relative specifier') is + // unactionable for a builtin — route to the dedicated message on + // every entrance, matching the ImportDeclaration arm (R12-13). + if (lower === 'module' || lower === 'node:module') { + return 'module-builtin'; + } + + // 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 }); + } + + /** new URL(spec, import.meta.url) — the URL arm resolves this + * construct itself (reporting a serve target exactly once); every + * other entrance must let it through instead of fail-closing the + * canonical fully-static dynamic-load pattern or double-reporting + * the serve form (R12-12). */ + function isNewUrlWithImportMetaUrl(sourceNode) { + const arg = unwrapSequence(sourceNode); + return ( + 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' + ); + } + + /** Check a Literal/TemplateLiteral/computed source node. */ + function checkSource(sourceNode) { + if (!sourceNode) return; + // Statically non-specifier arguments (env objects, numbers, + // functions) cannot load a module — treat them as non-imports + // instead of failing closed (R12-11: recorder.mock({ silent: true }) + // and cluster.fork(env) must not error with unactionable advice). + if ( + sourceNode.type === 'ObjectExpression' || + sourceNode.type === 'ArrayExpression' || + sourceNode.type === 'FunctionExpression' || + sourceNode.type === 'ArrowFunctionExpression' || + (sourceNode.type === 'Literal' && typeof sourceNode.value !== 'string') + ) { + return; + } + // The URL arm owns the new-URL-with-import.meta.url construct. + if (isNewUrlWithImportMetaUrl(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 === 'module-builtin') { + reportUnknown(sourceNode, 'moduleBuiltin'); + } else if (verdict === 'unknown') reportUnknown(sourceNode); + } + + /** Unwrap sequence expressions recursively: `(0, x)` evaluates to + * `x`, and double-wrapping `(0, (0, x))` is equally transparent + * (R12-2). */ + function unwrapSequence(node) { + let current = node; + while (current?.type === 'SequenceExpression') { + current = current.expressions[current.expressions.length - 1]; + } + return current; + } + + /** 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; sequence + * wrappers are transparent (`(0, process)` → 'process', R12-2). */ + function rightmostObjectName(objectNode) { + const node = unwrapSequence(objectNode); + if (node?.type === 'Identifier') return node.name; + if (node?.type === 'MemberExpression') { + return staticPropertyName(node.property, node.computed); + } + return undefined; + } + + /** A named guarded global object (process/globalThis/global) whose + * computed property key is statically unresolvable — one variable + * rename re-opens the guarded entrance, so fail closed (R12-7). + * Object-agnostic arms keep their documented residue. */ + function namedGuardedObjectWithOpaqueKey(memberExpr) { + return ( + memberExpr.type === 'MemberExpression' && + memberExpr.computed && + staticPropertyName(memberExpr.property, true) === undefined && + (() => { + const name = rightmostObjectName(memberExpr.object); + return ( + name === 'process' || name === 'globalThis' || name === 'global' + ); + })() + ); + } + + /** Inline lazy vm imports as callee objects: `(await import('node:vm'))` + * or `import('vm')` — canonical ESM spellings that need no aliasing + * (R12-9). */ + function isVmLazyImportObject(objectNode) { + let node = unwrapSequence(objectNode); + if (node?.type === 'AwaitExpression') { + node = unwrapSequence(node.argument); + } + if (node?.type !== 'ImportExpression') return false; + const source = + node.source?.type === 'Literal' ? node.source.value : undefined; + return typeof source === 'string' && /^(?:node:)?vm$/.test(source); + } + + /** 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) { + const unwrapped = unwrapSequence(node); + if (unwrapped?.type === 'Identifier') { + return unwrapped.name === 'process'; + } + if (unwrapped?.type !== 'MemberExpression') return false; + const objectName = rightmostObjectName(unwrapped.object); + return ( + (objectName === 'globalThis' || objectName === 'global') && + staticPropertyName(unwrapped.property, unwrapped.computed) === 'process' + ); + } + + /** The vm object test shared by the vm-exec and Script arms: tracked + * import names, the bare `vm`, or an inline lazy vm import (R12-9). */ + function isVmObject(objectNode) { + return ( + vmObjectNames.has(rightmostObjectName(objectNode) ?? '') || + isVmLazyImportObject(objectNode) + ); + } + + /** 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 }`, vitest loaders, 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(); + const vitestLoaderAliases = new Set(); + + function registerImportAliases(importNode) { + const value = importNode.source?.value; + if (typeof value !== 'string') return; + const bare = value.startsWith('node:') ? value.slice(5) : value; + for (const spec of importNode.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 === 'vitest' && + spec.type === 'ImportSpecifier' && + vitestLoaderNames.test(imported ?? '') + ) { + vitestLoaderAliases.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); + } + } + } + } + + // ESM imports are HOISTED: a renamed import used textually BEFORE its + // declaration is legal, so the alias sets must be populated from the + // whole module body before any guarded call is inspected — visitor + // source order would fail open on use-before-import (R12-5). + for (const statement of context.sourceCode.ast?.body ?? []) { + if (statement.type === 'ImportDeclaration') { + registerImportAliases(statement); + } + } + + 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; + } + 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)` (and nested `(0, (0, x))`) evaluates to `x` — unwrap + // recursively and uniformly before every callee-shape check (R12-2). + const callee = unwrapSequence(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). Chained + // indirection (x.call.call) resolves innerName to 'call' itself — + // fail closed rather than fall through (R12-3). + 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 === 'call' || + innerName === 'apply' || + innerName === 'bind' + ) { + reportUnknown(node); + return; + } + if ( + innerName === 'eval' || + innerName === 'Function' || + innerName === 'constructor' || + // vm exec callees forward CODE, not a specifier — and a + // renamed vm-exec import resolves through its alias set. + (innerName !== undefined && vmExecNames.test(innerName)) || + (innerName !== undefined && vmBareExecAliases.has(innerName)) + ) { + reportUnknown(node); + return; + } + if (innerName === 'getBuiltinModule') { + if (node.arguments.length > 0) { + reportUnknown(node, 'moduleBuiltin'); + } + return; + } + if ( + /^(?:require|fork|mock|doMock|importActual|importMock)$/.test( + innerName ?? '', + ) || + forkAliases.has(innerName ?? '') || + vitestLoaderAliases.has(innerName ?? '') + ) { + if (indirect === 'call') { + if (node.arguments.length > 1) { + checkSource(node.arguments[1]); + } + } else { + reportUnknown(node); + } + return; + } + } + // A named guarded global with an opaque computed key is one + // variable rename away from a guarded entrance — fail closed + // (R12-7; object-agnostic arms keep their documented residue). + // process-family objects keep the dedicated moduleBuiltin + // message: the opaque key stands in for getBuiltinModule. + if ( + node.arguments.length > 0 && + namedGuardedObjectWithOpaqueKey(callee) + ) { + reportUnknown( + node, + isProcessObject(callee.object) ? 'moduleBuiltin' : 'failClosed', + ); + 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. eval/Function fail closed on ANY + // argument; `.constructor` keeps the pass-through for statically + // NON-string literals but fails closed on variables and + // expression templates — a code body held in a variable is still + // code (R12-8). + 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]; + if (first.type === 'Literal' && typeof first.value !== 'string') { + return; // statically non-string: cannot be a code body + } + 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), the bare `vm` name, and + // inline lazy imports — `(await import('node:vm')).runInContext` + // is canonical ESM and needs no aliasing (R12-9). + if (node.arguments.length > 0) { + const vmProperty = + callee.type === 'MemberExpression' + ? staticPropertyName(callee.property, callee.computed) + : undefined; + if ( + (vmProperty !== undefined && + vmExecNames.test(vmProperty) && + isVmObject(callee.object)) || + (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(...)`, + // RENAMED included (`import { importActual as ia }`, R12-6: a + // renamed destructuring is still a destructured spelling). 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) || + vitestLoaderAliases.has(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). + // The target lists mirror the direct-call arms: Function and the + // vm exec/Script surface forward CODE; require/fork/Worker and + // the vitest loaders forward specifiers; alias sets included + // (R12-4). + if (memberCall(callee, ['Reflect'], /^(?:apply|construct)$/)) { + const target = unwrapSequence(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'); + return; + } + if (targetMember) { + if ( + targetName === 'eval' || + targetName === 'Function' || + targetName === 'fork' || + (targetName !== undefined && + vmExecNames.test(targetName) && + isVmObject(target.object)) || + (targetName === 'Script' && isVmObject(target.object)) || + (targetName !== undefined && vitestLoaderNames.test(targetName)) + ) { + reportUnknown(node); + } + } else if (target?.type === 'Identifier') { + if ( + /^(?:require|eval|fork|Function)$/.test(targetName ?? '') || + targetName === 'Worker' || + workerAliases.has(targetName ?? '') || + forkAliases.has(targetName ?? '') || + scriptAliases.has(targetName ?? '') || + vmBareExecAliases.has(targetName ?? '') || + vitestLoaderNames.test(targetName ?? '') || + vitestLoaderAliases.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) { + // Recursive sequence unwrap, same invariant as CallExpression + // (`new (0, (0, Worker))(…)` is transparent too, R12-2). + const callee = unwrapSequence(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, + // including inline lazy imports, R12-9). + if ( + node.arguments.length > 0 && + ((callee.type === 'MemberExpression' && + staticPropertyName(callee.property, callee.computed) === 'Script' && + isVmObject(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. Static analysis must match runtime + // object-literal semantics (R12-1): the LAST `eval` key wins + // (duplicates included), an options object WITHOUT `eval` + // defaults to false (arg0 stays a specifier), and a spread + // AFTER the last literal `eval` makes the final value + // unverifiable — fail closed on anything not statically false. + const opts = node.arguments[1]; + if (opts) { + let evalState = 'absent'; // 'absent' | 'false' | 'true' | 'unknown' + if (opts.type === 'ObjectExpression') { + for (const property of opts.properties) { + if (property.type !== 'Property') { + // SpreadElement (or anything else) can set or override + // `eval` at runtime — only a LATER literal wins. + evalState = 'unknown'; + continue; + } + if ( + staticPropertyName(property.key, property.computed) !== 'eval' + ) { + continue; + } + if ( + property.value.type === 'Literal' && + typeof property.value.value === 'boolean' + ) { + evalState = property.value.value ? 'true' : 'false'; + } else { + evalState = 'unknown'; + } + } + } else { + evalState = 'unknown'; // dynamic options object + } + if (evalState === 'unknown' || evalState === 'true') { + 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]; + if (!isNewUrlWithImportMetaUrl(arg)) 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. 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); + } + } + }, + }; + }, +}; diff --git a/scripts/tests/eslint-boundary-rules.test.js b/scripts/tests/eslint-boundary-rules.test.js index f337b390fd5..5a0ebaa2a2f 100644 --- a/scripts/tests/eslint-boundary-rules.test.js +++ b/scripts/tests/eslint-boundary-rules.test.js @@ -1 +1,1134 @@ -þÙ©ý¹hofú \ No newline at end of file +/** + * @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 () => { + // repoRoot is backslash-separated on Windows; interpolating it into a + // single-quoted JS literal raw would let NonEscapeCharacter cooking + // destroy the specifier (and fileURLToPath would throw Invalid URL), + // turning this pin deterministically red on the Windows merge gate + // (R12-10). Normalize to forward slashes, which both the rule and + // file URLs accept on every platform. + const serveEntry = path + .join(repoRoot, 'packages/cli/src/serve/index.ts') + .split(path.sep) + .join('/'); + 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'); + // Pre-clean leftovers from an interrupted run: an EEXIST here would + // otherwise be misread as "no unprivileged symlink support" and + // silently skip the pin forever (R12-15). + rmSync(intoServe, { force: true }); + rmSync(outOfServe, { force: true }); + 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. + // Clean up whatever the first call created before failing. + rmSync(intoServe, { force: true }); + rmSync(outOfServe, { force: true }); + } + 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); + } + }); + + // ── Round-12 review pins (batch 2) ─────────────────────────────────── + + // R12-1: the Worker eval-option analysis must match runtime + // object-literal semantics — last key wins, absent eval defaults to + // false (specifier path), and a spread after the last literal eval is + // unverifiable. + it('analyses Worker eval options with runtime literal semantics', async () => { + // No eval property: eval defaults to false — arg0 is a specifier, so + // a clean target passes (over-blocking regression pin). + await expectNoBoundaryHits( + ACP_FIXTURE, + "new Worker('../utils/worker.js', { name: 'bg' });", + ); + await expectNoBoundaryHits( + ACP_FIXTURE, + "new Worker('../utils/worker.js', {});", + ); + // A spread AFTER an eval:false literal can override eval at runtime. + await expectServeBoundaryError( + ACP_FIXTURE, + "const overrides = { eval: true };\nnew Worker('x', { eval: false, ...overrides });", + ); + // Duplicate keys: the runtime gives the LAST one. + await expectServeBoundaryError( + ACP_FIXTURE, + "new Worker('x', { eval: false, eval: true });", + ); + // A trailing literal false wins over an earlier spread. + await expectNoBoundaryHits( + ACP_FIXTURE, + "const overrides = {};\nnew Worker('../utils/worker.js', { ...overrides, eval: false });", + ); + }); + + // R12-2: sequence unwrapping is a uniform invariant — recursive on the + // callee AND applied to object expressions. + it('unwraps nested sequences and sequence-wrapped objects', async () => { + for (const code of [ + "(0, require).call(null, '../serve/index.js');", + "(0, (0, require))('../serve/index.js');", + "new (0, (0, Worker))('../serve/worker.js');", + "(0, process).getBuiltinModule('node:module');", + "Reflect.apply((0, process).getBuiltinModule, null, ['module']);", + ]) { + await expectServeBoundaryError(ACP_FIXTURE, code); + } + }); + + // R12-3: call/apply/bind indirection is complete — Function/constructor + // forward code, chained indirection fails closed, vm exec names and the + // rule's own alias sets resolve. + it('covers call/apply/bind indirection on every guarded family', async () => { + for (const code of [ + 'Function.call(null, "return import(\'../serve/index.js\')");', + 'Function.apply(null, ["return import(\'../serve/index.js\')"]);', + 'Function.bind(null, "return import(\'../serve/index.js\')")();', + 'eval.call.call(null, null, "import(\'../serve/index.js\')");', + "vi.mock.call.call(vi, null, '../serve/live/live-task-service.js');", + "process.getBuiltinModule.call.call(process, null, 'node:module');", + "import vm from 'node:vm';\nvm.runInContext.call(vm, 'x', {});", + "import { runInContext as ric } from 'node:vm';\nric.call(null, 'x', {});", + "import { fork as f } from 'node:child_process';\nf.call(null, '../serve/index.js');", + ]) { + await expectServeBoundaryError(ACP_FIXTURE, code); + } + }); + + // R12-4: Reflect target lists mirror the direct-call arms — Function, + // the vm exec/Script surface, and the vitest loaders. + it('covers Reflect.apply/construct on every guarded family', async () => { + for (const code of [ + 'Reflect.apply(Function, null, ["return import(\'../serve/index.js\')"]);', + 'Reflect.construct(Function, ["return import(\'../serve/index.js\')"]);', + 'Reflect.apply(globalThis.Function, null, ["x"]);', + "import vm from 'node:vm';\nReflect.apply(vm.runInContext, vm, ['x', {}]);", + "import { compileFunction } from 'node:vm';\nReflect.apply(compileFunction, null, ['x']);", + "import vm from 'node:vm';\nReflect.construct(vm.Script, ['x']);", + "import { vi } from 'vitest';\nReflect.apply(vi.mock, vi, ['../serve/live/live-task-service.js']);", + "import { importActual } from 'vitest';\nReflect.apply(importActual, null, ['../serve/live/live-task-service.js']);", + ]) { + await expectServeBoundaryError(ACP_FIXTURE, code); + } + }); + + // R12-5: ESM imports are hoisted — a renamed import used BEFORE its + // declaration must resolve through the alias sets (pre-pass, not + // visitor source order). All five families. + it('resolves renamed imports used before their declaration', async () => { + for (const code of [ + "export const w = new W('../serve/worker.js');\nimport { Worker as W } from 'node:worker_threads';", + "export const p = f('../serve/index.js');\nimport { fork as f } from 'node:child_process';", + "export const s = new S('x');\nimport { Script as S } from 'node:vm';", + "export const r = ric('x', {});\nimport { runInContext as ric } from 'node:vm';", + "export const c = v2.runInContext('x', {});\nimport v2 from 'node:vm';", + ]) { + await expectServeBoundaryError(ACP_FIXTURE, code); + } + }); + + // R12-6: renamed destructured vitest imports are still destructured + // spellings — the bare arm resolves them through vitestLoaderAliases. + it('catches renamed destructured vitest loader imports', async () => { + for (const [name, alias] of [ + ['importActual', 'ia'], + ['mock', 'm'], + ['doMock', 'dm'], + ['importMock', 'im'], + ]) { + await expectServeBoundaryError( + ACP_FIXTURE, + `import { ${name} as ${alias} } from 'vitest';\n${alias}('../serve/live/live-task-service.js');`, + ); + } + }); + + // R12-7: a named guarded global with an opaque computed key is one + // variable rename from a guarded entrance — fail closed. + it('fails closed on opaque computed keys of guarded globals', async () => { + const [gbm] = await lintCliFile( + ACP_FIXTURE, + "const gbm = 'getBuiltinModule';\nprocess[gbm]('node:module');", + ); + expect( + gbm.messages.some( + (message) => + message.ruleId === RULE_ID && message.messageId === 'moduleBuiltin', + ), + ).toBe(true); + const [evalCase] = await lintCliFile( + ACP_FIXTURE, + "const e = 'eval';\nglobalThis[e](\"import('../serve/index.js')\");", + ); + expect( + evalCase.messages.some((message) => message.ruleId === RULE_ID), + ).toBe(true); + }); + + // R12-8: .constructor fails closed on variable bodies and expression + // templates; statically non-string literals still pass through. + it('fails closed on dynamic constructor code bodies', async () => { + await expectServeBoundaryError( + ACP_FIXTURE, + 'const body = "return import(\'../serve/index.js\')";\n({}).constructor.constructor(body)()();', + ); + await expectServeBoundaryError( + ACP_FIXTURE, + "const x = 'x';\n(function(){}).constructor(`return '${'x'}' + x`)();", + ); + await expectNoBoundaryHits(ACP_FIXTURE, '({}).constructor(42);'); + }); + + // R12-9: inline lazy vm imports are vm objects without any aliasing — + // the canonical ESM spelling must not evade the vm arms. + it('catches inline lazy-import vm spellings', async () => { + for (const code of [ + "(await import('node:vm')).runInContext('x', {});", + "(await import('node:vm')).compileFunction('x');", + "new (await import('node:vm')).Script('x');", + "(await import('vm')).runInContext('x', {});", + ]) { + await expectServeBoundaryError(ACP_FIXTURE, code); + } + }); + + // R12-11: statically non-specifier arguments are non-imports — no + // unactionable fail-closed advice for env objects and the like. + it('does not fail-close statically non-specifier arguments', async () => { + for (const code of [ + 'recorder.mock({ silent: true });', + 'recorder.mock(42);', + "cluster.fork({ NODE_ENV: 'prod' });", + ]) { + await expectNoBoundaryHits(ACP_FIXTURE, code); + } + }); + + // R12-12: the URL arm owns new URL(spec, import.meta.url) on EVERY + // entrance — no fail-closed over-block on the clean form, exactly one + // serveBoundary on the serve form. + it('lets the URL arm own new URL(spec, import.meta.url) everywhere', async () => { + await expectNoBoundaryHits( + ACP_FIXTURE, + "export async function load() { return import(new URL('./plugin.js', import.meta.url)); }", + ); + const [serve] = await lintCliFile( + ACP_FIXTURE, + "export async function load() { return import(new URL('../serve/index.js', import.meta.url)); }", + ); + const hits = serve.messages.filter((message) => message.ruleId === RULE_ID); + expect(hits).toHaveLength(1); + expect(hits[0].messageId).toBe('serveBoundary'); + await expectNoBoundaryHits( + ACP_FIXTURE, + "const m = require(new URL('./plugin.js', import.meta.url));", + ); + }); + + // R12-13: the module builtin reports the dedicated moduleBuiltin + // message on every entrance, not the unactionable failClosed advice. + it('reports module-builtin imports with the dedicated message', async () => { + for (const code of [ + "export async function load() { return import('module'); }", + "const m = require('module');", + "import x = require('module');", + "export { createRequire } from 'node:module';", + ]) { + const [result] = await lintCliFile(ACP_FIXTURE, code); + expect( + result.messages.some( + (message) => + message.ruleId === RULE_ID && message.messageId === 'moduleBuiltin', + ), + ).toBe(true); + } + }); + + // R12-14 mutation survivors: the renamed-Script Identifier disjunct and + // the unprefixed builtin-import alias registration direction. + it('pins the renamed-Script arm and unprefixed alias registration', async () => { + await expectServeBoundaryError( + ACP_FIXTURE, + "import { Script as S } from 'node:vm';\nnew S('x');", + ); + await expectServeBoundaryError( + ACP_FIXTURE, + "import { fork as f } from 'child_process';\nf('../serve/index.js');", + ); + await expectServeBoundaryError( + ACP_FIXTURE, + "import { Worker as W } from 'worker_threads';\nnew W('../serve/worker.js');", + ); + }); +}); From ebe1cb5ae933fbf7e4bc32d93ea41d0712489cd3 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 17 Aug 2026 10:55:22 +0000 Subject: [PATCH 21/26] fix(lint): close the round-13 serve-boundary escape classes (#8084) - re-normalize backslashes AFTER percent-decoding so %5c/%5C cannot reintroduce a traversal the pre-decode normalization missed - on realpath ENOENT canonicalize the deepest existing ancestor and re-append the missing tail, so symlinked-ancestor checkouts fail closed instead of open - Worker eval-option scan: treat an unresolvable computed key like a spread (unknown), fail closed on a non-computed __proto__ prototype unless it is statically null, and resolve quoted string-literal keys - carry the opaque-key fail-closed check through composed callees: one hop below .call/.apply/.bind, as a Reflect target, and on the getBuiltinModule object side, with the process-family message Pins all four classes with executed fixtures plus negative controls; the guarded trees stay lint-clean. --- eslint-rules/no-serve-boundary-cross.js | 125 +++++++++++++++++--- scripts/tests/eslint-boundary-rules.test.js | 84 +++++++++++++ 2 files changed, 195 insertions(+), 14 deletions(-) diff --git a/eslint-rules/no-serve-boundary-cross.js b/eslint-rules/no-serve-boundary-cross.js index 8270a0d2190..3592c1d9816 100644 --- a/eslint-rules/no-serve-boundary-cross.js +++ b/eslint-rules/no-serve-boundary-cross.js @@ -64,7 +64,27 @@ function resolvePath(candidate) { try { return fs.realpathSync.native(resolved); } catch { - return resolved; + // ENOENT: an import target does not have to exist for the resolver + // (tsc/esbuild resolve extensionless and not-yet-written paths). + // Canonicalize the deepest EXISTING ancestor and re-append the + // missing tail — returning the textual path here lets candidates + // reached through a symlinked ancestor (macOS /tmp, symlink-mounted + // workspaces) miss the realpath'd serveDir and fail open (#8084 + // R13-2). Only a filesystem root that cannot be realpath'd keeps + // the raw path. + const tail = []; + let base = resolved; + for (;;) { + const parent = path.dirname(base); + if (parent === base) return resolved; + tail.unshift(path.basename(base)); + base = parent; + try { + return path.join(fs.realpathSync.native(base), ...tail); + } catch { + // keep walking up + } + } } } @@ -187,14 +207,24 @@ export default { // unconditionally; guarded trees have no legitimate absolute-path // imports. if (trimmed.startsWith('/')) { - const decoded = decodeSpecifier(stripUrlSuffixes(trimmed)); + // Decode first, THEN re-normalize: %5c/%5C reintroduce + // backslashes that the pre-decode normalization cannot see, and + // the decoded '..\\serve' form resolves like '../serve' + // (#8084 R13-1). + const decoded = decodeSpecifier(stripUrlSuffixes(trimmed))?.replace( + /\\/g, + '/', + ); if (decoded === undefined) return 'unknown'; return isInServeDir(resolvePath(decoded), serveDir) ? 'inside' : 'unknown'; } - const cleaned = decodeSpecifier(stripUrlSuffixes(trimmed)); + const cleaned = decodeSpecifier(stripUrlSuffixes(trimmed))?.replace( + /\\/g, + '/', + ); if (cleaned === undefined) return 'unknown'; // Relative specifiers resolve against the importing file. @@ -302,8 +332,13 @@ export default { * (`vi[`mock`]` is as resolvable as vi.mock). */ function staticPropertyName(propertyNode, computed) { if (!computed) { - return propertyNode.type === 'Identifier' - ? propertyNode.name + if (propertyNode.type === 'Identifier') return propertyNode.name; + // A QUOTED key in an object literal (`{ 'eval': true }`) is + // runtime-identical to the identifier key — resolving only + // Identifiers fails open on the quoted spelling (#8084 R13-3). + return propertyNode.type === 'Literal' && + typeof propertyNode.value === 'string' + ? propertyNode.value : undefined; } if ( @@ -349,6 +384,17 @@ export default { ); } + /** Message for an opaque-key fail-closed hit on a guarded global: + * the process family's opaque key stands in for getBuiltinModule, + * so it keeps the dedicated moduleBuiltin message; every other + * guarded global gets the generic failClosed advice (#8084 R13-4). */ + function opaqueGuardMessage(memberExpr) { + const processFamily = + isProcessObject(memberExpr.object) || + rightmostObjectName(memberExpr.object) === 'process'; + return processFamily ? 'moduleBuiltin' : 'failClosed'; + } + /** Inline lazy vm imports as callee objects: `(await import('node:vm'))` * or `import('vm')` — canonical ESM spellings that need no aliasing * (R12-9). */ @@ -559,6 +605,19 @@ export default { } return; } + // Opaque composition one hop down: `process[k].call(...)` — + // the callee BELOW `.call/.apply/.bind` carries a statically + // unresolvable key on a guarded global, so the name-based + // cascade above sees `undefined` and falls through. Fail + // closed at composition depth with the family message + // (#8084 R13-4). + if ( + node.arguments.length > 0 && + namedGuardedObjectWithOpaqueKey(callee.object) + ) { + reportUnknown(node, opaqueGuardMessage(callee.object)); + return; + } } // A named guarded global with an opaque computed key is one // variable rename away from a guarded entrance — fail closed @@ -690,6 +749,13 @@ export default { (callee.type === 'MemberExpression' && isProcessObject(callee.object) && builtinModuleProperty(callee)) || + // Opaque object side: `globalThis[p].getBuiltinModule(...)` — + // the callee's own property resolves, but the OBJECT is an + // opaque computed member of a guarded-global family, which + // isProcessObject cannot see (#8084 R13-4). + (callee.type === 'MemberExpression' && + builtinModuleProperty(callee) && + namedGuardedObjectWithOpaqueKey(callee.object)) || (callee.type === 'Identifier' && callee.name === 'getBuiltinModule') ) { reportUnknown(node, 'moduleBuiltin'); @@ -719,6 +785,14 @@ export default { reportUnknown(node, 'moduleBuiltin'); return; } + // Opaque composition as the Reflect target: + // `Reflect.apply(process[k], ...)` — targetName one hop down is + // undefined and every name-based check below misses it; fail + // closed at composition depth (#8084 R13-4). + if (targetMember && namedGuardedObjectWithOpaqueKey(target)) { + reportUnknown(node, opaqueGuardMessage(target)); + return; + } if (targetMember) { if ( targetName === 'eval' || @@ -815,12 +889,17 @@ export default { 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. Static analysis must match runtime - // object-literal semantics (R12-1): the LAST `eval` key wins - // (duplicates included), an options object WITHOUT `eval` - // defaults to false (arg0 stays a specifier), and a spread - // AFTER the last literal `eval` makes the final value - // unverifiable — fail closed on anything not statically false. + // not as a specifier. Contract (R12-1, modeled once in R13-3): + // fail closed on ANYTHING whose effect on the final `eval` + // value is statically undecided. Runtime object-literal + // semantics the scan must reproduce: the LAST `eval` key wins + // (duplicates included); an options object WITHOUT `eval` + // defaults to false (arg0 stays a specifier); a spread makes + // the final value unverifiable unless a LATER literal `eval` + // wins; a statically UNRESOLVABLE computed key may BE `eval`; + // and a non-computed `__proto__` key installs a PROTOTYPE, so + // `eval` can be inherited through the prototype chain even + // when no own `eval` exists. const opts = node.arguments[1]; if (opts) { let evalState = 'absent'; // 'absent' | 'false' | 'true' | 'unknown' @@ -832,11 +911,29 @@ export default { evalState = 'unknown'; continue; } - if ( - staticPropertyName(property.key, property.computed) !== 'eval' - ) { + const key = staticPropertyName(property.key, property.computed); + if (key === undefined) { + // A statically unresolvable computed key may be 'eval' + // at runtime — same posture as a spread: only a LATER + // literal `eval` settles the final value (R13-3). + evalState = 'unknown'; + continue; + } + if (key === '__proto__' && !property.computed) { + // Non-computed `__proto__` sets the prototype; Node + // reads `opts.eval` with prototype lookup, so an + // inherited `eval: true` executes arg0 as code. Only a + // static null severs the chain (R13-3). + if ( + property.value.type === 'Literal' && + property.value.value === null + ) { + continue; + } + evalState = 'unknown'; continue; } + if (key !== 'eval') continue; if ( property.value.type === 'Literal' && typeof property.value.value === 'boolean' diff --git a/scripts/tests/eslint-boundary-rules.test.js b/scripts/tests/eslint-boundary-rules.test.js index 5a0ebaa2a2f..a9ffdb7c6ae 100644 --- a/scripts/tests/eslint-boundary-rules.test.js +++ b/scripts/tests/eslint-boundary-rules.test.js @@ -1131,4 +1131,88 @@ describe('eslint cli serve boundary rules', () => { "import { Worker as W } from 'worker_threads';\nnew W('../serve/worker.js');", ); }); + + // ── Round-13 review pins ───────────────────────────────────────────── + + // R13-1: backslash normalization must run AFTER percent-decoding too — + // %5c/%5C reintroduce backslashes that decode into the already-pinned + // literal-backslash traversal. + it('rejects percent-encoded backslash traversal into serve', async () => { + for (const code of [ + "import '..%5cserve%5cindex.js';", + "import '..%5Cserve%5Cindex.js';", + "export async function load() { await import('..%5cserve%5cindex.js'); }", + ]) { + await expectServeBoundaryError(ACP_FIXTURE, code); + } + }); + + // R13-2: a nonexistent target reached through a symlinked ancestor must + // canonicalize via the deepest existing ancestor instead of failing open + // with the textual path. The link points runtime/ at src/, so + // `./r13-src-link/serve/...` resolves into the real serve tree even + // though the final file does not exist. + it('fails closed through symlinked ancestors for missing targets', async () => { + const link = path.join(repoRoot, 'packages/cli/src/runtime/r13-src-link'); + rmSync(link, { force: true }); + try { + symlinkSync('..', link); + await expectServeBoundaryError( + 'packages/cli/src/runtime/boundary-fixture.ts', + "import './r13-src-link/serve/r13-nonexistent.js';", + ); + // Negative control: the same mechanism resolving OUTSIDE serve + // stays allowed (over-blocking regression pin). + await expectNoBoundaryHits( + 'packages/cli/src/runtime/boundary-fixture.ts', + "import './r13-src-link/utils/r13-nonexistent.js';", + ); + } finally { + rmSync(link, { force: true }); + } + }); + + // R13-3: the Worker eval-option contract fails closed on every shape + // whose effect on the final eval value is statically undecided — an + // opaque computed key, a prototype-inherited eval, and a quoted key. + it('fails closed on undecided Worker eval option shapes', async () => { + for (const code of [ + "const k = 'eval';\nnew Worker('x', { [k]: true });", + "new Worker('x', { __proto__: { eval: true } });", + "new Worker('x', { 'eval': true });", + ]) { + await expectServeBoundaryError(ACP_FIXTURE, code); + } + // A LATER literal false still wins over an earlier opaque key, and a + // static null prototype severs the chain — both stay on the + // specifier path (over-blocking regression pins). + await expectNoBoundaryHits( + ACP_FIXTURE, + "const k = 'noise';\nnew Worker('../utils/worker.js', { [k]: true, eval: false });", + ); + await expectNoBoundaryHits( + ACP_FIXTURE, + "new Worker('../utils/worker.js', { __proto__: null });", + ); + }); + + // R13-4: the opaque-key fail-closed check applies at composition depth — + // one hop below .call, as a Reflect target, and on the getBuiltinModule + // object side. All three keep the moduleBuiltin message of the process + // family. + it('fails closed on opaque-key compositions of guarded globals', async () => { + for (const code of [ + "const k = 'getBuiltinModule';\nprocess[k].call(process, 'node:module');", + "const k = 'getBuiltinModule';\nReflect.apply(process[k], null, ['node:module']);", + "const p = 'pro' + 'cess';\nglobalThis[p].getBuiltinModule('node:module');", + ]) { + const [result] = await lintCliFile(ACP_FIXTURE, code); + expect( + result.messages.some( + (message) => + message.ruleId === RULE_ID && message.messageId === 'moduleBuiltin', + ), + ).toBe(true); + } + }); }); From 9fbd9066a7161f2ca00171abb45ff590a132dc7b Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Tue, 18 Aug 2026 01:44:01 +0000 Subject: [PATCH 22/26] fix(lint): close the round-13 binding-hop and callee-opacity escapes (#8084) --- eslint-rules/no-serve-boundary-cross.js | 428 +++++++++++++++++++- scripts/tests/eslint-boundary-rules.test.js | 97 +++++ 2 files changed, 514 insertions(+), 11 deletions(-) diff --git a/eslint-rules/no-serve-boundary-cross.js b/eslint-rules/no-serve-boundary-cross.js index 3592c1d9816..5fb46dfaa2b 100644 --- a/eslint-rules/no-serve-boundary-cross.js +++ b/eslint-rules/no-serve-boundary-cross.js @@ -23,7 +23,12 @@ * 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. + * none of those shapes occurs anywhere in the guarded trees today. The + * same posture covers CALLEE opacity: a call whose callee cannot be + * classified (an unreduced call result, undecided conditional, or + * non-static element access) is rejected too, and one binding hop from a + * guarded name is tracked, because a guarded function reached through an + * opaque or rebound callee is the same boundary crossing (R13-5, R13-7). * * Path comparison is case-insensitive: case-variant spellings * (`../../Serve/index.js`) load serve/ on case-insensitive filesystems, so @@ -318,11 +323,103 @@ export default { /** Unwrap sequence expressions recursively: `(0, x)` evaluates to * `x`, and double-wrapping `(0, (0, x))` is equally transparent - * (R12-2). */ + * (R12-2). TS assertion/instantiation wrappers (`x!`, `x as T`, + * `x satisfies T`, `x`) are runtime no-ops and equally + * transparent. */ function unwrapSequence(node) { let current = node; - while (current?.type === 'SequenceExpression') { - current = current.expressions[current.expressions.length - 1]; + for (;;) { + if (current?.type === 'SequenceExpression') { + current = current.expressions[current.expressions.length - 1]; + continue; + } + if ( + current?.type === 'TSNonNullExpression' || + current?.type === 'TSAsExpression' || + current?.type === 'TSSatisfiesExpression' || + current?.type === 'TSInstantiationExpression' + ) { + current = current.expression; + continue; + } + break; + } + return current; + } + + /** Callee resolution on top of unwrapSequence (#8084 R13-7): the + * statically-known callee shapes evaluate to their targets — element + * access into a literal array (`[fork][0]`), a conditional whose + * test is a static literal, and `Reflect.get(obj, 'key')` (the + * equivalent member access). Every other shape keeps its node so the + * classifiability check can fail it closed. */ + function unwrapCallee(node) { + let current = unwrapSequence(node); + for (;;) { + // A member chain whose OBJECT is itself a resolvable shape + // (`Reflect.get(process, 'getBuiltinModule').call(...)`) keeps + // its property but takes the resolved object. + if (current?.type === 'MemberExpression') { + const object = unwrapCallee(current.object); + if (object !== current.object) { + current = { + type: 'MemberExpression', + object, + property: current.property, + computed: current.computed, + }; + continue; + } + } + if ( + current?.type === 'MemberExpression' && + current.computed && + current.object.type === 'ArrayExpression' && + current.property.type === 'Literal' && + typeof current.property.value === 'number' && + Number.isInteger(current.property.value) && + current.property.value >= 0 && + current.property.value < current.object.elements.length && + current.object.elements[current.property.value] + ) { + current = unwrapSequence( + current.object.elements[current.property.value], + ); + continue; + } + if (current?.type === 'ConditionalExpression') { + const test = unwrapSequence(current.test); + let decided; + if (test?.type === 'Literal') { + decided = Boolean(test.value); + } else if (test?.type === 'TemplateLiteral') { + const value = staticTemplateValue(test); + if (value === undefined) break; + decided = Boolean(value); + } else { + break; + } + current = unwrapSequence( + decided ? current.consequent : current.alternate, + ); + continue; + } + if ( + current?.type === 'CallExpression' && + memberCall(current.callee, ['Reflect'], /^get$/) && + current.arguments.length >= 2 && + current.arguments[1].type === 'Literal' && + typeof current.arguments[1].value === 'string' + ) { + current = { + type: 'MemberExpression', + object: current.arguments[0], + property: current.arguments[1], + computed: true, + }; + continue; + } + break; } return current; } @@ -425,7 +522,9 @@ export default { function isProcessObject(node) { const unwrapped = unwrapSequence(node); if (unwrapped?.type === 'Identifier') { - return unwrapped.name === 'process'; + return ( + unwrapped.name === 'process' || processAliases.has(unwrapped.name) + ); } if (unwrapped?.type !== 'MemberExpression') return false; const objectName = rightmostObjectName(unwrapped.object); @@ -466,6 +565,13 @@ export default { /^(?:runInThisContext|runInNewContext|runInContext|compileFunction)$/; const vmBareExecAliases = new Set(); const vitestLoaderAliases = new Set(); + // Rebindings of the `process` global (isProcessObject consults these) + // and of getBuiltinModule extracted/destructured from it (#8084 R13-5). + const processAliases = new Set(); + const bareGetBuiltinModuleAliases = new Set(); + /** local name -> bare lib, for namespace-like bindings of tracked libs */ + const namespaceAliases = new Map(); + const trackedLibs = /^(?:worker_threads|child_process|vitest|vm)$/; function registerImportAliases(importNode) { const value = importNode.source?.value; @@ -476,6 +582,16 @@ export default { spec.type === 'ImportSpecifier' ? (spec.imported?.name ?? spec.imported?.value) : undefined; + // Namespace/default imports of a tracked lib are destructurable + // namespaces: `const { Worker: W } = wt` must see wt's lib + // (#8084 R13-5). vm additionally uses the name as a vm object. + if ( + (spec.type === 'ImportNamespaceSpecifier' || + spec.type === 'ImportDefaultSpecifier') && + trackedLibs.test(bare) + ) { + namespaceAliases.set(spec.local.name, bare); + } if (bare === 'child_process' && imported === 'fork') { forkAliases.add(spec.local.name); } else if (bare === 'worker_threads' && imported === 'Worker') { @@ -510,6 +626,191 @@ export default { } } + /** Bare lib name of a (possibly awaited, sequence-wrapped) dynamic + * import expression; undefined for any other shape. */ + function dynamicImportLib(node) { + let current = unwrapSequence(node); + if (current?.type === 'AwaitExpression') { + current = unwrapSequence(current.argument); + } + if (current?.type !== 'ImportExpression') return undefined; + const source = + current.source?.type === 'Literal' ? current.source.value : undefined; + if (typeof source !== 'string') return undefined; + return source.startsWith('node:') ? source.slice(5) : source; + } + + /** Lib of a namespace-like object expression: a tracked namespace + * alias, or an inline dynamic import of a tracked lib. */ + function namespaceLibOf(node) { + const current = unwrapSequence(node); + if (current?.type === 'Identifier') { + return namespaceAliases.get(current.name); + } + const lib = dynamicImportLib(current); + return lib !== undefined && trackedLibs.test(lib) ? lib : undefined; + } + + /** Register one named binding extracted from a tracked lib. */ + function registerNamedBinding(local, imported, lib, add) { + if (lib === 'child_process' && imported === 'fork') { + add(forkAliases, local); + } else if (lib === 'worker_threads' && imported === 'Worker') { + add(workerAliases, local); + } else if (lib === 'vitest' && vitestLoaderNames.test(imported ?? '')) { + add(vitestLoaderAliases, local); + } else if (lib === 'vm') { + if (imported === 'Script') add(scriptAliases, local); + else if (vmExecNames.test(imported ?? '')) { + add(vmBareExecAliases, local); + } + } + } + + // import('').then((ns) => ...) binds the namespace inside the + // callback — register the first parameter of any such callback, + // wherever it appears (#8084 R13-5). + (function walkForThenNamespaces(node) { + if (!node || typeof node !== 'object') return; + if (node.type === 'CallExpression') { + const callee = unwrapSequence(node.callee); + const callback = node.arguments?.[0]; + if ( + callee?.type === 'MemberExpression' && + staticPropertyName(callee.property, callee.computed) === 'then' && + (callback?.type === 'ArrowFunctionExpression' || + callback?.type === 'FunctionExpression') && + callback.params[0]?.type === 'Identifier' + ) { + const lib = dynamicImportLib(callee.object); + if (lib !== undefined && trackedLibs.test(lib)) { + if (lib === 'vm') vmObjectNames.add(callback.params[0].name); + namespaceAliases.set(callback.params[0].name, lib); + } + } + } + for (const key of Object.keys(node)) { + if (key === 'parent') continue; + const value = node[key]; + if (Array.isArray(value)) { + for (const child of value) walkForThenNamespaces(child); + } else if (value && typeof value.type === 'string') { + walkForThenNamespaces(value); + } + } + })(context.sourceCode.ast); + + // One binding hop defeats a name-based arm: a local holding a guarded + // function IS that function (#8084 R13-5). Propagate bindings over + // top-level declarators to a fixpoint (rebinding chains converge): + // rebinding of tracked names, member extraction from guarded globals + // and tracked namespaces, destructuring from guarded globals / + // namespaces / awaited dynamic imports, and whole-namespace bindings + // from dynamic imports. + for (;;) { + let changed = false; + const add = (set, name) => { + if (!set.has(name)) { + set.add(name); + changed = true; + } + }; + for (const statement of context.sourceCode.ast?.body ?? []) { + if (statement.type !== 'VariableDeclaration') continue; + for (const declarator of statement.declarations) { + const init = declarator.init + ? unwrapSequence(declarator.init) + : undefined; + if (!init) continue; + + if (declarator.id.type === 'Identifier') { + const local = declarator.id.name; + if (init.type === 'Identifier') { + const name = init.name; + if (name === 'process' || processAliases.has(name)) { + add(processAliases, local); + } + if (name === 'Worker' || workerAliases.has(name)) { + add(workerAliases, local); + } + if (name === 'fork' || forkAliases.has(name)) { + add(forkAliases, local); + } + if (name === 'Script' || scriptAliases.has(name)) { + add(scriptAliases, local); + } + if ( + name === 'getBuiltinModule' || + bareGetBuiltinModuleAliases.has(name) + ) { + add(bareGetBuiltinModuleAliases, local); + } + if (vmObjectNames.has(name)) add(vmObjectNames, local); + if (vmBareExecAliases.has(name)) add(vmBareExecAliases, local); + if (vitestLoaderAliases.has(name)) { + add(vitestLoaderAliases, local); + } + const nsLib = namespaceAliases.get(name); + if ( + nsLib !== undefined && + namespaceAliases.get(local) !== nsLib + ) { + namespaceAliases.set(local, nsLib); + changed = true; + } + } + if (init.type === 'MemberExpression') { + const property = staticPropertyName(init.property, init.computed); + if ( + property === 'getBuiltinModule' && + isProcessObject(init.object) + ) { + add(bareGetBuiltinModuleAliases, local); + } + if (property !== undefined) { + const lib = namespaceLibOf(init.object); + if (lib !== undefined) { + registerNamedBinding(local, property, lib, add); + } + } + } + const lib = dynamicImportLib(init); + if (lib !== undefined && trackedLibs.test(lib)) { + if (lib === 'vm') add(vmObjectNames, local); + if (namespaceAliases.get(local) !== lib) { + namespaceAliases.set(local, lib); + changed = true; + } + } + } + + if (declarator.id.type === 'ObjectPattern') { + const fromProcess = isProcessObject(init); + const lib = fromProcess ? undefined : namespaceLibOf(init); + if (!fromProcess && lib === undefined) continue; + for (const property of declarator.id.properties) { + if ( + property.type !== 'Property' || + property.value.type !== 'Identifier' + ) { + continue; + } + const key = staticPropertyName(property.key, property.computed); + if (key === undefined) continue; + if (fromProcess) { + if (key === 'getBuiltinModule') { + add(bareGetBuiltinModuleAliases, property.value.name); + } + } else { + registerNamedBinding(property.value.name, key, lib, add); + } + } + } + } + } + if (!changed) break; + } + return { ImportDeclaration(node) { const value = node.source?.value; @@ -545,8 +846,61 @@ export default { }, CallExpression(node) { // `(0, x)` (and nested `(0, (0, x))`) evaluates to `x` — unwrap - // recursively and uniformly before every callee-shape check (R12-2). - const callee = unwrapSequence(node.callee); + // recursively and uniformly before every callee-shape check + // (R12-2); statically-known callee shapes ([x][0], static + // conditionals, Reflect.get with a literal key) additionally + // resolve to their targets (R13-7). + const callee = unwrapCallee(node.callee); + + // Callee opacity (#8084 R13-7): a guarded function invoked + // through a callee shape no arm can classify evades every + // name-based check. Fail closed narrowly: a conditional callee + // whose branch is statically undecided, a non-static element + // access into an array callee (the static-index form is resolved + // by unwrapCallee), and Reflect.get with an opaque key on a + // guarded global. Call-result callees of any other provenance + // (`it.each([...])(...)`, factory patterns) keep the documented + // pass-through — their binding is no more resolvable than any + // other local the rule does not track. + if (callee.type === 'ConditionalExpression') { + reportUnknown(node); + return; + } + if ( + callee.type === 'MemberExpression' && + callee.computed && + callee.object.type === 'ArrayExpression' + ) { + reportUnknown(node); + return; + } + if ( + callee.type === 'CallExpression' && + memberCall(callee.callee, ['Reflect'], /^get$/) && + callee.arguments.length > 0 + ) { + const staticKey = + callee.arguments.length >= 2 && + staticPropertyName(callee.arguments[1], true) !== undefined; + if (!staticKey) { + const target = unwrapSequence(callee.arguments[0]); + const targetObjectName = rightmostObjectName(target); + if ( + isProcessObject(target) || + targetObjectName === 'process' || + targetObjectName === 'globalThis' || + targetObjectName === 'global' + ) { + reportUnknown( + node, + isProcessObject(target) || targetObjectName === 'process' + ? 'moduleBuiltin' + : 'failClosed', + ); + return; + } + } + } // Function.prototype.call/apply/bind indirection on guarded // callees: `.call` unwraps like a direct call with the specifier @@ -575,6 +929,13 @@ export default { innerName === 'eval' || innerName === 'Function' || innerName === 'constructor' || + // Worker/Script forward a module path / code — same + // fail-closed class as eval/Function; the alias sets keep + // this list mirrored with the direct-call arms (R13-6). + innerName === 'Worker' || + innerName === 'Script' || + (innerName !== undefined && workerAliases.has(innerName)) || + (innerName !== undefined && scriptAliases.has(innerName)) || // vm exec callees forward CODE, not a specifier — and a // renamed vm-exec import resolves through its alias set. (innerName !== undefined && vmExecNames.test(innerName)) || @@ -583,7 +944,11 @@ export default { reportUnknown(node); return; } - if (innerName === 'getBuiltinModule') { + if ( + innerName === 'getBuiltinModule' || + (innerName !== undefined && + bareGetBuiltinModuleAliases.has(innerName)) + ) { if (node.arguments.length > 0) { reportUnknown(node, 'moduleBuiltin'); } @@ -673,6 +1038,20 @@ export default { return; } + // Worker invoked as a plain function — direct or reached through + // a resolved opaque shape ([Worker][0]('...'), R13-7). The + // guarded trees have no legitimate Worker-named factories, and + // the member match mirrors the object-agnostic new-Worker arm + // (#8084 R13-6). + if ( + node.arguments.length > 0 && + (calleeName === 'Worker' || + (callee.type === 'Identifier' && workerAliases.has(callee.name))) + ) { + reportUnknown(node); + return; + } + // node:vm string-execution surface — runInThisContext / // runInNewContext / runInContext / compileFunction compile or run // arbitrary string code. Scoped to vm imports (default/namespace @@ -756,7 +1135,11 @@ export default { (callee.type === 'MemberExpression' && builtinModuleProperty(callee) && namedGuardedObjectWithOpaqueKey(callee.object)) || - (callee.type === 'Identifier' && callee.name === 'getBuiltinModule') + // Bare identifier: destructured/extracted spellings resolve + // through the alias set (R13-5 binding propagation). + (callee.type === 'Identifier' && + (callee.name === 'getBuiltinModule' || + bareGetBuiltinModuleAliases.has(callee.name))) ) { reportUnknown(node, 'moduleBuiltin'); return; @@ -798,6 +1181,11 @@ export default { targetName === 'eval' || targetName === 'Function' || targetName === 'fork' || + // Worker mirrors the object-agnostic new-Worker arm; the + // alias sets keep this list mirrored with the direct-call + // arms (R13-6). + targetName === 'Worker' || + (targetName !== undefined && workerAliases.has(targetName)) || (targetName !== undefined && vmExecNames.test(targetName) && isVmObject(target.object)) || @@ -847,8 +1235,26 @@ export default { }, NewExpression(node) { // Recursive sequence unwrap, same invariant as CallExpression - // (`new (0, (0, Worker))(…)` is transparent too, R12-2). - const callee = unwrapSequence(node.callee); + // (`new (0, (0, Worker))(…)` is transparent too, R12-2); the + // statically-known callee shapes resolve as well (R13-7). + const callee = unwrapCallee(node.callee); + + // Callee opacity, constructor spelling (#8084 R13-7): same + // narrow fail-closed shapes as the call arm — an undecided + // conditional callee and a non-static element access into an + // array callee. + if (callee.type === 'ConditionalExpression') { + reportUnknown(node); + return; + } + if ( + callee.type === 'MemberExpression' && + callee.computed && + callee.object.type === 'ArrayExpression' + ) { + reportUnknown(node); + return; + } // new Function(body) / new globalThis.Function(body) compiles // arbitrary string code that can import() anything — fail closed diff --git a/scripts/tests/eslint-boundary-rules.test.js b/scripts/tests/eslint-boundary-rules.test.js index a9ffdb7c6ae..7659d2f81e8 100644 --- a/scripts/tests/eslint-boundary-rules.test.js +++ b/scripts/tests/eslint-boundary-rules.test.js @@ -1215,4 +1215,101 @@ describe('eslint cli serve boundary rules', () => { ).toBe(true); } }); + + // R13-5: one binding hop must not defeat the name-based arms — the + // top-level binding-propagation pre-scan covers rebinding chains, + // member extraction, destructuring from guarded globals / tracked + // namespaces / awaited dynamic imports, and .then namespace params. + it('tracks bindings propagated from guarded names and imports', async () => { + for (const code of [ + "const { Worker: W } = await import('node:worker_threads');\nnew W('../serve/worker.js');", + "const { fork: f } = await import('node:child_process');\nf('../serve/index.js');", + "import * as wt from 'node:worker_threads';\nconst { Worker: W } = wt;\nnew W('../serve/worker.js');", + "const W = Worker;\nconst W2 = W;\nnew W2('../serve/worker.js');", + "const m = await import('node:vm');\nm.runInThisContext('x');", + "import('node:vm').then((vmNs) => { vmNs.runInThisContext('x'); });", + "const P = process;\nP.getBuiltinModule('node:module');", + "const { mock: m } = await import('vitest');\nm('../serve/live/live-task-service.js');", + ]) { + await expectServeBoundaryError(ACP_FIXTURE, code); + } + // Extracted/destructured getBuiltinModule keeps the dedicated + // moduleBuiltin message on every entrance. + for (const code of [ + "const { getBuiltinModule: g } = process;\ng('node:module');", + 'const g = process.getBuiltinModule;\ng.call(null, "node:module");', + ]) { + const [result] = await lintCliFile(ACP_FIXTURE, code); + expect( + result.messages.some( + (message) => + message.ruleId === RULE_ID && message.messageId === 'moduleBuiltin', + ), + ).toBe(true); + } + }); + + // R13-6: the indirection and Reflect arms guard Worker/Script too — + // the target lists mirror the direct-call arms. + it('covers Worker/Script through indirection and Reflect targets', async () => { + for (const code of [ + "import * as wt from 'node:worker_threads';\nReflect.construct(wt.Worker, ['../serve/worker.js']);", + "import * as wt from 'node:worker_threads';\nReflect.apply(wt.Worker, null, ['../serve/worker.js']);", + "const W = Worker.bind(null, '../serve/worker.js');\nnew W();", + "import { Script as S } from 'node:vm';\nS.call(null, 'x');", + ]) { + await expectServeBoundaryError(ACP_FIXTURE, code); + } + }); + + // R13-7: statically-known callee shapes resolve to their targets; an + // unclassifiable callee fails closed like an unresolvable source. + it('resolves or fails closed on opaque callee shapes', async () => { + await expectServeBoundaryError( + ACP_FIXTURE, + "import { fork } from 'node:child_process';\n[fork][0]('../serve/index.js');", + ); + await expectServeBoundaryError( + ACP_FIXTURE, + "new [Worker][0]('../serve/worker.js');", + ); + for (const code of [ + "Reflect.get(process, 'getBuiltinModule')('node:module');", + "(1 ? process.getBuiltinModule : 0)('node:module');", + "const { getBuiltinModule: g } = process;\nReflect.get(process, 'getBuiltinModule').call(null, 'node:module');", + // Opaque key on a guarded global at callee depth keeps the family + // message (R13-4 posture, R13-7 entrance). + "const k = 'getBuiltinModule';\nReflect.get(process, k)('node:module');", + ]) { + const [result] = await lintCliFile(ACP_FIXTURE, code); + expect( + result.messages.some( + (message) => + message.ruleId === RULE_ID && message.messageId === 'moduleBuiltin', + ), + ).toBe(true); + } + for (const code of [ + "[Worker][0]('../serve/worker.js');", + "const i = 1;\n[Worker][i]('../serve/worker.js');", + "const c = Math.random();\n(c ? process.getBuiltinModule : 0)('node:module');", + ]) { + await expectServeBoundaryError(ACP_FIXTURE, code); + } + // Inline-defined callees cannot alias a guarded binding, and call + // results of any other provenance (it.each spellings, factories) + // keep their documented pass-through — no over-blocking (regression + // pins). + await expectNoBoundaryHits(ACP_FIXTURE, "((x) => x)('y');"); + await expectNoBoundaryHits(ACP_FIXTURE, 'new (class {})();'); + await expectNoBoundaryHits( + ACP_FIXTURE, + "const w = makeWorker('./plugin.js');\nw();", + ); + await expectNoBoundaryHits( + ACP_FIXTURE, + "each([1, 2])('case %s', (n) => n);", + ); + await expectNoBoundaryHits(ACP_FIXTURE, 'finishUpdate!();'); + }); }); From 58d43984dc0966d2b6d4b272822905846dede638 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Wed, 19 Aug 2026 22:39:45 +0800 Subject: [PATCH 23/26] refactor(cli): simplify ACP serve boundary guard --- eslint-rules/no-serve-boundary-cross.js | 1396 ------------------- eslint.config.js | 95 +- scripts/tests/eslint-boundary-rules.test.js | 1315 ----------------- 3 files changed, 39 insertions(+), 2767 deletions(-) delete mode 100644 eslint-rules/no-serve-boundary-cross.js delete mode 100644 scripts/tests/eslint-boundary-rules.test.js diff --git a/eslint-rules/no-serve-boundary-cross.js b/eslint-rules/no-serve-boundary-cross.js deleted file mode 100644 index 5fb46dfaa2b..00000000000 --- a/eslint-rules/no-serve-boundary-cross.js +++ /dev/null @@ -1,1396 +0,0 @@ -/** - * @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. The - * same posture covers CALLEE opacity: a call whose callee cannot be - * classified (an unreduced call result, undecided conditional, or - * non-static element access) is rejected too, and one binding hop from a - * guarded name is tracked, because a guarded function reached through an - * opaque or rebound callee is the same boundary crossing (R13-5, R13-7). - * - * 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 { - // ENOENT: an import target does not have to exist for the resolver - // (tsc/esbuild resolve extensionless and not-yet-written paths). - // Canonicalize the deepest EXISTING ancestor and re-append the - // missing tail — returning the textual path here lets candidates - // reached through a symlinked ancestor (macOS /tmp, symlink-mounted - // workspaces) miss the realpath'd serveDir and fail open (#8084 - // R13-2). Only a filesystem root that cannot be realpath'd keeps - // the raw path. - const tail = []; - let base = resolved; - for (;;) { - const parent = path.dirname(base); - if (parent === base) return resolved; - tail.unshift(path.basename(base)); - base = parent; - try { - return path.join(fs.realpathSync.native(base), ...tail); - } catch { - // keep walking up - } - } - } -} - -/** 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(); - - // Distinct verdict: the error is right but the generic failClosed - // advice ('use a plain string-literal relative specifier') is - // unactionable for a builtin — route to the dedicated message on - // every entrance, matching the ImportDeclaration arm (R12-13). - if (lower === 'module' || lower === 'node:module') { - return 'module-builtin'; - } - - // 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('/')) { - // Decode first, THEN re-normalize: %5c/%5C reintroduce - // backslashes that the pre-decode normalization cannot see, and - // the decoded '..\\serve' form resolves like '../serve' - // (#8084 R13-1). - const decoded = decodeSpecifier(stripUrlSuffixes(trimmed))?.replace( - /\\/g, - '/', - ); - if (decoded === undefined) return 'unknown'; - return isInServeDir(resolvePath(decoded), serveDir) - ? 'inside' - : 'unknown'; - } - - const cleaned = decodeSpecifier(stripUrlSuffixes(trimmed))?.replace( - /\\/g, - '/', - ); - 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 }); - } - - /** new URL(spec, import.meta.url) — the URL arm resolves this - * construct itself (reporting a serve target exactly once); every - * other entrance must let it through instead of fail-closing the - * canonical fully-static dynamic-load pattern or double-reporting - * the serve form (R12-12). */ - function isNewUrlWithImportMetaUrl(sourceNode) { - const arg = unwrapSequence(sourceNode); - return ( - 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' - ); - } - - /** Check a Literal/TemplateLiteral/computed source node. */ - function checkSource(sourceNode) { - if (!sourceNode) return; - // Statically non-specifier arguments (env objects, numbers, - // functions) cannot load a module — treat them as non-imports - // instead of failing closed (R12-11: recorder.mock({ silent: true }) - // and cluster.fork(env) must not error with unactionable advice). - if ( - sourceNode.type === 'ObjectExpression' || - sourceNode.type === 'ArrayExpression' || - sourceNode.type === 'FunctionExpression' || - sourceNode.type === 'ArrowFunctionExpression' || - (sourceNode.type === 'Literal' && typeof sourceNode.value !== 'string') - ) { - return; - } - // The URL arm owns the new-URL-with-import.meta.url construct. - if (isNewUrlWithImportMetaUrl(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 === 'module-builtin') { - reportUnknown(sourceNode, 'moduleBuiltin'); - } else if (verdict === 'unknown') reportUnknown(sourceNode); - } - - /** Unwrap sequence expressions recursively: `(0, x)` evaluates to - * `x`, and double-wrapping `(0, (0, x))` is equally transparent - * (R12-2). TS assertion/instantiation wrappers (`x!`, `x as T`, - * `x satisfies T`, `x`) are runtime no-ops and equally - * transparent. */ - function unwrapSequence(node) { - let current = node; - for (;;) { - if (current?.type === 'SequenceExpression') { - current = current.expressions[current.expressions.length - 1]; - continue; - } - if ( - current?.type === 'TSNonNullExpression' || - current?.type === 'TSAsExpression' || - current?.type === 'TSSatisfiesExpression' || - current?.type === 'TSInstantiationExpression' - ) { - current = current.expression; - continue; - } - break; - } - return current; - } - - /** Callee resolution on top of unwrapSequence (#8084 R13-7): the - * statically-known callee shapes evaluate to their targets — element - * access into a literal array (`[fork][0]`), a conditional whose - * test is a static literal, and `Reflect.get(obj, 'key')` (the - * equivalent member access). Every other shape keeps its node so the - * classifiability check can fail it closed. */ - function unwrapCallee(node) { - let current = unwrapSequence(node); - for (;;) { - // A member chain whose OBJECT is itself a resolvable shape - // (`Reflect.get(process, 'getBuiltinModule').call(...)`) keeps - // its property but takes the resolved object. - if (current?.type === 'MemberExpression') { - const object = unwrapCallee(current.object); - if (object !== current.object) { - current = { - type: 'MemberExpression', - object, - property: current.property, - computed: current.computed, - }; - continue; - } - } - if ( - current?.type === 'MemberExpression' && - current.computed && - current.object.type === 'ArrayExpression' && - current.property.type === 'Literal' && - typeof current.property.value === 'number' && - Number.isInteger(current.property.value) && - current.property.value >= 0 && - current.property.value < current.object.elements.length && - current.object.elements[current.property.value] - ) { - current = unwrapSequence( - current.object.elements[current.property.value], - ); - continue; - } - if (current?.type === 'ConditionalExpression') { - const test = unwrapSequence(current.test); - let decided; - if (test?.type === 'Literal') { - decided = Boolean(test.value); - } else if (test?.type === 'TemplateLiteral') { - const value = staticTemplateValue(test); - if (value === undefined) break; - decided = Boolean(value); - } else { - break; - } - current = unwrapSequence( - decided ? current.consequent : current.alternate, - ); - continue; - } - if ( - current?.type === 'CallExpression' && - memberCall(current.callee, ['Reflect'], /^get$/) && - current.arguments.length >= 2 && - current.arguments[1].type === 'Literal' && - typeof current.arguments[1].value === 'string' - ) { - current = { - type: 'MemberExpression', - object: current.arguments[0], - property: current.arguments[1], - computed: true, - }; - continue; - } - break; - } - return current; - } - - /** 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) { - if (propertyNode.type === 'Identifier') return propertyNode.name; - // A QUOTED key in an object literal (`{ 'eval': true }`) is - // runtime-identical to the identifier key — resolving only - // Identifiers fails open on the quoted spelling (#8084 R13-3). - return propertyNode.type === 'Literal' && - typeof propertyNode.value === 'string' - ? propertyNode.value - : 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; sequence - * wrappers are transparent (`(0, process)` → 'process', R12-2). */ - function rightmostObjectName(objectNode) { - const node = unwrapSequence(objectNode); - if (node?.type === 'Identifier') return node.name; - if (node?.type === 'MemberExpression') { - return staticPropertyName(node.property, node.computed); - } - return undefined; - } - - /** A named guarded global object (process/globalThis/global) whose - * computed property key is statically unresolvable — one variable - * rename re-opens the guarded entrance, so fail closed (R12-7). - * Object-agnostic arms keep their documented residue. */ - function namedGuardedObjectWithOpaqueKey(memberExpr) { - return ( - memberExpr.type === 'MemberExpression' && - memberExpr.computed && - staticPropertyName(memberExpr.property, true) === undefined && - (() => { - const name = rightmostObjectName(memberExpr.object); - return ( - name === 'process' || name === 'globalThis' || name === 'global' - ); - })() - ); - } - - /** Message for an opaque-key fail-closed hit on a guarded global: - * the process family's opaque key stands in for getBuiltinModule, - * so it keeps the dedicated moduleBuiltin message; every other - * guarded global gets the generic failClosed advice (#8084 R13-4). */ - function opaqueGuardMessage(memberExpr) { - const processFamily = - isProcessObject(memberExpr.object) || - rightmostObjectName(memberExpr.object) === 'process'; - return processFamily ? 'moduleBuiltin' : 'failClosed'; - } - - /** Inline lazy vm imports as callee objects: `(await import('node:vm'))` - * or `import('vm')` — canonical ESM spellings that need no aliasing - * (R12-9). */ - function isVmLazyImportObject(objectNode) { - let node = unwrapSequence(objectNode); - if (node?.type === 'AwaitExpression') { - node = unwrapSequence(node.argument); - } - if (node?.type !== 'ImportExpression') return false; - const source = - node.source?.type === 'Literal' ? node.source.value : undefined; - return typeof source === 'string' && /^(?:node:)?vm$/.test(source); - } - - /** 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) { - const unwrapped = unwrapSequence(node); - if (unwrapped?.type === 'Identifier') { - return ( - unwrapped.name === 'process' || processAliases.has(unwrapped.name) - ); - } - if (unwrapped?.type !== 'MemberExpression') return false; - const objectName = rightmostObjectName(unwrapped.object); - return ( - (objectName === 'globalThis' || objectName === 'global') && - staticPropertyName(unwrapped.property, unwrapped.computed) === 'process' - ); - } - - /** The vm object test shared by the vm-exec and Script arms: tracked - * import names, the bare `vm`, or an inline lazy vm import (R12-9). */ - function isVmObject(objectNode) { - return ( - vmObjectNames.has(rightmostObjectName(objectNode) ?? '') || - isVmLazyImportObject(objectNode) - ); - } - - /** 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 }`, vitest loaders, 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(); - const vitestLoaderAliases = new Set(); - // Rebindings of the `process` global (isProcessObject consults these) - // and of getBuiltinModule extracted/destructured from it (#8084 R13-5). - const processAliases = new Set(); - const bareGetBuiltinModuleAliases = new Set(); - /** local name -> bare lib, for namespace-like bindings of tracked libs */ - const namespaceAliases = new Map(); - const trackedLibs = /^(?:worker_threads|child_process|vitest|vm)$/; - - function registerImportAliases(importNode) { - const value = importNode.source?.value; - if (typeof value !== 'string') return; - const bare = value.startsWith('node:') ? value.slice(5) : value; - for (const spec of importNode.specifiers) { - const imported = - spec.type === 'ImportSpecifier' - ? (spec.imported?.name ?? spec.imported?.value) - : undefined; - // Namespace/default imports of a tracked lib are destructurable - // namespaces: `const { Worker: W } = wt` must see wt's lib - // (#8084 R13-5). vm additionally uses the name as a vm object. - if ( - (spec.type === 'ImportNamespaceSpecifier' || - spec.type === 'ImportDefaultSpecifier') && - trackedLibs.test(bare) - ) { - namespaceAliases.set(spec.local.name, bare); - } - 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 === 'vitest' && - spec.type === 'ImportSpecifier' && - vitestLoaderNames.test(imported ?? '') - ) { - vitestLoaderAliases.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); - } - } - } - } - - // ESM imports are HOISTED: a renamed import used textually BEFORE its - // declaration is legal, so the alias sets must be populated from the - // whole module body before any guarded call is inspected — visitor - // source order would fail open on use-before-import (R12-5). - for (const statement of context.sourceCode.ast?.body ?? []) { - if (statement.type === 'ImportDeclaration') { - registerImportAliases(statement); - } - } - - /** Bare lib name of a (possibly awaited, sequence-wrapped) dynamic - * import expression; undefined for any other shape. */ - function dynamicImportLib(node) { - let current = unwrapSequence(node); - if (current?.type === 'AwaitExpression') { - current = unwrapSequence(current.argument); - } - if (current?.type !== 'ImportExpression') return undefined; - const source = - current.source?.type === 'Literal' ? current.source.value : undefined; - if (typeof source !== 'string') return undefined; - return source.startsWith('node:') ? source.slice(5) : source; - } - - /** Lib of a namespace-like object expression: a tracked namespace - * alias, or an inline dynamic import of a tracked lib. */ - function namespaceLibOf(node) { - const current = unwrapSequence(node); - if (current?.type === 'Identifier') { - return namespaceAliases.get(current.name); - } - const lib = dynamicImportLib(current); - return lib !== undefined && trackedLibs.test(lib) ? lib : undefined; - } - - /** Register one named binding extracted from a tracked lib. */ - function registerNamedBinding(local, imported, lib, add) { - if (lib === 'child_process' && imported === 'fork') { - add(forkAliases, local); - } else if (lib === 'worker_threads' && imported === 'Worker') { - add(workerAliases, local); - } else if (lib === 'vitest' && vitestLoaderNames.test(imported ?? '')) { - add(vitestLoaderAliases, local); - } else if (lib === 'vm') { - if (imported === 'Script') add(scriptAliases, local); - else if (vmExecNames.test(imported ?? '')) { - add(vmBareExecAliases, local); - } - } - } - - // import('').then((ns) => ...) binds the namespace inside the - // callback — register the first parameter of any such callback, - // wherever it appears (#8084 R13-5). - (function walkForThenNamespaces(node) { - if (!node || typeof node !== 'object') return; - if (node.type === 'CallExpression') { - const callee = unwrapSequence(node.callee); - const callback = node.arguments?.[0]; - if ( - callee?.type === 'MemberExpression' && - staticPropertyName(callee.property, callee.computed) === 'then' && - (callback?.type === 'ArrowFunctionExpression' || - callback?.type === 'FunctionExpression') && - callback.params[0]?.type === 'Identifier' - ) { - const lib = dynamicImportLib(callee.object); - if (lib !== undefined && trackedLibs.test(lib)) { - if (lib === 'vm') vmObjectNames.add(callback.params[0].name); - namespaceAliases.set(callback.params[0].name, lib); - } - } - } - for (const key of Object.keys(node)) { - if (key === 'parent') continue; - const value = node[key]; - if (Array.isArray(value)) { - for (const child of value) walkForThenNamespaces(child); - } else if (value && typeof value.type === 'string') { - walkForThenNamespaces(value); - } - } - })(context.sourceCode.ast); - - // One binding hop defeats a name-based arm: a local holding a guarded - // function IS that function (#8084 R13-5). Propagate bindings over - // top-level declarators to a fixpoint (rebinding chains converge): - // rebinding of tracked names, member extraction from guarded globals - // and tracked namespaces, destructuring from guarded globals / - // namespaces / awaited dynamic imports, and whole-namespace bindings - // from dynamic imports. - for (;;) { - let changed = false; - const add = (set, name) => { - if (!set.has(name)) { - set.add(name); - changed = true; - } - }; - for (const statement of context.sourceCode.ast?.body ?? []) { - if (statement.type !== 'VariableDeclaration') continue; - for (const declarator of statement.declarations) { - const init = declarator.init - ? unwrapSequence(declarator.init) - : undefined; - if (!init) continue; - - if (declarator.id.type === 'Identifier') { - const local = declarator.id.name; - if (init.type === 'Identifier') { - const name = init.name; - if (name === 'process' || processAliases.has(name)) { - add(processAliases, local); - } - if (name === 'Worker' || workerAliases.has(name)) { - add(workerAliases, local); - } - if (name === 'fork' || forkAliases.has(name)) { - add(forkAliases, local); - } - if (name === 'Script' || scriptAliases.has(name)) { - add(scriptAliases, local); - } - if ( - name === 'getBuiltinModule' || - bareGetBuiltinModuleAliases.has(name) - ) { - add(bareGetBuiltinModuleAliases, local); - } - if (vmObjectNames.has(name)) add(vmObjectNames, local); - if (vmBareExecAliases.has(name)) add(vmBareExecAliases, local); - if (vitestLoaderAliases.has(name)) { - add(vitestLoaderAliases, local); - } - const nsLib = namespaceAliases.get(name); - if ( - nsLib !== undefined && - namespaceAliases.get(local) !== nsLib - ) { - namespaceAliases.set(local, nsLib); - changed = true; - } - } - if (init.type === 'MemberExpression') { - const property = staticPropertyName(init.property, init.computed); - if ( - property === 'getBuiltinModule' && - isProcessObject(init.object) - ) { - add(bareGetBuiltinModuleAliases, local); - } - if (property !== undefined) { - const lib = namespaceLibOf(init.object); - if (lib !== undefined) { - registerNamedBinding(local, property, lib, add); - } - } - } - const lib = dynamicImportLib(init); - if (lib !== undefined && trackedLibs.test(lib)) { - if (lib === 'vm') add(vmObjectNames, local); - if (namespaceAliases.get(local) !== lib) { - namespaceAliases.set(local, lib); - changed = true; - } - } - } - - if (declarator.id.type === 'ObjectPattern') { - const fromProcess = isProcessObject(init); - const lib = fromProcess ? undefined : namespaceLibOf(init); - if (!fromProcess && lib === undefined) continue; - for (const property of declarator.id.properties) { - if ( - property.type !== 'Property' || - property.value.type !== 'Identifier' - ) { - continue; - } - const key = staticPropertyName(property.key, property.computed); - if (key === undefined) continue; - if (fromProcess) { - if (key === 'getBuiltinModule') { - add(bareGetBuiltinModuleAliases, property.value.name); - } - } else { - registerNamedBinding(property.value.name, key, lib, add); - } - } - } - } - } - if (!changed) break; - } - - 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; - } - 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)` (and nested `(0, (0, x))`) evaluates to `x` — unwrap - // recursively and uniformly before every callee-shape check - // (R12-2); statically-known callee shapes ([x][0], static - // conditionals, Reflect.get with a literal key) additionally - // resolve to their targets (R13-7). - const callee = unwrapCallee(node.callee); - - // Callee opacity (#8084 R13-7): a guarded function invoked - // through a callee shape no arm can classify evades every - // name-based check. Fail closed narrowly: a conditional callee - // whose branch is statically undecided, a non-static element - // access into an array callee (the static-index form is resolved - // by unwrapCallee), and Reflect.get with an opaque key on a - // guarded global. Call-result callees of any other provenance - // (`it.each([...])(...)`, factory patterns) keep the documented - // pass-through — their binding is no more resolvable than any - // other local the rule does not track. - if (callee.type === 'ConditionalExpression') { - reportUnknown(node); - return; - } - if ( - callee.type === 'MemberExpression' && - callee.computed && - callee.object.type === 'ArrayExpression' - ) { - reportUnknown(node); - return; - } - if ( - callee.type === 'CallExpression' && - memberCall(callee.callee, ['Reflect'], /^get$/) && - callee.arguments.length > 0 - ) { - const staticKey = - callee.arguments.length >= 2 && - staticPropertyName(callee.arguments[1], true) !== undefined; - if (!staticKey) { - const target = unwrapSequence(callee.arguments[0]); - const targetObjectName = rightmostObjectName(target); - if ( - isProcessObject(target) || - targetObjectName === 'process' || - targetObjectName === 'globalThis' || - targetObjectName === 'global' - ) { - reportUnknown( - node, - isProcessObject(target) || targetObjectName === 'process' - ? 'moduleBuiltin' - : 'failClosed', - ); - return; - } - } - } - - // 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). Chained - // indirection (x.call.call) resolves innerName to 'call' itself — - // fail closed rather than fall through (R12-3). - 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 === 'call' || - innerName === 'apply' || - innerName === 'bind' - ) { - reportUnknown(node); - return; - } - if ( - innerName === 'eval' || - innerName === 'Function' || - innerName === 'constructor' || - // Worker/Script forward a module path / code — same - // fail-closed class as eval/Function; the alias sets keep - // this list mirrored with the direct-call arms (R13-6). - innerName === 'Worker' || - innerName === 'Script' || - (innerName !== undefined && workerAliases.has(innerName)) || - (innerName !== undefined && scriptAliases.has(innerName)) || - // vm exec callees forward CODE, not a specifier — and a - // renamed vm-exec import resolves through its alias set. - (innerName !== undefined && vmExecNames.test(innerName)) || - (innerName !== undefined && vmBareExecAliases.has(innerName)) - ) { - reportUnknown(node); - return; - } - if ( - innerName === 'getBuiltinModule' || - (innerName !== undefined && - bareGetBuiltinModuleAliases.has(innerName)) - ) { - if (node.arguments.length > 0) { - reportUnknown(node, 'moduleBuiltin'); - } - return; - } - if ( - /^(?:require|fork|mock|doMock|importActual|importMock)$/.test( - innerName ?? '', - ) || - forkAliases.has(innerName ?? '') || - vitestLoaderAliases.has(innerName ?? '') - ) { - if (indirect === 'call') { - if (node.arguments.length > 1) { - checkSource(node.arguments[1]); - } - } else { - reportUnknown(node); - } - return; - } - // Opaque composition one hop down: `process[k].call(...)` — - // the callee BELOW `.call/.apply/.bind` carries a statically - // unresolvable key on a guarded global, so the name-based - // cascade above sees `undefined` and falls through. Fail - // closed at composition depth with the family message - // (#8084 R13-4). - if ( - node.arguments.length > 0 && - namedGuardedObjectWithOpaqueKey(callee.object) - ) { - reportUnknown(node, opaqueGuardMessage(callee.object)); - return; - } - } - // A named guarded global with an opaque computed key is one - // variable rename away from a guarded entrance — fail closed - // (R12-7; object-agnostic arms keep their documented residue). - // process-family objects keep the dedicated moduleBuiltin - // message: the opaque key stands in for getBuiltinModule. - if ( - node.arguments.length > 0 && - namedGuardedObjectWithOpaqueKey(callee) - ) { - reportUnknown( - node, - isProcessObject(callee.object) ? 'moduleBuiltin' : 'failClosed', - ); - 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. eval/Function fail closed on ANY - // argument; `.constructor` keeps the pass-through for statically - // NON-string literals but fails closed on variables and - // expression templates — a code body held in a variable is still - // code (R12-8). - 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]; - if (first.type === 'Literal' && typeof first.value !== 'string') { - return; // statically non-string: cannot be a code body - } - reportUnknown(node); - } else { - reportUnknown(node); - } - return; - } - - // Worker invoked as a plain function — direct or reached through - // a resolved opaque shape ([Worker][0]('...'), R13-7). The - // guarded trees have no legitimate Worker-named factories, and - // the member match mirrors the object-agnostic new-Worker arm - // (#8084 R13-6). - if ( - node.arguments.length > 0 && - (calleeName === 'Worker' || - (callee.type === 'Identifier' && workerAliases.has(callee.name))) - ) { - 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), the bare `vm` name, and - // inline lazy imports — `(await import('node:vm')).runInContext` - // is canonical ESM and needs no aliasing (R12-9). - if (node.arguments.length > 0) { - const vmProperty = - callee.type === 'MemberExpression' - ? staticPropertyName(callee.property, callee.computed) - : undefined; - if ( - (vmProperty !== undefined && - vmExecNames.test(vmProperty) && - isVmObject(callee.object)) || - (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(...)`, - // RENAMED included (`import { importActual as ia }`, R12-6: a - // renamed destructuring is still a destructured spelling). 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) || - vitestLoaderAliases.has(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)) || - // Opaque object side: `globalThis[p].getBuiltinModule(...)` — - // the callee's own property resolves, but the OBJECT is an - // opaque computed member of a guarded-global family, which - // isProcessObject cannot see (#8084 R13-4). - (callee.type === 'MemberExpression' && - builtinModuleProperty(callee) && - namedGuardedObjectWithOpaqueKey(callee.object)) || - // Bare identifier: destructured/extracted spellings resolve - // through the alias set (R13-5 binding propagation). - (callee.type === 'Identifier' && - (callee.name === 'getBuiltinModule' || - bareGetBuiltinModuleAliases.has(callee.name))) - ) { - 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). - // The target lists mirror the direct-call arms: Function and the - // vm exec/Script surface forward CODE; require/fork/Worker and - // the vitest loaders forward specifiers; alias sets included - // (R12-4). - if (memberCall(callee, ['Reflect'], /^(?:apply|construct)$/)) { - const target = unwrapSequence(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'); - return; - } - // Opaque composition as the Reflect target: - // `Reflect.apply(process[k], ...)` — targetName one hop down is - // undefined and every name-based check below misses it; fail - // closed at composition depth (#8084 R13-4). - if (targetMember && namedGuardedObjectWithOpaqueKey(target)) { - reportUnknown(node, opaqueGuardMessage(target)); - return; - } - if (targetMember) { - if ( - targetName === 'eval' || - targetName === 'Function' || - targetName === 'fork' || - // Worker mirrors the object-agnostic new-Worker arm; the - // alias sets keep this list mirrored with the direct-call - // arms (R13-6). - targetName === 'Worker' || - (targetName !== undefined && workerAliases.has(targetName)) || - (targetName !== undefined && - vmExecNames.test(targetName) && - isVmObject(target.object)) || - (targetName === 'Script' && isVmObject(target.object)) || - (targetName !== undefined && vitestLoaderNames.test(targetName)) - ) { - reportUnknown(node); - } - } else if (target?.type === 'Identifier') { - if ( - /^(?:require|eval|fork|Function)$/.test(targetName ?? '') || - targetName === 'Worker' || - workerAliases.has(targetName ?? '') || - forkAliases.has(targetName ?? '') || - scriptAliases.has(targetName ?? '') || - vmBareExecAliases.has(targetName ?? '') || - vitestLoaderNames.test(targetName ?? '') || - vitestLoaderAliases.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) { - // Recursive sequence unwrap, same invariant as CallExpression - // (`new (0, (0, Worker))(…)` is transparent too, R12-2); the - // statically-known callee shapes resolve as well (R13-7). - const callee = unwrapCallee(node.callee); - - // Callee opacity, constructor spelling (#8084 R13-7): same - // narrow fail-closed shapes as the call arm — an undecided - // conditional callee and a non-static element access into an - // array callee. - if (callee.type === 'ConditionalExpression') { - reportUnknown(node); - return; - } - if ( - callee.type === 'MemberExpression' && - callee.computed && - callee.object.type === 'ArrayExpression' - ) { - reportUnknown(node); - return; - } - - // 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, - // including inline lazy imports, R12-9). - if ( - node.arguments.length > 0 && - ((callee.type === 'MemberExpression' && - staticPropertyName(callee.property, callee.computed) === 'Script' && - isVmObject(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. Contract (R12-1, modeled once in R13-3): - // fail closed on ANYTHING whose effect on the final `eval` - // value is statically undecided. Runtime object-literal - // semantics the scan must reproduce: the LAST `eval` key wins - // (duplicates included); an options object WITHOUT `eval` - // defaults to false (arg0 stays a specifier); a spread makes - // the final value unverifiable unless a LATER literal `eval` - // wins; a statically UNRESOLVABLE computed key may BE `eval`; - // and a non-computed `__proto__` key installs a PROTOTYPE, so - // `eval` can be inherited through the prototype chain even - // when no own `eval` exists. - const opts = node.arguments[1]; - if (opts) { - let evalState = 'absent'; // 'absent' | 'false' | 'true' | 'unknown' - if (opts.type === 'ObjectExpression') { - for (const property of opts.properties) { - if (property.type !== 'Property') { - // SpreadElement (or anything else) can set or override - // `eval` at runtime — only a LATER literal wins. - evalState = 'unknown'; - continue; - } - const key = staticPropertyName(property.key, property.computed); - if (key === undefined) { - // A statically unresolvable computed key may be 'eval' - // at runtime — same posture as a spread: only a LATER - // literal `eval` settles the final value (R13-3). - evalState = 'unknown'; - continue; - } - if (key === '__proto__' && !property.computed) { - // Non-computed `__proto__` sets the prototype; Node - // reads `opts.eval` with prototype lookup, so an - // inherited `eval: true` executes arg0 as code. Only a - // static null severs the chain (R13-3). - if ( - property.value.type === 'Literal' && - property.value.value === null - ) { - continue; - } - evalState = 'unknown'; - continue; - } - if (key !== 'eval') continue; - if ( - property.value.type === 'Literal' && - typeof property.value.value === 'boolean' - ) { - evalState = property.value.value ? 'true' : 'false'; - } else { - evalState = 'unknown'; - } - } - } else { - evalState = 'unknown'; // dynamic options object - } - if (evalState === 'unknown' || evalState === 'true') { - 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]; - if (!isNewUrlWithImportMetaUrl(arg)) 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. 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); - } - } - }, - }; - }, -}; diff --git a/eslint.config.js b/eslint.config.js index 8cdbd7df140..84a3f66a3f1 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -16,38 +16,6 @@ import globals from 'globals'; import storybook from 'eslint-plugin-storybook'; import checkFile from 'eslint-plugin-check-file'; import { legacyFilenames } from './eslint.legacy-filenames.mjs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import noServeBoundaryCross from './eslint-rules/no-serve-boundary-cross.js'; - -// Resolution-based serve boundary guard (#8084, #9144 round-8 decision): -// a local rule that RESOLVES each import-like specifier against the -// importing file and reports anything landing inside src/serve/, -// fail-closed on sources it cannot check statically. It replaces the -// spelling-by-spelling regex/glob matrix (depth-enumerated globs, -// per-shape selector regexes, percent/query/fragment/absolute/createRequire -// special cases) — eight review rounds each demonstrated a new spelling -// escaping that matrix, because every spelling is just another way to name -// the same target. -const serveBoundaryPlugin = { - rules: { 'no-serve-boundary-cross': noServeBoundaryCross }, -}; -const configDir = path.dirname(fileURLToPath(import.meta.url)); -const serveBoundaryOptions = { - serveDir: path.resolve(configDir, 'packages/cli/src/serve'), - baseUrlDir: path.resolve(configDir, 'packages/cli'), -}; - -const restrictedRequire = { - selector: 'CallExpression[callee.name="require"]', - message: 'Avoid using require(). Use ES6 imports instead.', -}; - -const restrictedStringThrow = { - selector: 'ThrowStatement > Literal:not([value=/^\\w+Error:/])', - message: - 'Do not throw string literals or non-Error objects. Throw new Error("...") instead.', -}; export default tseslint.config( { @@ -105,25 +73,44 @@ export default tseslint.config( }, }, { - // `runtime/` is the neutral layer acp-integration is directed to; it must - // not import `serve/` internals itself, or the #8084 boundary reforms - // transitively one hop away. Enforced by the resolution-based local rule - // (see serveBoundaryPlugin above). - files: ['packages/cli/src/runtime/**/*.{ts,tsx}'], - plugins: { 'qwen-boundary': serveBoundaryPlugin }, + // ACP integration and the daemon are separate runtime surfaces that happen + // to share a package directory. ACP may consume neutral contracts under + // `runtime/`, but never `serve/` implementation modules — see #8084. + files: ['packages/cli/src/acp-integration/**/*.{ts,tsx}'], rules: { - 'qwen-boundary/no-serve-boundary-cross': ['error', serveBoundaryOptions], + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['**/serve/*', '**/serve/**'], + message: + 'acp-integration must not import serve/ internals. Put shared, lifecycle-free logic in packages/cli/src/runtime/ instead (#8084).', + }, + ], + }, + ], }, - }, { + }, + { // `utils/` is the layer every other directory imports, so it must not // import back into one. The daemon direction is clean and enforced here; // the remaining `ui/`, `config/`, `i18n/` and `nonInteractive/` edges are // tracked in #9146 and will be added to this group as they are resolved. - // Enforced by the resolution-based local rule (see serveBoundaryPlugin). files: ['packages/cli/src/utils/**/*.{ts,tsx}'], - plugins: { 'qwen-boundary': serveBoundaryPlugin }, rules: { - 'qwen-boundary/no-serve-boundary-cross': ['error', serveBoundaryOptions], + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['**/serve/*', '**/serve/**'], + message: + 'packages/cli/src/utils must not import serve/. Move lifecycle-free logic down into utils/ instead (#9146).', + }, + ], + }, + ], }, }, { @@ -203,8 +190,15 @@ export default tseslint.config( 'no-duplicate-case': 'error', 'no-restricted-syntax': [ 'error', - restrictedRequire, - restrictedStringThrow, + { + selector: 'CallExpression[callee.name="require"]', + message: 'Avoid using require(). Use ES6 imports instead.', + }, + { + selector: 'ThrowStatement > Literal:not([value=/^\\w+Error:/])', + message: + 'Do not throw string literals or non-Error objects. Throw new Error("...") instead.', + }, ], 'no-unsafe-finally': 'error', 'no-console': 'error', @@ -222,17 +216,6 @@ export default tseslint.config( radix: 'error', 'default-case': 'error', }, - }, { - // ACP integration and the daemon are separate runtime surfaces that happen - // to share a package directory. ACP may consume neutral contracts under - // `runtime/`, but never `serve/` implementation modules — see #8084. - // Enforced by the resolution-based local rule (see serveBoundaryPlugin); - // fixture coverage lives in scripts/tests/eslint-boundary-rules.test.js. - files: ['packages/cli/src/acp-integration/**/*.{ts,tsx}'], - plugins: { 'qwen-boundary': serveBoundaryPlugin }, - rules: { - 'qwen-boundary/no-serve-boundary-cross': ['error', serveBoundaryOptions], - }, }, { files: [ diff --git a/scripts/tests/eslint-boundary-rules.test.js b/scripts/tests/eslint-boundary-rules.test.js deleted file mode 100644 index 7659d2f81e8..00000000000 --- a/scripts/tests/eslint-boundary-rules.test.js +++ /dev/null @@ -1,1315 +0,0 @@ -/** - * @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 () => { - // repoRoot is backslash-separated on Windows; interpolating it into a - // single-quoted JS literal raw would let NonEscapeCharacter cooking - // destroy the specifier (and fileURLToPath would throw Invalid URL), - // turning this pin deterministically red on the Windows merge gate - // (R12-10). Normalize to forward slashes, which both the rule and - // file URLs accept on every platform. - const serveEntry = path - .join(repoRoot, 'packages/cli/src/serve/index.ts') - .split(path.sep) - .join('/'); - 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'); - // Pre-clean leftovers from an interrupted run: an EEXIST here would - // otherwise be misread as "no unprivileged symlink support" and - // silently skip the pin forever (R12-15). - rmSync(intoServe, { force: true }); - rmSync(outOfServe, { force: true }); - 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. - // Clean up whatever the first call created before failing. - rmSync(intoServe, { force: true }); - rmSync(outOfServe, { force: true }); - } - 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); - } - }); - - // ── Round-12 review pins (batch 2) ─────────────────────────────────── - - // R12-1: the Worker eval-option analysis must match runtime - // object-literal semantics — last key wins, absent eval defaults to - // false (specifier path), and a spread after the last literal eval is - // unverifiable. - it('analyses Worker eval options with runtime literal semantics', async () => { - // No eval property: eval defaults to false — arg0 is a specifier, so - // a clean target passes (over-blocking regression pin). - await expectNoBoundaryHits( - ACP_FIXTURE, - "new Worker('../utils/worker.js', { name: 'bg' });", - ); - await expectNoBoundaryHits( - ACP_FIXTURE, - "new Worker('../utils/worker.js', {});", - ); - // A spread AFTER an eval:false literal can override eval at runtime. - await expectServeBoundaryError( - ACP_FIXTURE, - "const overrides = { eval: true };\nnew Worker('x', { eval: false, ...overrides });", - ); - // Duplicate keys: the runtime gives the LAST one. - await expectServeBoundaryError( - ACP_FIXTURE, - "new Worker('x', { eval: false, eval: true });", - ); - // A trailing literal false wins over an earlier spread. - await expectNoBoundaryHits( - ACP_FIXTURE, - "const overrides = {};\nnew Worker('../utils/worker.js', { ...overrides, eval: false });", - ); - }); - - // R12-2: sequence unwrapping is a uniform invariant — recursive on the - // callee AND applied to object expressions. - it('unwraps nested sequences and sequence-wrapped objects', async () => { - for (const code of [ - "(0, require).call(null, '../serve/index.js');", - "(0, (0, require))('../serve/index.js');", - "new (0, (0, Worker))('../serve/worker.js');", - "(0, process).getBuiltinModule('node:module');", - "Reflect.apply((0, process).getBuiltinModule, null, ['module']);", - ]) { - await expectServeBoundaryError(ACP_FIXTURE, code); - } - }); - - // R12-3: call/apply/bind indirection is complete — Function/constructor - // forward code, chained indirection fails closed, vm exec names and the - // rule's own alias sets resolve. - it('covers call/apply/bind indirection on every guarded family', async () => { - for (const code of [ - 'Function.call(null, "return import(\'../serve/index.js\')");', - 'Function.apply(null, ["return import(\'../serve/index.js\')"]);', - 'Function.bind(null, "return import(\'../serve/index.js\')")();', - 'eval.call.call(null, null, "import(\'../serve/index.js\')");', - "vi.mock.call.call(vi, null, '../serve/live/live-task-service.js');", - "process.getBuiltinModule.call.call(process, null, 'node:module');", - "import vm from 'node:vm';\nvm.runInContext.call(vm, 'x', {});", - "import { runInContext as ric } from 'node:vm';\nric.call(null, 'x', {});", - "import { fork as f } from 'node:child_process';\nf.call(null, '../serve/index.js');", - ]) { - await expectServeBoundaryError(ACP_FIXTURE, code); - } - }); - - // R12-4: Reflect target lists mirror the direct-call arms — Function, - // the vm exec/Script surface, and the vitest loaders. - it('covers Reflect.apply/construct on every guarded family', async () => { - for (const code of [ - 'Reflect.apply(Function, null, ["return import(\'../serve/index.js\')"]);', - 'Reflect.construct(Function, ["return import(\'../serve/index.js\')"]);', - 'Reflect.apply(globalThis.Function, null, ["x"]);', - "import vm from 'node:vm';\nReflect.apply(vm.runInContext, vm, ['x', {}]);", - "import { compileFunction } from 'node:vm';\nReflect.apply(compileFunction, null, ['x']);", - "import vm from 'node:vm';\nReflect.construct(vm.Script, ['x']);", - "import { vi } from 'vitest';\nReflect.apply(vi.mock, vi, ['../serve/live/live-task-service.js']);", - "import { importActual } from 'vitest';\nReflect.apply(importActual, null, ['../serve/live/live-task-service.js']);", - ]) { - await expectServeBoundaryError(ACP_FIXTURE, code); - } - }); - - // R12-5: ESM imports are hoisted — a renamed import used BEFORE its - // declaration must resolve through the alias sets (pre-pass, not - // visitor source order). All five families. - it('resolves renamed imports used before their declaration', async () => { - for (const code of [ - "export const w = new W('../serve/worker.js');\nimport { Worker as W } from 'node:worker_threads';", - "export const p = f('../serve/index.js');\nimport { fork as f } from 'node:child_process';", - "export const s = new S('x');\nimport { Script as S } from 'node:vm';", - "export const r = ric('x', {});\nimport { runInContext as ric } from 'node:vm';", - "export const c = v2.runInContext('x', {});\nimport v2 from 'node:vm';", - ]) { - await expectServeBoundaryError(ACP_FIXTURE, code); - } - }); - - // R12-6: renamed destructured vitest imports are still destructured - // spellings — the bare arm resolves them through vitestLoaderAliases. - it('catches renamed destructured vitest loader imports', async () => { - for (const [name, alias] of [ - ['importActual', 'ia'], - ['mock', 'm'], - ['doMock', 'dm'], - ['importMock', 'im'], - ]) { - await expectServeBoundaryError( - ACP_FIXTURE, - `import { ${name} as ${alias} } from 'vitest';\n${alias}('../serve/live/live-task-service.js');`, - ); - } - }); - - // R12-7: a named guarded global with an opaque computed key is one - // variable rename from a guarded entrance — fail closed. - it('fails closed on opaque computed keys of guarded globals', async () => { - const [gbm] = await lintCliFile( - ACP_FIXTURE, - "const gbm = 'getBuiltinModule';\nprocess[gbm]('node:module');", - ); - expect( - gbm.messages.some( - (message) => - message.ruleId === RULE_ID && message.messageId === 'moduleBuiltin', - ), - ).toBe(true); - const [evalCase] = await lintCliFile( - ACP_FIXTURE, - "const e = 'eval';\nglobalThis[e](\"import('../serve/index.js')\");", - ); - expect( - evalCase.messages.some((message) => message.ruleId === RULE_ID), - ).toBe(true); - }); - - // R12-8: .constructor fails closed on variable bodies and expression - // templates; statically non-string literals still pass through. - it('fails closed on dynamic constructor code bodies', async () => { - await expectServeBoundaryError( - ACP_FIXTURE, - 'const body = "return import(\'../serve/index.js\')";\n({}).constructor.constructor(body)()();', - ); - await expectServeBoundaryError( - ACP_FIXTURE, - "const x = 'x';\n(function(){}).constructor(`return '${'x'}' + x`)();", - ); - await expectNoBoundaryHits(ACP_FIXTURE, '({}).constructor(42);'); - }); - - // R12-9: inline lazy vm imports are vm objects without any aliasing — - // the canonical ESM spelling must not evade the vm arms. - it('catches inline lazy-import vm spellings', async () => { - for (const code of [ - "(await import('node:vm')).runInContext('x', {});", - "(await import('node:vm')).compileFunction('x');", - "new (await import('node:vm')).Script('x');", - "(await import('vm')).runInContext('x', {});", - ]) { - await expectServeBoundaryError(ACP_FIXTURE, code); - } - }); - - // R12-11: statically non-specifier arguments are non-imports — no - // unactionable fail-closed advice for env objects and the like. - it('does not fail-close statically non-specifier arguments', async () => { - for (const code of [ - 'recorder.mock({ silent: true });', - 'recorder.mock(42);', - "cluster.fork({ NODE_ENV: 'prod' });", - ]) { - await expectNoBoundaryHits(ACP_FIXTURE, code); - } - }); - - // R12-12: the URL arm owns new URL(spec, import.meta.url) on EVERY - // entrance — no fail-closed over-block on the clean form, exactly one - // serveBoundary on the serve form. - it('lets the URL arm own new URL(spec, import.meta.url) everywhere', async () => { - await expectNoBoundaryHits( - ACP_FIXTURE, - "export async function load() { return import(new URL('./plugin.js', import.meta.url)); }", - ); - const [serve] = await lintCliFile( - ACP_FIXTURE, - "export async function load() { return import(new URL('../serve/index.js', import.meta.url)); }", - ); - const hits = serve.messages.filter((message) => message.ruleId === RULE_ID); - expect(hits).toHaveLength(1); - expect(hits[0].messageId).toBe('serveBoundary'); - await expectNoBoundaryHits( - ACP_FIXTURE, - "const m = require(new URL('./plugin.js', import.meta.url));", - ); - }); - - // R12-13: the module builtin reports the dedicated moduleBuiltin - // message on every entrance, not the unactionable failClosed advice. - it('reports module-builtin imports with the dedicated message', async () => { - for (const code of [ - "export async function load() { return import('module'); }", - "const m = require('module');", - "import x = require('module');", - "export { createRequire } from 'node:module';", - ]) { - const [result] = await lintCliFile(ACP_FIXTURE, code); - expect( - result.messages.some( - (message) => - message.ruleId === RULE_ID && message.messageId === 'moduleBuiltin', - ), - ).toBe(true); - } - }); - - // R12-14 mutation survivors: the renamed-Script Identifier disjunct and - // the unprefixed builtin-import alias registration direction. - it('pins the renamed-Script arm and unprefixed alias registration', async () => { - await expectServeBoundaryError( - ACP_FIXTURE, - "import { Script as S } from 'node:vm';\nnew S('x');", - ); - await expectServeBoundaryError( - ACP_FIXTURE, - "import { fork as f } from 'child_process';\nf('../serve/index.js');", - ); - await expectServeBoundaryError( - ACP_FIXTURE, - "import { Worker as W } from 'worker_threads';\nnew W('../serve/worker.js');", - ); - }); - - // ── Round-13 review pins ───────────────────────────────────────────── - - // R13-1: backslash normalization must run AFTER percent-decoding too — - // %5c/%5C reintroduce backslashes that decode into the already-pinned - // literal-backslash traversal. - it('rejects percent-encoded backslash traversal into serve', async () => { - for (const code of [ - "import '..%5cserve%5cindex.js';", - "import '..%5Cserve%5Cindex.js';", - "export async function load() { await import('..%5cserve%5cindex.js'); }", - ]) { - await expectServeBoundaryError(ACP_FIXTURE, code); - } - }); - - // R13-2: a nonexistent target reached through a symlinked ancestor must - // canonicalize via the deepest existing ancestor instead of failing open - // with the textual path. The link points runtime/ at src/, so - // `./r13-src-link/serve/...` resolves into the real serve tree even - // though the final file does not exist. - it('fails closed through symlinked ancestors for missing targets', async () => { - const link = path.join(repoRoot, 'packages/cli/src/runtime/r13-src-link'); - rmSync(link, { force: true }); - try { - symlinkSync('..', link); - await expectServeBoundaryError( - 'packages/cli/src/runtime/boundary-fixture.ts', - "import './r13-src-link/serve/r13-nonexistent.js';", - ); - // Negative control: the same mechanism resolving OUTSIDE serve - // stays allowed (over-blocking regression pin). - await expectNoBoundaryHits( - 'packages/cli/src/runtime/boundary-fixture.ts', - "import './r13-src-link/utils/r13-nonexistent.js';", - ); - } finally { - rmSync(link, { force: true }); - } - }); - - // R13-3: the Worker eval-option contract fails closed on every shape - // whose effect on the final eval value is statically undecided — an - // opaque computed key, a prototype-inherited eval, and a quoted key. - it('fails closed on undecided Worker eval option shapes', async () => { - for (const code of [ - "const k = 'eval';\nnew Worker('x', { [k]: true });", - "new Worker('x', { __proto__: { eval: true } });", - "new Worker('x', { 'eval': true });", - ]) { - await expectServeBoundaryError(ACP_FIXTURE, code); - } - // A LATER literal false still wins over an earlier opaque key, and a - // static null prototype severs the chain — both stay on the - // specifier path (over-blocking regression pins). - await expectNoBoundaryHits( - ACP_FIXTURE, - "const k = 'noise';\nnew Worker('../utils/worker.js', { [k]: true, eval: false });", - ); - await expectNoBoundaryHits( - ACP_FIXTURE, - "new Worker('../utils/worker.js', { __proto__: null });", - ); - }); - - // R13-4: the opaque-key fail-closed check applies at composition depth — - // one hop below .call, as a Reflect target, and on the getBuiltinModule - // object side. All three keep the moduleBuiltin message of the process - // family. - it('fails closed on opaque-key compositions of guarded globals', async () => { - for (const code of [ - "const k = 'getBuiltinModule';\nprocess[k].call(process, 'node:module');", - "const k = 'getBuiltinModule';\nReflect.apply(process[k], null, ['node:module']);", - "const p = 'pro' + 'cess';\nglobalThis[p].getBuiltinModule('node:module');", - ]) { - const [result] = await lintCliFile(ACP_FIXTURE, code); - expect( - result.messages.some( - (message) => - message.ruleId === RULE_ID && message.messageId === 'moduleBuiltin', - ), - ).toBe(true); - } - }); - - // R13-5: one binding hop must not defeat the name-based arms — the - // top-level binding-propagation pre-scan covers rebinding chains, - // member extraction, destructuring from guarded globals / tracked - // namespaces / awaited dynamic imports, and .then namespace params. - it('tracks bindings propagated from guarded names and imports', async () => { - for (const code of [ - "const { Worker: W } = await import('node:worker_threads');\nnew W('../serve/worker.js');", - "const { fork: f } = await import('node:child_process');\nf('../serve/index.js');", - "import * as wt from 'node:worker_threads';\nconst { Worker: W } = wt;\nnew W('../serve/worker.js');", - "const W = Worker;\nconst W2 = W;\nnew W2('../serve/worker.js');", - "const m = await import('node:vm');\nm.runInThisContext('x');", - "import('node:vm').then((vmNs) => { vmNs.runInThisContext('x'); });", - "const P = process;\nP.getBuiltinModule('node:module');", - "const { mock: m } = await import('vitest');\nm('../serve/live/live-task-service.js');", - ]) { - await expectServeBoundaryError(ACP_FIXTURE, code); - } - // Extracted/destructured getBuiltinModule keeps the dedicated - // moduleBuiltin message on every entrance. - for (const code of [ - "const { getBuiltinModule: g } = process;\ng('node:module');", - 'const g = process.getBuiltinModule;\ng.call(null, "node:module");', - ]) { - const [result] = await lintCliFile(ACP_FIXTURE, code); - expect( - result.messages.some( - (message) => - message.ruleId === RULE_ID && message.messageId === 'moduleBuiltin', - ), - ).toBe(true); - } - }); - - // R13-6: the indirection and Reflect arms guard Worker/Script too — - // the target lists mirror the direct-call arms. - it('covers Worker/Script through indirection and Reflect targets', async () => { - for (const code of [ - "import * as wt from 'node:worker_threads';\nReflect.construct(wt.Worker, ['../serve/worker.js']);", - "import * as wt from 'node:worker_threads';\nReflect.apply(wt.Worker, null, ['../serve/worker.js']);", - "const W = Worker.bind(null, '../serve/worker.js');\nnew W();", - "import { Script as S } from 'node:vm';\nS.call(null, 'x');", - ]) { - await expectServeBoundaryError(ACP_FIXTURE, code); - } - }); - - // R13-7: statically-known callee shapes resolve to their targets; an - // unclassifiable callee fails closed like an unresolvable source. - it('resolves or fails closed on opaque callee shapes', async () => { - await expectServeBoundaryError( - ACP_FIXTURE, - "import { fork } from 'node:child_process';\n[fork][0]('../serve/index.js');", - ); - await expectServeBoundaryError( - ACP_FIXTURE, - "new [Worker][0]('../serve/worker.js');", - ); - for (const code of [ - "Reflect.get(process, 'getBuiltinModule')('node:module');", - "(1 ? process.getBuiltinModule : 0)('node:module');", - "const { getBuiltinModule: g } = process;\nReflect.get(process, 'getBuiltinModule').call(null, 'node:module');", - // Opaque key on a guarded global at callee depth keeps the family - // message (R13-4 posture, R13-7 entrance). - "const k = 'getBuiltinModule';\nReflect.get(process, k)('node:module');", - ]) { - const [result] = await lintCliFile(ACP_FIXTURE, code); - expect( - result.messages.some( - (message) => - message.ruleId === RULE_ID && message.messageId === 'moduleBuiltin', - ), - ).toBe(true); - } - for (const code of [ - "[Worker][0]('../serve/worker.js');", - "const i = 1;\n[Worker][i]('../serve/worker.js');", - "const c = Math.random();\n(c ? process.getBuiltinModule : 0)('node:module');", - ]) { - await expectServeBoundaryError(ACP_FIXTURE, code); - } - // Inline-defined callees cannot alias a guarded binding, and call - // results of any other provenance (it.each spellings, factories) - // keep their documented pass-through — no over-blocking (regression - // pins). - await expectNoBoundaryHits(ACP_FIXTURE, "((x) => x)('y');"); - await expectNoBoundaryHits(ACP_FIXTURE, 'new (class {})();'); - await expectNoBoundaryHits( - ACP_FIXTURE, - "const w = makeWorker('./plugin.js');\nw();", - ); - await expectNoBoundaryHits( - ACP_FIXTURE, - "each([1, 2])('case %s', (n) => n);", - ); - await expectNoBoundaryHits(ACP_FIXTURE, 'finishUpdate!();'); - }); -}); From 210b2e1068baca5f7ee6ffedf96a37f748ee98a0 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Fri, 21 Aug 2026 03:39:40 +0800 Subject: [PATCH 24/26] fix(lint): close the round-21 contract pins and bare-barrel escape (#8084) --- eslint.config.js | 2 +- .../tests/acp-serve-boundary-guard.test.js | 58 +++++++++++++++++++ scripts/tests/cross-package-contracts.test.js | 4 +- 3 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 scripts/tests/acp-serve-boundary-guard.test.js diff --git a/eslint.config.js b/eslint.config.js index 84a3f66a3f1..66533fd13a2 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -83,7 +83,7 @@ export default tseslint.config( { patterns: [ { - group: ['**/serve/*', '**/serve/**'], + group: ['**/serve', '**/serve/*', '**/serve/**'], message: 'acp-integration must not import serve/ internals. Put shared, lifecycle-free logic in packages/cli/src/runtime/ instead (#8084).', }, diff --git a/scripts/tests/acp-serve-boundary-guard.test.js b/scripts/tests/acp-serve-boundary-guard.test.js new file mode 100644 index 00000000000..6691e60e219 --- /dev/null +++ b/scripts/tests/acp-serve-boundary-guard.test.js @@ -0,0 +1,58 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { ESLint } from 'eslint'; +import { expect, it } from 'vitest'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '../..'); + +const eslint = new ESLint({ cwd: root }); + +async function restrictedReports(statement) { + const filePath = join( + root, + 'packages/cli/src/acp-integration/boundary-probe.ts', + ); + const [result] = await eslint.lintText(`${statement}\n`, { filePath }); + return result.messages.filter((m) => m.ruleId === 'no-restricted-imports'); +} + +// Bare-directory specifiers resolve to packages/cli/src/serve/index.ts, a +// barrel re-exporting the full daemon surface — they must be caught by the +// same guard that blocks deep serve/ internals (#8084). +it.each(['../serve', '../../serve'])( + 'blocks the bare barrel specifier %s from acp-integration', + async (specifier) => { + const reports = await restrictedReports( + `import { createServeApp } from '${specifier}';`, + ); + expect(reports).toHaveLength(1); + expect(reports[0].message).toContain('acp-integration'); + }, +); + +it('blocks a bare barrel re-export from acp-integration', async () => { + const reports = await restrictedReports( + `export { createServeApp } from '../serve';`, + ); + expect(reports).toHaveLength(1); +}); + +it('still blocks deep serve/ internals from acp-integration', async () => { + const reports = await restrictedReports( + `import { createServeApp } from '../serve/index.js';`, + ); + expect(reports).toHaveLength(1); +}); + +it('allows neutral runtime/ contracts from acp-integration', async () => { + const reports = await restrictedReports( + `import { something } from '../runtime/contracts.js';`, + ); + expect(reports).toHaveLength(0); +}); diff --git a/scripts/tests/cross-package-contracts.test.js b/scripts/tests/cross-package-contracts.test.js index e9a47fe80a8..ce3afc86f8a 100644 --- a/scripts/tests/cross-package-contracts.test.js +++ b/scripts/tests/cross-package-contracts.test.js @@ -55,7 +55,7 @@ const imports = [ ], [ 'LIVE_TASK_TOOL_NAMES', - 'packages/cli/src/serve/live/live-task-tools.ts', + 'packages/cli/src/acp-integration/live/live-task-tools.ts', '@qwen-code/acp-bridge/bridgeOptions', ], [ @@ -65,7 +65,7 @@ const imports = [ ], [ 'LiveTaskToolName', - 'packages/cli/src/serve/live/live-task-tools.ts', + 'packages/cli/src/acp-integration/live/live-task-tools.ts', '@qwen-code/acp-bridge/bridgeOptions', ], [ From 01475abb61847cde6f3316e2ef19e11223aac98c Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Sat, 22 Aug 2026 18:09:24 +0800 Subject: [PATCH 25/26] fix(lint): close dynamic-import and js-file holes in the acp/serve guard (#8084) --- eslint.config.js | 52 ++++++++++++++----- .../tests/acp-serve-boundary-guard.test.js | 38 ++++++++++++-- 2 files changed, 72 insertions(+), 18 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 66533fd13a2..089392326b7 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -17,6 +17,21 @@ import storybook from 'eslint-plugin-storybook'; import checkFile from 'eslint-plugin-check-file'; import { legacyFilenames } from './eslint.legacy-filenames.mjs'; +// General syntax restrictions applied to every TS/TSX source file. Hoisted so +// surface-specific overrides (flat config keeps only the last +// no-restricted-syntax setting per file) can repeat them without drift. +const generalRestrictedSyntaxSelectors = [ + { + selector: 'CallExpression[callee.name="require"]', + message: 'Avoid using require(). Use ES6 imports instead.', + }, + { + selector: 'ThrowStatement > Literal:not([value=/^\\w+Error:/])', + message: + 'Do not throw string literals or non-Error objects. Throw new Error("...") instead.', + }, +]; + export default tseslint.config( { // Global ignores @@ -76,14 +91,14 @@ export default tseslint.config( // ACP integration and the daemon are separate runtime surfaces that happen // to share a package directory. ACP may consume neutral contracts under // `runtime/`, but never `serve/` implementation modules — see #8084. - files: ['packages/cli/src/acp-integration/**/*.{ts,tsx}'], + files: ['packages/cli/src/acp-integration/**/*.{ts,tsx,js}'], rules: { 'no-restricted-imports': [ 'error', { patterns: [ { - group: ['**/serve', '**/serve/*', '**/serve/**'], + group: ['**/serve', '**/serve/**'], message: 'acp-integration must not import serve/ internals. Put shared, lifecycle-free logic in packages/cli/src/runtime/ instead (#8084).', }, @@ -188,18 +203,7 @@ export default tseslint.config( 'no-cond-assign': 'error', 'no-debugger': 'error', 'no-duplicate-case': 'error', - 'no-restricted-syntax': [ - 'error', - { - selector: 'CallExpression[callee.name="require"]', - message: 'Avoid using require(). Use ES6 imports instead.', - }, - { - selector: 'ThrowStatement > Literal:not([value=/^\\w+Error:/])', - message: - 'Do not throw string literals or non-Error objects. Throw new Error("...") instead.', - }, - ], + 'no-restricted-syntax': ['error', ...generalRestrictedSyntaxSelectors], 'no-unsafe-finally': 'error', 'no-console': 'error', 'no-unused-expressions': 'off', // Disable base rule @@ -217,6 +221,26 @@ export default tseslint.config( 'default-case': 'error', }, }, + { + // no-restricted-imports only sees static import/export declarations, so a + // dynamic `await import('../serve/...')` would slip past the #8084 guard + // above. Kept after the general TS block because flat config applies only + // the last no-restricted-syntax setting per file, hence the repeated + // general selectors. + files: ['packages/cli/src/acp-integration/**/*.{ts,tsx,js}'], + rules: { + 'no-restricted-syntax': [ + 'error', + ...generalRestrictedSyntaxSelectors, + { + // \x2f is '/' — esquery selector regexes cannot contain a literal '/'. + selector: "ImportExpression[source.value=/(^|\\x2f)serve(\\x2f|$)/]", + message: + 'acp-integration must not dynamically import serve/ internals. Put shared, lifecycle-free logic in packages/cli/src/runtime/ instead (#8084).', + }, + ], + }, + }, { files: [ 'packages/web-shell/client/**/*.{ts,tsx}', diff --git a/scripts/tests/acp-serve-boundary-guard.test.js b/scripts/tests/acp-serve-boundary-guard.test.js index 6691e60e219..b71e7404a79 100644 --- a/scripts/tests/acp-serve-boundary-guard.test.js +++ b/scripts/tests/acp-serve-boundary-guard.test.js @@ -13,13 +13,20 @@ const root = join(dirname(fileURLToPath(import.meta.url)), '../..'); const eslint = new ESLint({ cwd: root }); +// Static imports are reported by no-restricted-imports; dynamic import() is +// reported by no-restricted-syntax because the former never sees it. +const BOUNDARY_RULES = new Set([ + 'no-restricted-imports', + 'no-restricted-syntax', +]); + async function restrictedReports(statement) { const filePath = join( root, 'packages/cli/src/acp-integration/boundary-probe.ts', ); const [result] = await eslint.lintText(`${statement}\n`, { filePath }); - return result.messages.filter((m) => m.ruleId === 'no-restricted-imports'); + return result.messages.filter((m) => BOUNDARY_RULES.has(m.ruleId)); } // Bare-directory specifiers resolve to packages/cli/src/serve/index.ts, a @@ -50,9 +57,32 @@ it('still blocks deep serve/ internals from acp-integration', async () => { expect(reports).toHaveLength(1); }); -it('allows neutral runtime/ contracts from acp-integration', async () => { +it('blocks type-only imports and re-exports from serve/ from acp-integration', async () => { + expect( + await restrictedReports(`import type { ServeAppDeps } from '../serve';`), + ).toHaveLength(1); + expect( + await restrictedReports(`export type { ServeAppDeps } from '../serve';`), + ).toHaveLength(1); +}); + +it('blocks a dynamic import() of serve/ from acp-integration', async () => { const reports = await restrictedReports( - `import { something } from '../runtime/contracts.js';`, + `async function probe() { await import('../serve/index.js'); }`, ); - expect(reports).toHaveLength(0); + expect(reports).toHaveLength(1); + expect(reports[0].message).toContain('acp-integration'); +}); + +it('allows neutral runtime/ contracts from acp-integration', async () => { + expect( + await restrictedReports( + `import { something } from '../runtime/contracts.js';`, + ), + ).toHaveLength(0); + expect( + await restrictedReports( + `async function probe() { await import('../runtime/contracts.js'); }`, + ), + ).toHaveLength(0); }); From 5e7511ac6b08071f172cddd279ea44e0368068ea Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sat, 22 Aug 2026 20:29:44 +0800 Subject: [PATCH 26/26] fix(lint): make acp/serve dynamic-import guard case-insensitive The no-restricted-syntax selector for dynamic import() of serve/ was case-sensitive, so a macOS case-variant specifier (`../Serve/...`) would resolve to the daemon barrel without tripping the guard. Add the /i flag and cover case-variant plus computed-specifier behavior. --- eslint.config.js | 2 +- scripts/tests/acp-serve-boundary-guard.test.js | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/eslint.config.js b/eslint.config.js index 089392326b7..da1dc61caae 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -234,7 +234,7 @@ export default tseslint.config( ...generalRestrictedSyntaxSelectors, { // \x2f is '/' — esquery selector regexes cannot contain a literal '/'. - selector: "ImportExpression[source.value=/(^|\\x2f)serve(\\x2f|$)/]", + selector: "ImportExpression[source.value=/(^|\\x2f)serve(\\x2f|$)/i]", message: 'acp-integration must not dynamically import serve/ internals. Put shared, lifecycle-free logic in packages/cli/src/runtime/ instead (#8084).', }, diff --git a/scripts/tests/acp-serve-boundary-guard.test.js b/scripts/tests/acp-serve-boundary-guard.test.js index b71e7404a79..b8fbb05b6b2 100644 --- a/scripts/tests/acp-serve-boundary-guard.test.js +++ b/scripts/tests/acp-serve-boundary-guard.test.js @@ -74,6 +74,13 @@ it('blocks a dynamic import() of serve/ from acp-integration', async () => { expect(reports[0].message).toContain('acp-integration'); }); +it('blocks a case-variant dynamic import() of Serve/ from acp-integration', async () => { + const reports = await restrictedReports( + `async function probe() { await import('../Serve/index.js'); }`, + ); + expect(reports).toHaveLength(1); +}); + it('allows neutral runtime/ contracts from acp-integration', async () => { expect( await restrictedReports( @@ -85,4 +92,12 @@ it('allows neutral runtime/ contracts from acp-integration', async () => { `async function probe() { await import('../runtime/contracts.js'); }`, ), ).toHaveLength(0); + // A computed specifier (not a string literal) has no source.value, so the + // dynamic guard must not reject it — the import target is unknowable at + // lint time. + expect( + await restrictedReports( + `async function probe() { const target = '../runtime/contracts.js'; await import(target); }`, + ), + ).toHaveLength(0); });