From f4797921ac01ad80dafa2c2ac7684c62cc0e2ca3 Mon Sep 17 00:00:00 2001 From: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Mon, 4 May 2026 15:30:37 +1000 Subject: [PATCH] lint: classify raw socket callsites --- docs/security/network-proxy.md | 9 + package.json | 2 + .../check-managed-proxy-runtime-mutation.mjs | 448 ++++++++++++++++++ ...eck-raw-socket-callsite-classification.mjs | 370 +++++++++++++++ scripts/run-additional-boundary-checks.mjs | 6 + ...eck-managed-proxy-runtime-mutation.test.ts | 184 +++++++ ...raw-socket-callsite-classification.test.ts | 192 ++++++++ .../run-additional-boundary-checks.test.ts | 16 + 8 files changed, 1227 insertions(+) create mode 100644 scripts/check-managed-proxy-runtime-mutation.mjs create mode 100644 scripts/check-raw-socket-callsite-classification.mjs create mode 100644 test/scripts/check-managed-proxy-runtime-mutation.test.ts create mode 100644 test/scripts/check-raw-socket-callsite-classification.test.ts diff --git a/docs/security/network-proxy.md b/docs/security/network-proxy.md index cbe499f89f0..78687ee186f 100644 --- a/docs/security/network-proxy.md +++ b/docs/security/network-proxy.md @@ -220,3 +220,12 @@ proxy: - Gateway control-plane proxy bypass is intentionally limited to `localhost` and literal loopback IP URLs. Use `ws://127.0.0.1:18789`, `ws://[::1]:18789`, or `ws://localhost:18789` for local direct Gateway control-plane connections; other hostnames route like ordinary hostname-based traffic. - OpenClaw does not inspect, test, or certify your proxy policy. - Treat proxy policy changes as security-sensitive operational changes. + +| Surface | Managed proxy status | +| ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- | +| `fetch`, `node:http`, `node:https`, common WebSocket clients | Routed through managed proxy hooks when configured. | +| APNs direct HTTP/2 | Routed through the APNs managed CONNECT helper. | +| Gateway control-plane loopback | Direct only for the configured local loopback Gateway URL. | +| Debug proxy upstream forwarding | Disabled while managed proxy mode is active unless explicitly enabled for local diagnostics. | +| IRC | Raw TCP/TLS; not proxied by managed HTTP proxy mode. Disable unless direct IRC egress is approved. | +| Other raw `net`, `tls`, or `http2` client calls | Must be classified by the raw socket guard before landing. | diff --git a/package.json b/package.json index 7bb342362c0..f784c2143b9 100644 --- a/package.json +++ b/package.json @@ -1433,9 +1433,11 @@ "lint:swift": "swiftlint lint --config config/swiftlint.yml && (cd apps/ios && swiftlint lint --config .swiftlint.yml)", "lint:tmp:channel-agnostic-boundaries": "node scripts/check-channel-agnostic-boundaries.mjs", "lint:tmp:dynamic-import-warts": "node scripts/check-dynamic-import-warts.mjs", + "lint:tmp:managed-proxy-runtime-mutation": "node scripts/check-managed-proxy-runtime-mutation.mjs", "lint:tmp:no-random-messaging": "node scripts/check-no-random-messaging-tmp.mjs", "lint:tmp:no-raw-channel-fetch": "node scripts/check-no-raw-channel-fetch.mjs", "lint:tmp:no-raw-http2-imports": "node scripts/check-no-raw-http2-imports.mjs", + "lint:tmp:raw-socket-classification": "node scripts/check-raw-socket-callsite-classification.mjs", "lint:tmp:tsgo-core-boundary": "node scripts/check-tsgo-core-boundary.mjs", "lint:ui:no-raw-window-open": "node scripts/check-no-raw-window-open.mjs", "lint:web-fetch-provider-boundaries": "node scripts/check-web-fetch-provider-boundaries.mjs", diff --git a/scripts/check-managed-proxy-runtime-mutation.mjs b/scripts/check-managed-proxy-runtime-mutation.mjs new file mode 100644 index 00000000000..48dd5318dda --- /dev/null +++ b/scripts/check-managed-proxy-runtime-mutation.mjs @@ -0,0 +1,448 @@ +#!/usr/bin/env node + +import ts from "typescript"; +import { runCallsiteGuard } from "./lib/callsite-guard.mjs"; +import { runAsScript, toLine, unwrapExpression } from "./lib/ts-guard-utils.mjs"; + +const sourceRoots = ["src", "extensions"]; + +const allowedManagedProxyRuntimeMutationCallsites = new Set([ + // Canonical managed proxy lifecycle owns process proxy env/global-agent mutation. + "src/infra/net/proxy/proxy-lifecycle.ts:114", + "src/infra/net/proxy/proxy-lifecycle.ts:117", + "src/infra/net/proxy/proxy-lifecycle.ts:119", + "src/infra/net/proxy/proxy-lifecycle.ts:120", + "src/infra/net/proxy/proxy-lifecycle.ts:121", + "src/infra/net/proxy/proxy-lifecycle.ts:123", + "src/infra/net/proxy/proxy-lifecycle.ts:131", + "src/infra/net/proxy/proxy-lifecycle.ts:133", + "src/infra/net/proxy/proxy-lifecycle.ts:146", + "src/infra/net/proxy/proxy-lifecycle.ts:147", + "src/infra/net/proxy/proxy-lifecycle.ts:148", + "src/infra/net/proxy/proxy-lifecycle.ts:178", + "src/infra/net/proxy/proxy-lifecycle.ts:180", + "src/infra/net/proxy/proxy-lifecycle.ts:199", + "src/infra/net/proxy/proxy-lifecycle.ts:200", + "src/infra/net/proxy/proxy-lifecycle.ts:201", + "src/infra/net/proxy/proxy-lifecycle.ts:312", + "src/infra/net/proxy/proxy-lifecycle.ts:313", + "src/infra/net/proxy/proxy-lifecycle.ts:314", + "src/infra/net/proxy/proxy-lifecycle.ts:315", + "src/infra/net/proxy/proxy-lifecycle.ts:316", + "src/infra/net/proxy/proxy-lifecycle.ts:317", + "src/infra/net/proxy/proxy-lifecycle.ts:318", + "src/infra/net/proxy/proxy-lifecycle.ts:319", + "src/infra/net/proxy/proxy-lifecycle.ts:329", + "src/infra/net/proxy/proxy-lifecycle.ts:330", + "src/infra/net/proxy/proxy-lifecycle.ts:331", + "src/infra/net/proxy/proxy-lifecycle.ts:332", + "src/infra/net/proxy/proxy-lifecycle.ts:333", + "src/infra/net/proxy/proxy-lifecycle.ts:334", + "src/infra/net/proxy/proxy-lifecycle.ts:335", + "src/infra/net/proxy/proxy-lifecycle.ts:336", + "src/infra/net/proxy/proxy-lifecycle.ts:369", + "src/infra/net/proxy/proxy-lifecycle.ts:376", + "src/infra/net/proxy/proxy-lifecycle.ts:484", + "src/infra/net/proxy/proxy-lifecycle.ts:507", + "src/infra/net/proxy/proxy-lifecycle.ts:508", + "src/infra/net/proxy/proxy-lifecycle.ts:515", + "src/infra/net/proxy/proxy-lifecycle.ts:516", + + // Browser CDP loopback control-plane helper leases NO_PROXY only for localhost/loopback CDP URLs. + "extensions/browser/src/browser/cdp-proxy-bypass.ts:87", + "extensions/browser/src/browser/cdp-proxy-bypass.ts:88", + "extensions/browser/src/browser/cdp-proxy-bypass.ts:120", + "extensions/browser/src/browser/cdp-proxy-bypass.ts:122", + "extensions/browser/src/browser/cdp-proxy-bypass.ts:125", + "extensions/browser/src/browser/cdp-proxy-bypass.ts:127", +]); + +const forbiddenEnvKeys = new Set([ + "HTTP_PROXY", + "HTTPS_PROXY", + "http_proxy", + "https_proxy", + "NO_PROXY", + "no_proxy", + "GLOBAL_AGENT_HTTP_PROXY", + "GLOBAL_AGENT_HTTPS_PROXY", + "GLOBAL_AGENT_NO_PROXY", + "GLOBAL_AGENT_FORCE_GLOBAL_AGENT", + "OPENCLAW_PROXY_ACTIVE", + "OPENCLAW_PROXY_LOOPBACK_MODE", +]); + +const forbiddenGlobalAgentKeys = new Set(["HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY"]); + +function stringLiteralText(node) { + return ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) ? node.text : null; +} + +function isGlobalIdentifier(node, context = { globalAliases: new Set() }) { + const unwrapped = unwrapExpression(node); + return ( + ts.isIdentifier(unwrapped) && + (unwrapped.text === "global" || + unwrapped.text === "globalThis" || + context.globalAliases.has(unwrapped.text)) + ); +} + +function processEnvExpression(expression, context = { envAliases: new Set() }) { + const unwrapped = unwrapExpression(expression); + if (ts.isIdentifier(unwrapped) && context.envAliases.has(unwrapped.text)) { + return unwrapped; + } + if (ts.isPropertyAccessExpression(unwrapped) && unwrapped.name.text === "env") { + const base = unwrapExpression(unwrapped.expression); + return ts.isIdentifier(base) && base.text === "process" ? unwrapped : null; + } + if (ts.isElementAccessExpression(unwrapped)) { + const key = stringLiteralText(unwrapExpression(unwrapped.argumentExpression)); + if (key !== "env") { + return null; + } + const base = unwrapExpression(unwrapped.expression); + return ts.isIdentifier(base) && base.text === "process" ? unwrapped : null; + } + return null; +} + +function collectStringConstants(sourceFile) { + const constants = new Map(); + const visit = (node) => { + if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) { + const literal = stringLiteralText(unwrapExpression(node.initializer)); + if (literal) { + constants.set(node.name.text, literal); + } + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return constants; +} + +function collectStringArrays(sourceFile) { + const arrays = new Map(); + for (let pass = 0; pass < 4; pass += 1) { + const visit = (node) => { + if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) { + const initializer = unwrapExpression(node.initializer); + if (ts.isArrayLiteralExpression(initializer)) { + const values = []; + let complete = true; + for (const element of initializer.elements) { + const unwrapped = unwrapExpression(element); + if ( + ts.isSpreadElement(unwrapped) && + ts.isIdentifier(unwrapExpression(unwrapped.expression)) + ) { + const nested = arrays.get(unwrapExpression(unwrapped.expression).text); + if (nested) { + values.push(...nested); + } else { + complete = false; + } + continue; + } + const literal = stringLiteralText(unwrapped); + if (literal) { + values.push(literal); + } else { + complete = false; + } + } + if (complete) { + const previous = arrays.get(node.name.text); + const sameValues = previous && previous.join("\0") === values.join("\0"); + if (!sameValues) { + arrays.set(node.name.text, values); + } + } + } + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + } + return arrays; +} + +function collectForbiddenKeyArrays(sourceFile) { + const stringArrays = collectStringArrays(sourceFile); + const arrays = new Set(); + for (const [name, values] of stringArrays) { + if (values.some((key) => forbiddenEnvKeys.has(key))) { + arrays.add(name); + } + } + return arrays; +} + +function collectForbiddenKeyVariables(sourceFile, forbiddenKeyArrays) { + const variables = new Set(); + const visit = (node) => { + if (ts.isForOfStatement(node)) { + const expression = unwrapExpression(node.expression); + if (ts.isIdentifier(expression) && forbiddenKeyArrays.has(expression.text)) { + const initializer = node.initializer; + if (ts.isVariableDeclarationList(initializer)) { + for (const declaration of initializer.declarations) { + if (ts.isIdentifier(declaration.name)) { + variables.add(declaration.name.text); + } + } + } else if (ts.isIdentifier(initializer)) { + variables.add(initializer.text); + } + } + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return variables; +} + +function envKeyExpressionIsForbidden(argumentExpression, context) { + const keyExpression = unwrapExpression(argumentExpression); + const literalKey = stringLiteralText(keyExpression); + if (literalKey) { + return forbiddenEnvKeys.has(literalKey); + } + if (!ts.isIdentifier(keyExpression)) { + return false; + } + if (context.forbiddenKeyVariables.has(keyExpression.text)) { + return true; + } + const constant = context.stringConstants.get(keyExpression.text); + return constant ? forbiddenEnvKeys.has(constant) : false; +} + +function envMutationTarget(expression, context) { + const unwrapped = unwrapExpression(expression); + if ( + ts.isPropertyAccessExpression(unwrapped) && + processEnvExpression(unwrapped.expression, context) + ) { + return forbiddenEnvKeys.has(unwrapped.name.text) ? unwrapped : null; + } + if ( + ts.isElementAccessExpression(unwrapped) && + processEnvExpression(unwrapped.expression, context) + ) { + return envKeyExpressionIsForbidden(unwrapped.argumentExpression, context) ? unwrapped : null; + } + return null; +} + +function globalAgentExpression( + expression, + context = { globalAgentAliases: new Set(), globalAliases: new Set() }, +) { + const unwrapped = unwrapExpression(expression); + if (ts.isIdentifier(unwrapped) && context.globalAgentAliases.has(unwrapped.text)) { + return unwrapped; + } + if (ts.isPropertyAccessExpression(unwrapped)) { + const receiver = unwrapExpression(unwrapped.expression); + if (unwrapped.name.text === "GLOBAL_AGENT" && isGlobalIdentifier(receiver, context)) { + return unwrapped; + } + } + if (ts.isElementAccessExpression(unwrapped)) { + const receiver = unwrapExpression(unwrapped.expression); + const key = stringLiteralText(unwrapExpression(unwrapped.argumentExpression)); + if (key === "GLOBAL_AGENT" && isGlobalIdentifier(receiver, context)) { + return unwrapped; + } + } + return null; +} + +function collectEnvAliases(sourceFile) { + const aliases = new Set(); + const emptyContext = { envAliases: new Set() }; + const visit = (node) => { + if (ts.isVariableDeclaration(node) && node.initializer) { + if (ts.isIdentifier(node.name) && processEnvExpression(node.initializer, emptyContext)) { + aliases.add(node.name.text); + } + if (ts.isObjectBindingPattern(node.name)) { + const initializer = unwrapExpression(node.initializer); + if (ts.isIdentifier(initializer) && initializer.text === "process") { + for (const element of node.name.elements) { + if (!ts.isIdentifier(element.name)) { + continue; + } + const importedName = + element.propertyName && ts.isIdentifier(element.propertyName) + ? element.propertyName.text + : element.name.text; + if (importedName === "env") { + aliases.add(element.name.text); + } + } + } + } + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return aliases; +} + +function collectGlobalAliases(sourceFile) { + const aliases = new Set(); + const visit = (node) => { + if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) { + if (isGlobalIdentifier(node.initializer, { globalAliases: new Set() })) { + aliases.add(node.name.text); + } + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return aliases; +} + +function collectGlobalAgentAliases(sourceFile, globalAliases = collectGlobalAliases(sourceFile)) { + const aliases = new Set(); + const emptyContext = { globalAgentAliases: new Set(), globalAliases }; + const visit = (node) => { + if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) { + if (globalAgentExpression(node.initializer, emptyContext)) { + aliases.add(node.name.text); + } + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return aliases; +} + +function globalAgentMutationTarget(expression, context) { + const unwrapped = unwrapExpression(expression); + if (globalAgentExpression(unwrapped, context)) { + return unwrapped; + } + if ( + ts.isPropertyAccessExpression(unwrapped) && + globalAgentExpression(unwrapped.expression, context) + ) { + return forbiddenGlobalAgentKeys.has(unwrapped.name.text) ? unwrapped : null; + } + if ( + ts.isElementAccessExpression(unwrapped) && + globalAgentExpression(unwrapped.expression, context) + ) { + const key = stringLiteralText(unwrapExpression(unwrapped.argumentExpression)); + return key && forbiddenGlobalAgentKeys.has(key) ? unwrapped : null; + } + return null; +} + +function mutationTarget(expression, context) { + return envMutationTarget(expression, context) ?? globalAgentMutationTarget(expression, context); +} + +function deleteTarget(expression, context) { + const unwrapped = unwrapExpression(expression); + return ts.isDeleteExpression(unwrapped) ? mutationTarget(unwrapped.expression, context) : null; +} + +function assignmentTarget(expression, context) { + const unwrapped = unwrapExpression(expression); + if (ts.isBinaryExpression(unwrapped) && ts.isAssignmentOperator(unwrapped.operatorToken.kind)) { + return mutationTarget(unwrapped.left, context); + } + return null; +} + +function mutatingCallTarget(expression, context) { + const unwrapped = unwrapExpression(expression); + if (!ts.isCallExpression(unwrapped)) { + return null; + } + const callee = unwrapExpression(unwrapped.expression); + if (!ts.isPropertyAccessExpression(callee)) { + return null; + } + const method = callee.name.text; + if (method !== "defineProperty" && method !== "assign") { + return null; + } + const receiver = unwrapExpression(callee.expression); + if (!ts.isIdentifier(receiver) || receiver.text !== "Object") { + return null; + } + const first = unwrapped.arguments[0] ? unwrapExpression(unwrapped.arguments[0]) : null; + if (!first) { + return null; + } + if (method === "assign") { + return globalAgentExpression(first, context) ?? processEnvExpression(first, context); + } + const rawKeyArg = unwrapped.arguments[1] ? unwrapExpression(unwrapped.arguments[1]) : null; + const literalKeyArg = rawKeyArg ? stringLiteralText(rawKeyArg) : null; + const keyArg = + literalKeyArg ?? + (rawKeyArg && ts.isIdentifier(rawKeyArg) ? context.stringConstants.get(rawKeyArg.text) : null); + if (keyArg && processEnvExpression(first, context)) { + return forbiddenEnvKeys.has(keyArg) ? first : null; + } + if (keyArg && globalAgentExpression(first, context)) { + return forbiddenGlobalAgentKeys.has(keyArg) ? first : null; + } + return null; +} + +export function findManagedProxyRuntimeMutationLines(content, fileName = "source.ts") { + const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true); + const globalAliases = collectGlobalAliases(sourceFile); + const context = { + forbiddenKeyVariables: collectForbiddenKeyVariables( + sourceFile, + collectForbiddenKeyArrays(sourceFile), + ), + globalAgentAliases: collectGlobalAgentAliases(sourceFile, globalAliases), + globalAliases, + envAliases: collectEnvAliases(sourceFile), + stringConstants: collectStringConstants(sourceFile), + }; + const lines = []; + const visit = (node) => { + const match = + assignmentTarget(node, context) ?? + deleteTarget(node, context) ?? + mutatingCallTarget(node, context); + if (match) { + lines.push(toLine(sourceFile, match)); + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return lines; +} + +export async function main() { + await runCallsiteGuard({ + importMetaUrl: import.meta.url, + sourceRoots, + extraTestSuffixes: [ + ".browser.test.ts", + ".node.test.ts", + ".live.test.ts", + ".e2e.test.ts", + ".integration.test.ts", + ], + findCallLines: findManagedProxyRuntimeMutationLines, + allowCallsite: (callsite) => allowedManagedProxyRuntimeMutationCallsites.has(callsite), + header: "Found unmanaged managed-proxy runtime mutation:", + footer: + "Only proxy lifecycle code may mutate GLOBAL_AGENT or proxy-related process.env runtime state.", + }); +} + +runAsScript(import.meta.url, main); diff --git a/scripts/check-raw-socket-callsite-classification.mjs b/scripts/check-raw-socket-callsite-classification.mjs new file mode 100644 index 00000000000..3d240bede17 --- /dev/null +++ b/scripts/check-raw-socket-callsite-classification.mjs @@ -0,0 +1,370 @@ +#!/usr/bin/env node + +import ts from "typescript"; +import { bundledPluginCallsite } from "./lib/bundled-plugin-paths.mjs"; +import { runCallsiteGuard } from "./lib/callsite-guard.mjs"; +import { + collectCallExpressionLines, + runAsScript, + unwrapExpression, +} from "./lib/ts-guard-utils.mjs"; + +const sourceRoots = ["src", "extensions"]; + +// Managed-proxy raw-socket classification allowlist. +// Each entry is intentionally a concrete callsite so new raw socket egress fails until reviewed. +const allowedRawSocketCallsites = new Set([ + // Local Gateway run loop readiness probe. + "src/cli/gateway-cli/run-loop.ts:46", + + // Local loopback readiness probe for SSH tunnels. + "src/infra/ssh-tunnel.ts:80", + + // Local gateway lock probe. + "src/infra/gateway-lock.ts:147", + + // Local Unix-domain socket IPC client. + "src/infra/jsonl-socket.ts:35", + + // Managed HTTP CONNECT tunnel helper used by APNs and proxy validation. + "src/infra/net/http-connect-tunnel.ts:117", + "src/infra/net/http-connect-tunnel.ts:123", + "src/infra/net/http-connect-tunnel.ts:268", + + // APNs HTTP/2 wrapper: direct only when managed proxy is inactive; tunneled when active. + "src/infra/push-apns-http2.ts:74", + "src/infra/push-apns-http2.ts:85", + + // Debug proxy CONNECT internals. PR #77010 guards this path while managed proxy mode is active. + "src/proxy-capture/proxy-server.ts:266", + + // QA-lab tunnel/capture helpers used for local lab diagnostics. + bundledPluginCallsite("qa-lab", "src/lab-server-capture.ts", 99), + bundledPluginCallsite("qa-lab", "src/lab-server-ui.ts", 207), + bundledPluginCallsite("qa-lab", "src/lab-server-ui.ts", 212), + + // IRC is a raw TCP/TLS channel and is documented as outside managed HTTP proxy coverage. + bundledPluginCallsite("irc", "src/client.ts", 124), + bundledPluginCallsite("irc", "src/client.ts", 129), +]); + +function stringLiteralText(node) { + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) { + return node.text; + } + return undefined; +} + +const rawModuleSpecifiers = new Map([ + ["node:net", "net"], + ["net", "net"], + ["node:tls", "tls"], + ["tls", "tls"], + ["node:http2", "http2"], + ["http2", "http2"], +]); + +function unwrapInitializer(expression) { + const unwrapped = unwrapExpression(expression); + if (ts.isAwaitExpression(unwrapped)) { + return unwrapExpression(unwrapped.expression); + } + return unwrapped; +} + +function rawMemberAliasKind(initializer, aliases) { + const unwrapped = unwrapExpression(initializer); + let receiverExpression; + let member; + if (ts.isPropertyAccessExpression(unwrapped)) { + receiverExpression = unwrapped.expression; + member = unwrapped.name.text; + } else if (ts.isElementAccessExpression(unwrapped)) { + receiverExpression = unwrapped.expression; + member = stringLiteralText(unwrapExpression(unwrapped.argumentExpression)); + } else { + return undefined; + } + if (!member) { + return undefined; + } + const receiverKind = aliasKind(receiverExpression, aliases); + if ( + (receiverKind === "net" || receiverKind === "tls") && + (member === "connect" || member === "createConnection") + ) { + return `${receiverKind}.${member}`; + } + if (receiverKind === "net" && member === "Socket") { + return "net.Socket"; + } + if (receiverKind === "http2" && member === "connect") { + return "http2.connect"; + } + return undefined; +} + +function bindRawModuleDestructureAliases(bindingName, moduleKind, aliases) { + if (!ts.isObjectBindingPattern(bindingName)) { + return; + } + for (const element of bindingName.elements) { + if (!ts.isIdentifier(element.name)) { + continue; + } + const importedName = + element.propertyName && ts.isIdentifier(element.propertyName) + ? element.propertyName.text + : element.name.text; + if (importedName === "default") { + aliases.set(element.name.text, moduleKind); + continue; + } + if ( + importedName === "connect" || + importedName === "createConnection" || + importedName === "Socket" + ) { + aliases.set(element.name.text, `${moduleKind}.${importedName}`); + } + } +} + +function collectSocketInstanceAliases(sourceFile, rawAliases) { + const socketAliases = new Set(); + const visit = (node) => { + if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) { + if (isSocketConstructorExpression(node.initializer, rawAliases)) { + socketAliases.add(node.name.text); + } + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return socketAliases; +} + +function collectRawModuleAliases(sourceFile) { + const aliases = new Map(); + const visit = (node) => { + if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) { + const moduleKind = rawModuleSpecifiers.get(node.moduleSpecifier.text); + const clause = node.importClause; + if (moduleKind && clause) { + if (clause.name) { + aliases.set(clause.name.text, moduleKind); + } + if (clause.namedBindings && ts.isNamespaceImport(clause.namedBindings)) { + aliases.set(clause.namedBindings.name.text, moduleKind); + } + if (clause.namedBindings && ts.isNamedImports(clause.namedBindings)) { + for (const specifier of clause.namedBindings.elements) { + const importedName = (specifier.propertyName ?? specifier.name).text; + if (importedName === "default") { + aliases.set(specifier.name.text, moduleKind); + continue; + } + if ( + importedName === "connect" || + importedName === "createConnection" || + importedName === "Socket" + ) { + aliases.set(specifier.name.text, `${moduleKind}.${importedName}`); + } + } + } + } + } + if (ts.isVariableDeclaration(node) && node.initializer) { + const initializer = unwrapInitializer(node.initializer); + if (ts.isIdentifier(node.name)) { + const moduleKind = aliasKind(initializer, aliases); + if (moduleKind === "net" || moduleKind === "tls" || moduleKind === "http2") { + aliases.set(node.name.text, moduleKind); + } + const memberKind = rawMemberAliasKind(initializer, aliases); + if (memberKind) { + aliases.set(node.name.text, memberKind); + } + } + if ( + ts.isCallExpression(initializer) && + ts.isIdentifier(unwrapExpression(initializer.expression)) && + unwrapExpression(initializer.expression).text === "require" && + initializer.arguments.length === 1 && + ts.isStringLiteral(initializer.arguments[0]) + ) { + const moduleKind = rawModuleSpecifiers.get(initializer.arguments[0].text); + if (moduleKind) { + if (ts.isIdentifier(node.name)) { + aliases.set(node.name.text, moduleKind); + } else { + bindRawModuleDestructureAliases(node.name, moduleKind, aliases); + } + } + } + if (ts.isObjectBindingPattern(node.name) && ts.isIdentifier(initializer)) { + const moduleKind = aliases.get(initializer.text); + if (moduleKind === "net" || moduleKind === "tls" || moduleKind === "http2") { + bindRawModuleDestructureAliases(node.name, moduleKind, aliases); + } + } + if ( + ts.isCallExpression(initializer) && + initializer.expression.kind === ts.SyntaxKind.ImportKeyword && + initializer.arguments.length === 1 && + ts.isStringLiteral(initializer.arguments[0]) + ) { + const moduleKind = rawModuleSpecifiers.get(initializer.arguments[0].text); + if (moduleKind) { + if (ts.isIdentifier(node.name)) { + aliases.set(node.name.text, moduleKind); + } else { + bindRawModuleDestructureAliases(node.name, moduleKind, aliases); + } + } + } + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return aliases; +} + +function rawModuleKindFromExpression(expression) { + const unwrappedExpression = unwrapExpression(expression); + const unwrapped = ts.isAwaitExpression(unwrappedExpression) + ? unwrapExpression(unwrappedExpression.expression) + : unwrappedExpression; + if ( + ts.isCallExpression(unwrapped) && + ts.isIdentifier(unwrapped.expression) && + unwrapped.expression.text === "require" && + unwrapped.arguments.length === 1 + ) { + const moduleName = stringLiteralText(unwrapExpression(unwrapped.arguments[0])); + return moduleName ? rawModuleSpecifiers.get(moduleName) : undefined; + } + if ( + ts.isCallExpression(unwrapped) && + unwrapped.expression.kind === ts.SyntaxKind.ImportKeyword && + unwrapped.arguments.length === 1 + ) { + const moduleName = stringLiteralText(unwrapExpression(unwrapped.arguments[0])); + return moduleName ? rawModuleSpecifiers.get(moduleName) : undefined; + } + return undefined; +} + +function aliasKind(expression, aliases) { + const receiver = unwrapExpression(expression); + if (ts.isIdentifier(receiver)) { + return aliases.get(receiver.text); + } + if (ts.isPropertyAccessExpression(receiver) && receiver.name.text === "default") { + return rawModuleKindFromExpression(receiver.expression); + } + return rawModuleKindFromExpression(receiver); +} + +function isRawModuleAlias(expression, aliases, expectedKinds) { + const kind = aliasKind(expression, aliases); + return expectedKinds.has(kind); +} + +function isSocketConstructorExpression(expression, aliases) { + const unwrapped = unwrapExpression(expression); + if (!ts.isNewExpression(unwrapped)) { + return false; + } + const callee = unwrapExpression(unwrapped.expression); + if (aliasKind(callee, aliases) === "net.Socket") { + return true; + } + if (!ts.isPropertyAccessExpression(callee) || callee.name.text !== "Socket") { + return false; + } + return isRawModuleAlias(callee.expression, aliases, new Set(["net"])); +} + +function rawSocketCallee(expression, aliases, socketAliases = new Set()) { + const callee = unwrapExpression(expression); + if (ts.isIdentifier(callee)) { + const kind = aliasKind(callee, aliases); + return kind === "net.connect" || + kind === "tls.connect" || + kind === "http2.connect" || + kind === "net.createConnection" || + kind === "tls.createConnection" + ? callee + : null; + } + let receiverExpression; + let member; + if (ts.isPropertyAccessExpression(callee)) { + receiverExpression = callee.expression; + member = callee.name.text; + } else if (ts.isElementAccessExpression(callee)) { + receiverExpression = callee.expression; + member = stringLiteralText(unwrapExpression(callee.argumentExpression)); + } else { + return null; + } + if (!member) { + return null; + } + if ( + member === "connect" && + isRawModuleAlias(receiverExpression, aliases, new Set(["net", "tls", "http2"])) + ) { + return callee; + } + if ( + member === "createConnection" && + isRawModuleAlias(receiverExpression, aliases, new Set(["net", "tls"])) + ) { + return callee; + } + if (member === "connect" && isSocketConstructorExpression(receiverExpression, aliases)) { + return callee; + } + if ( + member === "connect" && + ts.isIdentifier(unwrapExpression(receiverExpression)) && + socketAliases.has(unwrapExpression(receiverExpression).text) + ) { + return callee; + } + return null; +} + +export function findRawSocketClientCallLines(content, fileName = "source.ts") { + const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true); + const aliases = collectRawModuleAliases(sourceFile); + const socketAliases = collectSocketInstanceAliases(sourceFile, aliases); + return collectCallExpressionLines(ts, sourceFile, (node) => + rawSocketCallee(node.expression, aliases, socketAliases), + ); +} + +export async function main() { + await runCallsiteGuard({ + importMetaUrl: import.meta.url, + sourceRoots, + extraTestSuffixes: [ + ".browser.test.ts", + ".node.test.ts", + ".live.test.ts", + ".e2e.test.ts", + ".integration.test.ts", + ], + findCallLines: findRawSocketClientCallLines, + skipRelativePath: (relPath) => relPath.includes("/test-support/"), + allowCallsite: (callsite) => allowedRawSocketCallsites.has(callsite), + header: "Found unclassified raw socket client calls:", + footer: + "Classify raw net/tls/http2 egress as managed/proxied, local-only, diagnostic guarded, or documented unsupported before adding callsites.", + }); +} + +runAsScript(import.meta.url, main); diff --git a/scripts/run-additional-boundary-checks.mjs b/scripts/run-additional-boundary-checks.mjs index b40d9a7fb99..d9264b69b0a 100644 --- a/scripts/run-additional-boundary-checks.mjs +++ b/scripts/run-additional-boundary-checks.mjs @@ -10,6 +10,12 @@ export const BOUNDARY_CHECKS = [ ["lint:tmp:tsgo-core-boundary", "pnpm", ["run", "lint:tmp:tsgo-core-boundary"]], ["lint:tmp:no-raw-channel-fetch", "pnpm", ["run", "lint:tmp:no-raw-channel-fetch"]], ["lint:tmp:no-raw-http2-imports", "pnpm", ["run", "lint:tmp:no-raw-http2-imports"]], + [ + "lint:tmp:managed-proxy-runtime-mutation", + "pnpm", + ["run", "lint:tmp:managed-proxy-runtime-mutation"], + ], + ["lint:tmp:raw-socket-classification", "pnpm", ["run", "lint:tmp:raw-socket-classification"]], ["lint:agent:ingress-owner", "pnpm", ["run", "lint:agent:ingress-owner"]], [ "lint:plugins:no-register-http-handler", diff --git a/test/scripts/check-managed-proxy-runtime-mutation.test.ts b/test/scripts/check-managed-proxy-runtime-mutation.test.ts new file mode 100644 index 00000000000..d64af060f69 --- /dev/null +++ b/test/scripts/check-managed-proxy-runtime-mutation.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from "vitest"; +import { findManagedProxyRuntimeMutationLines } from "../../scripts/check-managed-proxy-runtime-mutation.mjs"; + +describe("check-managed-proxy-runtime-mutation", () => { + it("finds assignments and deletes for proxy env vars", () => { + const source = ` + process.env.HTTP_PROXY = "http://proxy"; + process.env["HTTPS_PROXY"] = "http://proxy"; + delete process.env.NO_PROXY; + delete process.env["GLOBAL_AGENT_NO_PROXY"]; + `; + + expect(findManagedProxyRuntimeMutationLines(source)).toEqual([2, 3, 4, 5]); + }); + + it("finds global object alias GLOBAL_AGENT mutations", () => { + const source = ` + const globalRecord = global; + const agent = globalRecord.GLOBAL_AGENT; + globalRecord.GLOBAL_AGENT = {}; + globalRecord["GLOBAL_AGENT"] = {}; + delete globalRecord.GLOBAL_AGENT; + delete globalRecord["GLOBAL_AGENT"]; + agent.HTTP_PROXY = "http://proxy"; + `; + + expect(findManagedProxyRuntimeMutationLines(source)).toEqual([4, 5, 6, 7, 8]); + }); + + it("finds GLOBAL_AGENT mutations", () => { + const source = ` + global.GLOBAL_AGENT = {}; + global.GLOBAL_AGENT.NO_PROXY = "localhost"; + global["GLOBAL_AGENT"].HTTP_PROXY = "http://proxy"; + delete global.GLOBAL_AGENT.HTTPS_PROXY; + `; + + expect(findManagedProxyRuntimeMutationLines(source)).toEqual([2, 3, 4, 5]); + }); + + it("finds Object.assign and Object.defineProperty mutations", () => { + const source = ` + Object.assign(global.GLOBAL_AGENT, { NO_PROXY: "localhost" }); + Object.assign(process.env, { NO_PROXY: "localhost" }); + Object.defineProperty(process.env, "HTTP_PROXY", { value: "http://proxy" }); + `; + + expect(findManagedProxyRuntimeMutationLines(source)).toEqual([2, 3, 4]); + }); + + it("finds missing managed-proxy env key mutations", () => { + const source = ` + process.env.GLOBAL_AGENT_FORCE_GLOBAL_AGENT = "true"; + process.env.OPENCLAW_PROXY_LOOPBACK_MODE = "gateway-only"; + `; + + expect(findManagedProxyRuntimeMutationLines(source)).toEqual([2, 3]); + }); + + it("finds defineProperty mutations with constant proxy keys", () => { + const source = ` + const proxyKey = "HTTP_PROXY"; + const agentKey = "NO_PROXY"; + Object.defineProperty(process.env, proxyKey, { value: "http://proxy" }); + Object.defineProperty(global.GLOBAL_AGENT, agentKey, { value: "localhost" }); + `; + + expect(findManagedProxyRuntimeMutationLines(source)).toEqual([4, 5]); + }); + + it("finds destructured process.env alias mutations", () => { + const source = ` + const { env } = process; + env.HTTP_PROXY = "http://proxy"; + env["NO_PROXY"] = "localhost"; + `; + + expect(findManagedProxyRuntimeMutationLines(source)).toEqual([3, 4]); + }); + + it("finds process.env alias and constant key mutations", () => { + const source = ` + const env = process.env; + const proxyKey = "HTTP_PROXY"; + env.HTTPS_PROXY = "http://proxy"; + env[proxyKey] = "http://proxy"; + delete env.NO_PROXY; + Object.assign(env, { GLOBAL_AGENT_HTTP_PROXY: "http://proxy" }); + Object.defineProperty(env, "OPENCLAW_PROXY_ACTIVE", { value: "1" }); + `; + + expect(findManagedProxyRuntimeMutationLines(source)).toEqual([4, 5, 6, 7, 8]); + }); + + it("finds dynamic process.env key mutations from forbidden key arrays", () => { + const source = ` + const proxyKeys = ["HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY"]; + for (const key of proxyKeys) { + process.env[key] = "http://proxy"; + } + for (const key of proxyKeys) { + delete process.env[key]; + } + `; + + expect(findManagedProxyRuntimeMutationLines(source)).toEqual([4, 7]); + }); + + it("finds dynamic process.env key mutations from spread-built forbidden key arrays", () => { + const source = ` + const lower = ["http_proxy", "https_proxy"]; + const upper = ["HTTP_PROXY", "HTTPS_PROXY"]; + const all = [...lower, ...upper, "OPENCLAW_PROXY_LOOPBACK_MODE"]; + for (const key of all) { + delete process.env[key]; + } + `; + + expect(findManagedProxyRuntimeMutationLines(source)).toEqual([6]); + }); + + it("ignores dynamic process.env key mutations from unrelated key arrays", () => { + const source = ` + const normalKeys = ["PATH", "HOME"]; + for (const key of normalKeys) { + process.env[key] = "value"; + } + `; + + expect(findManagedProxyRuntimeMutationLines(source)).toEqual([]); + }); + + it("finds GLOBAL_AGENT alias mutations", () => { + const source = ` + const agent = global.GLOBAL_AGENT; + agent.HTTP_PROXY = "http://proxy"; + agent["NO_PROXY"] = "localhost"; + delete agent.HTTPS_PROXY; + `; + + expect(findManagedProxyRuntimeMutationLines(source)).toEqual([3, 4, 5]); + }); + + it("finds globalThis.GLOBAL_AGENT mutations alongside global.GLOBAL_AGENT", () => { + const source = ` + globalThis.GLOBAL_AGENT = {}; + globalThis.GLOBAL_AGENT.NO_PROXY = "localhost"; + globalThis["GLOBAL_AGENT"].HTTP_PROXY = "http://proxy"; + delete globalThis.GLOBAL_AGENT.HTTPS_PROXY; + `; + + expect(findManagedProxyRuntimeMutationLines(source)).toEqual([2, 3, 4, 5]); + }); + + it('finds process["env"] mixed access mutations', () => { + const source = ` + process["env"].HTTP_PROXY = "http://proxy"; + process["env"]["HTTPS_PROXY"] = "http://proxy"; + delete process["env"].NO_PROXY; + `; + + expect(findManagedProxyRuntimeMutationLines(source)).toEqual([2, 3, 4]); + }); + + it("does not flag Object.assign on a non-process .env namespace", () => { + const source = ` + Object.assign(config.env, { NO_PROXY: "localhost" }); + Object.defineProperty(config.env, "HTTP_PROXY", { value: "http://proxy" }); + `; + + expect(findManagedProxyRuntimeMutationLines(source)).toEqual([]); + }); + + it("ignores reads, unrelated env vars, comments, and strings", () => { + const source = ` + const current = process.env.HTTP_PROXY; + process.env.PATH = "/usr/bin"; + const text = "process.env.NO_PROXY = '*'"; + // global.GLOBAL_AGENT.NO_PROXY = '*'; + `; + + expect(findManagedProxyRuntimeMutationLines(source)).toEqual([]); + }); +}); diff --git a/test/scripts/check-raw-socket-callsite-classification.test.ts b/test/scripts/check-raw-socket-callsite-classification.test.ts new file mode 100644 index 00000000000..e4c29d2287f --- /dev/null +++ b/test/scripts/check-raw-socket-callsite-classification.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it } from "vitest"; +import { findRawSocketClientCallLines } from "../../scripts/check-raw-socket-callsite-classification.mjs"; + +describe("check-raw-socket-callsite-classification", () => { + it("finds raw net, tls, and http2 client calls", () => { + const source = ` + import net from "node:net"; + import * as tls from "node:tls"; + import http2 from "node:http2"; + net.connect({ host: "example.com", port: 6667 }); + tls.connect({ host: "example.com", port: 6697 }); + http2.connect("https://api.example.com"); + `; + + expect(findRawSocketClientCallLines(source)).toEqual([5, 6, 7]); + }); + + it("ignores comments, strings, and unrelated connect methods", () => { + const source = ` + // net.connect({ host: "example.com" }); + const text = "tls.connect({ host: 'example.com' })"; + client.connect(transport); + websocket.connect(); + `; + + expect(findRawSocketClientCallLines(source)).toEqual([]); + }); + + it("handles aliased imports, requires, and dynamic literal imports", () => { + const source = ` + import * as rawNet from "node:net"; + const rawTls = require("node:tls"); + const rawHttp2 = await import("node:http2"); + rawNet.connect({ host: "127.0.0.1", port: 1 }); + rawTls.connect({ host: "127.0.0.1", port: 1 }); + rawHttp2.connect("https://example.com"); + `; + + expect(findRawSocketClientCallLines(source)).toEqual([5, 6, 7]); + }); + + it("finds element-access raw socket calls", () => { + const source = ` + import net from "node:net"; + import tls from "node:tls"; + import http2 from "node:http2"; + net["connect"]({ host: "127.0.0.1", port: 1 }); + tls["createConnection"]({ host: "127.0.0.1", port: 1 }); + http2["connect"]("https://example.com"); + `; + + expect(findRawSocketClientCallLines(source)).toEqual([5, 6, 7]); + }); + + it("finds destructured dynamic-import default raw module aliases", () => { + const source = ` + const { default: rawNet } = await import("node:net"); + const { default: rawTls } = await import("node:tls"); + const { default: rawHttp2 } = await import("node:http2"); + rawNet.connect({ host: "127.0.0.1", port: 1 }); + rawTls.connect({ host: "127.0.0.1", port: 1 }); + rawHttp2.connect("https://example.com"); + `; + + expect(findRawSocketClientCallLines(source)).toEqual([5, 6, 7]); + }); + + it("finds direct raw module receiver calls", () => { + const source = ` + require("node:net").connect({ host: "127.0.0.1", port: 1 }); + require("node:tls").createConnection({ host: "127.0.0.1", port: 1 }); + (await import("node:http2")).connect("https://example.com"); + (await import("node:net")).default.connect({ host: "127.0.0.1", port: 1 }); + (await import("node:tls")).default.createConnection({ host: "127.0.0.1", port: 1 }); + (await import("node:http2")).default.connect("https://example.com"); + `; + + expect(findRawSocketClientCallLines(source)).toEqual([2, 3, 4, 5, 6, 7]); + }); + + it("finds named default raw module imports", () => { + const source = ` + import { default as rawNet } from "node:net"; + import { default as rawTls } from "node:tls"; + import { default as rawHttp2 } from "node:http2"; + rawNet.connect({ host: "127.0.0.1", port: 1 }); + rawTls.connect({ host: "127.0.0.1", port: 1 }); + rawHttp2.connect("https://example.com"); + `; + + expect(findRawSocketClientCallLines(source)).toEqual([5, 6, 7]); + }); + + it("finds raw socket module object aliases", () => { + const source = ` + import net from "node:net"; + import tls from "node:tls"; + import http2 from "node:http2"; + const rawNet = net; + const rawTls = tls; + const rawHttp2 = http2; + rawNet.connect({ host: "127.0.0.1", port: 1 }); + rawTls.connect({ host: "127.0.0.1", port: 1 }); + rawHttp2.connect("https://example.com"); + `; + + expect(findRawSocketClientCallLines(source)).toEqual([8, 9, 10]); + }); + + it("finds aliases to raw socket module members", () => { + const source = ` + import net from "node:net"; + import tls from "node:tls"; + import http2 from "node:http2"; + const netConnect = net.connect; + const tlsConnect = tls.connect; + const h2Connect = http2.connect; + const Socket = net.Socket; + const { createConnection } = net; + netConnect({ host: "127.0.0.1", port: 1 }); + tlsConnect({ host: "127.0.0.1", port: 1 }); + h2Connect("https://example.com"); + createConnection({ host: "127.0.0.1", port: 1 }); + new Socket().connect("/tmp/socket"); + `; + + expect(findRawSocketClientCallLines(source)).toEqual([10, 11, 12, 13, 14]); + }); + + it("finds destructured require and dynamic import raw socket bindings", () => { + const source = ` + const { connect, createConnection, Socket } = require("node:net"); + const { connect: tlsConnect } = await import("node:tls"); + connect({ host: "127.0.0.1", port: 1 }); + createConnection({ host: "127.0.0.1", port: 1 }); + tlsConnect({ host: "127.0.0.1", port: 1 }); + new Socket().connect("/tmp/socket"); + `; + + expect(findRawSocketClientCallLines(source)).toEqual([4, 5, 6, 7]); + }); + + it("finds stored Socket instance connect calls", () => { + const source = ` + import net from "node:net"; + const client = new net.Socket(); + client.connect("/tmp/socket"); + `; + + expect(findRawSocketClientCallLines(source)).toEqual([4]); + }); + + it("finds named raw socket imports", () => { + const source = ` + import { connect as netConnect, createConnection, Socket } from "node:net"; + import { connect as tlsConnect } from "node:tls"; + import { connect as http2Connect } from "node:http2"; + netConnect({ host: "127.0.0.1", port: 1 }); + createConnection({ host: "127.0.0.1", port: 1 }); + tlsConnect({ host: "127.0.0.1", port: 1 }); + http2Connect("https://example.com"); + new Socket().connect("/tmp/socket"); + `; + + expect(findRawSocketClientCallLines(source)).toEqual([5, 6, 7, 8, 9]); + }); + + it("finds createConnection and constructed Socket.connect client calls", () => { + const source = ` + import net from "node:net"; + import tls from "node:tls"; + net.createConnection({ host: "127.0.0.1", port: 1 }); + tls.createConnection({ host: "127.0.0.1", port: 1 }); + new net.Socket().connect("/tmp/socket"); + `; + + expect(findRawSocketClientCallLines(source)).toEqual([4, 5, 6]); + }); + + it("handles parenthesized and asserted module identifiers", () => { + const source = ` + import net from "node:net"; + import tls from "node:tls"; + import http2 from "node:http2"; + (net as typeof import("node:net")).connect({ host: "127.0.0.1", port: 1 }); + (tls as typeof import("node:tls")).connect({ host: "127.0.0.1", port: 1 }); + (http2 as typeof import("node:http2")).connect("https://example.com"); + `; + + expect(findRawSocketClientCallLines(source)).toEqual([5, 6, 7]); + }); +}); diff --git a/test/scripts/run-additional-boundary-checks.test.ts b/test/scripts/run-additional-boundary-checks.test.ts index 1a5e9f099a7..a64cc411d64 100644 --- a/test/scripts/run-additional-boundary-checks.test.ts +++ b/test/scripts/run-additional-boundary-checks.test.ts @@ -30,6 +30,22 @@ describe("run-additional-boundary-checks", () => { }); }); + it("runs managed proxy runtime mutation guard in CI", () => { + expect(BOUNDARY_CHECKS).toContainEqual({ + label: "lint:tmp:managed-proxy-runtime-mutation", + command: "pnpm", + args: ["run", "lint:tmp:managed-proxy-runtime-mutation"], + }); + }); + + it("runs raw socket classification guard in CI", () => { + expect(BOUNDARY_CHECKS).toContainEqual({ + label: "lint:tmp:raw-socket-classification", + command: "pnpm", + args: ["run", "lint:tmp:raw-socket-classification"], + }); + }); + it("normalizes concurrency input", () => { expect(resolveConcurrency("6")).toBe(6); expect(resolveConcurrency("0")).toBe(4);