From b6e2930f9aedc744ea7b000cf34f41826d1b0b8a Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Fri, 24 Jul 2026 12:18:50 -0700 Subject: [PATCH 1/3] fix(security): upgrade bundled npm dependencies --- .github/workflows/base-image.yaml | 1 + Dockerfile.base | 14 +- agents/hermes/Dockerfile.base | 11 +- .../langchain-deepagents-code/Dockerfile.base | 6 + scripts/upgrade-bundled-npm.mts | 281 ++++++++++++++++++ .../source-identity.test.ts | 1 + src/lib/sandbox-base-image/source-identity.ts | 1 + src/lib/sandbox/build-context.ts | 4 + test/node-tar-dockerfile-contract.test.ts | 33 ++ test/sandbox-build-context.test.ts | 2 + test/upgrade-bundled-npm.test.ts | 161 ++++++++++ 11 files changed, 510 insertions(+), 5 deletions(-) create mode 100755 scripts/upgrade-bundled-npm.mts create mode 100644 test/upgrade-bundled-npm.test.ts diff --git a/.github/workflows/base-image.yaml b/.github/workflows/base-image.yaml index 2fb1d00431f..61086b0a43e 100644 --- a/.github/workflows/base-image.yaml +++ b/.github/workflows/base-image.yaml @@ -37,6 +37,7 @@ on: - "scripts/lib/reviewed-npm-archive.mts" - "scripts/checks/node-tar-image-scan.mts" - "scripts/patch-bundled-npm-tar.mts" + - "scripts/upgrade-bundled-npm.mts" - "scripts/lib/sandbox-rlimits.sh" workflow_dispatch: inputs: diff --git a/Dockerfile.base b/Dockerfile.base index 00a74785430..d5801030b6f 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -19,6 +19,7 @@ # docker-pin-check.yaml # apt packages — pinned to exact Debian trixie versions # gosu 1.19 — pinned release + per-arch sha256 checksum +# npm 11.18.0 — reviewed archive + sha512 integrity # gateway/sandbox — OS users and groups; names and UIDs are a # users stable contract with OpenShell # .openclaw dirs — directory structure is dictated by the OpenClaw @@ -41,8 +42,9 @@ # 3. gosu upgrade — update URL, checksum, and version # 4. node:22-trixie-slim digest rotated — update-docker-pin.sh updates all # Dockerfile and Dockerfile.base -# 5. New .openclaw subdirectory — add mkdir below -# 6. PyYAML or other pip dep bump — change the version below +# 5. npm upgrade — update upgrade-bundled-npm.mts +# 6. New .openclaw subdirectory — add mkdir below +# 7. PyYAML or other pip dep bump — change the version below # For ad-hoc rebuilds (e.g., security patch), use workflow_dispatch on # the base-image workflow. # @@ -232,13 +234,19 @@ COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts COPY scripts/lib/reviewed-npm-audit.mts /scripts/lib/reviewed-npm-audit.mts COPY scripts/lib/openclaw-npm-remediation.mts /scripts/lib/openclaw-npm-remediation.mts COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts +COPY scripts/upgrade-bundled-npm.mts /scripts/upgrade-bundled-npm.mts # npm 10.9.8 in the pinned Node 22 image bundles an affected node-tar copy. # Replace only that private package from a registry- and SRI-verified archive -# before npm processes any OpenClaw or mcporter installation input. +# before npm processes the reviewed npm archive. RUN node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts \ --npm-root /usr/local/lib/node_modules/npm +# Upgrade the complete private npm tree so its sigstore, brace-expansion, and +# picomatch packages meet the reviewed security floors. +RUN node --experimental-strip-types /scripts/upgrade-bundled-npm.mts \ + --npm-root /usr/local/lib/node_modules/npm + # Keep OpenClaw's jiti-generated source cache out of /tmp so provider marker # names do not persist in runtime snapshots or leak-scan inputs. ENV JITI_FS_CACHE=false diff --git a/agents/hermes/Dockerfile.base b/agents/hermes/Dockerfile.base index dedccea37c0..d0c414e6faf 100644 --- a/agents/hermes/Dockerfile.base +++ b/agents/hermes/Dockerfile.base @@ -15,7 +15,8 @@ # 2. New apt package needed — add it to the apt-get install list # 3. gosu upgrade — update URL, checksum, and version # 4. node:24-trixie-slim digest rot — update-docker-pin.sh updates all -# 5. New .hermes subdirectory — add mkdir/chmod below +# 5. npm upgrade — update upgrade-bundled-npm.mts +# 6. New .hermes subdirectory — add mkdir/chmod below # ──────────────────────────────────────────────────────────────── FROM node:24-trixie-slim@sha256:05c08ce4291e9a58f59456a7985176defb12cdd42271f35ff81a3e167ea61d4c @@ -63,13 +64,19 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts +COPY scripts/upgrade-bundled-npm.mts /scripts/upgrade-bundled-npm.mts # npm 11.13.0 in the pinned Node 24 image bundles an affected node-tar copy. # Patch that private package after curl is installed and before npm processes -# the checksum-pinned Hermes release lockfiles. +# the reviewed npm archive. RUN node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts \ --npm-root /usr/local/lib/node_modules/npm +# Upgrade the complete private npm tree before npm processes the +# checksum-pinned Hermes release lockfiles. +RUN node --experimental-strip-types /scripts/upgrade-bundled-npm.mts \ + --npm-root /usr/local/lib/node_modules/npm + # gosu for privilege separation (gateway vs sandbox user). # Identical to OpenClaw base — pinned to 1.19 with checksum. # hadolint ignore=DL4006 diff --git a/agents/langchain-deepagents-code/Dockerfile.base b/agents/langchain-deepagents-code/Dockerfile.base index 2b769a5b09c..de75f7c274b 100644 --- a/agents/langchain-deepagents-code/Dockerfile.base +++ b/agents/langchain-deepagents-code/Dockerfile.base @@ -11,6 +11,7 @@ FROM node:22-trixie-slim@sha256:e6d9a389d34ff9678438af985c9913fbd1eb6ed36e80fea5 COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts +COPY scripts/upgrade-bundled-npm.mts /scripts/upgrade-bundled-npm.mts ENV DEBIAN_FRONTEND=noninteractive \ VIRTUAL_ENV=/opt/venv \ @@ -41,6 +42,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ RUN node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts \ --npm-root /usr/local/lib/node_modules/npm +# Replace the complete private npm tree so managed-terminal npm commands use +# the reviewed dependency set. +RUN node --experimental-strip-types /scripts/upgrade-bundled-npm.mts \ + --npm-root /usr/local/lib/node_modules/npm + RUN groupadd -r sandbox \ && useradd -r -g sandbox -d /sandbox -s /bin/bash sandbox \ && usermod -a -G sandbox root \ diff --git a/scripts/upgrade-bundled-npm.mts b/scripts/upgrade-bundled-npm.mts new file mode 100755 index 00000000000..32e99599af4 --- /dev/null +++ b/scripts/upgrade-bundled-npm.mts @@ -0,0 +1,281 @@ +#!/usr/bin/env -S node --experimental-strip-types + +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + closeSync, + constants, + fstatSync, + lstatSync, + mkdtempSync, + openSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const REVIEWED_NPM_VERSION = "11.18.0"; +export const REVIEWED_NPM_INTEGRITY = + "sha512-T67M4L5wNm0cZ7EBLErcEkY1SmzEW/WJ+SADBzsFUY1UdAPfFHXFQtZ6SEXiK0+vzXysCvAsepbMaBTwnrAD+w=="; +export const REVIEWED_NPM_TARBALL = "https://registry.npmjs.org/npm/-/npm-11.18.0.tgz"; + +export const REVIEWED_NPM_PACKAGES = { + "brace-expansion": "5.0.7", + picomatch: "4.0.4", + sigstore: "4.1.1", + tar: "7.5.19", +} as const; + +const REPLACEABLE_NPM_VERSIONS = new Set(["10.9.8", "11.13.0"]); + +type JsonRecord = Record; + +function record(value: unknown, label: string): JsonRecord { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${label} must be a JSON object`); + } + return value as JsonRecord; +} + +function readJson(file: string, label: string): JsonRecord { + const descriptor = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + if (!fstatSync(descriptor).isFile()) throw new Error(`${label} must be a real file: ${file}`); + return record(JSON.parse(readFileSync(descriptor, "utf8")), label); + } catch (error) { + throw new Error(`${label} is invalid: ${String(error)}`); + } finally { + closeSync(descriptor); + } +} + +function realDirectory(directory: string, label: string): string { + const resolved = resolve(directory); + const metadata = lstatSync(resolved); + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + throw new Error(`${label} must be a real directory: ${resolved}`); + } + return realpathSync(resolved); +} + +function npmVersion(npmRoot: string): string { + const manifest = readJson(join(npmRoot, "package.json"), "npm package manifest"); + if (manifest.name !== "npm" || typeof manifest.version !== "string") { + throw new Error("npm package identity has drifted"); + } + return manifest.version; +} + +type ReviewedPackageName = keyof typeof REVIEWED_NPM_PACKAGES; + +function collectReviewedPackages( + directory: string, + packages: Map, +): void { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const child = join(directory, entry.name); + // npm creates node_modules/.bin symlinks during the reviewed archive install. + // Do not follow them while inventorying package manifests. + if (entry.isSymbolicLink()) continue; + if (!entry.isDirectory() && !entry.isFile()) { + throw new Error(`npm package contains an unsafe member: ${child}`); + } + if (entry.isDirectory()) { + collectReviewedPackages(child, packages); + continue; + } + if (entry.name !== "package.json") continue; + + const manifest = readJson(child, "bundled npm package manifest"); + const name = manifest.name; + if ( + typeof name === "string" && + Object.hasOwn(REVIEWED_NPM_PACKAGES, name) && + typeof manifest.version === "string" + ) { + packages.get(name as ReviewedPackageName)?.push(manifest.version); + } + } +} + +export type ReviewedNpmState = Readonly<{ + npmVersion: string; + packages: Readonly>; +}>; + +export function verifyReviewedNpm(npmRoot: string): ReviewedNpmState { + const root = realDirectory(npmRoot, "npm package root"); + const version = npmVersion(root); + if (version !== REVIEWED_NPM_VERSION) { + throw new Error(`npm@${version} is not reviewed npm@${REVIEWED_NPM_VERSION}`); + } + + const packages = new Map( + Object.keys(REVIEWED_NPM_PACKAGES).map((name) => [name as ReviewedPackageName, []]), + ); + collectReviewedPackages(join(root, "node_modules"), packages); + + for (const [name, expectedVersion] of Object.entries(REVIEWED_NPM_PACKAGES)) { + const observed = packages.get(name as ReviewedPackageName) ?? []; + if (observed.length === 0 || observed.some((item) => item !== expectedVersion)) { + throw new Error( + `npm@${version} bundled ${name} versions ${JSON.stringify(observed)}; expected only ${expectedVersion}`, + ); + } + } + + return { + npmVersion: version, + packages: { + "brace-expansion": packages.get("brace-expansion") ?? [], + picomatch: packages.get("picomatch") ?? [], + sigstore: packages.get("sigstore") ?? [], + tar: packages.get("tar") ?? [], + }, + }; +} + +export function verifyReviewedNpmArchive(archivePath: string): void { + const descriptor = openSync(archivePath, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + if (!fstatSync(descriptor).isFile()) { + throw new Error(`reviewed npm archive must be a real file: ${archivePath}`); + } + const integrity = `sha512-${createHash("sha512") + .update(readFileSync(descriptor)) + .digest("base64")}`; + if (integrity !== REVIEWED_NPM_INTEGRITY) { + throw new Error( + `reviewed npm archive integrity mismatch\nExpected: ${REVIEWED_NPM_INTEGRITY}\nActual: ${integrity}`, + ); + } + } finally { + closeSync(descriptor); + } +} + +export type BundledNpmCommandRunner = (command: string, args: readonly string[]) => void; + +function run(command: string, args: readonly string[]): void { + const result = spawnSync(command, args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + timeout: 120_000, + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`${command} failed: ${`${result.stdout ?? ""}${result.stderr ?? ""}`.trim()}`); + } +} + +type PreparedArchive = Readonly<{ + archivePath: string; + cleanup: () => void; +}>; + +function prepareReviewedNpmArchive(commandRunner: BundledNpmCommandRunner): PreparedArchive { + const rootDirectory = mkdtempSync(join(tmpdir(), "nemoclaw-reviewed-npm-")); + const archivePath = join(rootDirectory, `npm-${REVIEWED_NPM_VERSION}.tgz`); + try { + commandRunner("curl", [ + "--proto", + "=https", + "--tlsv1.2", + "--fail", + "--silent", + "--show-error", + "--output", + archivePath, + REVIEWED_NPM_TARBALL, + ]); + verifyReviewedNpmArchive(archivePath); + return { + archivePath, + cleanup: () => rmSync(rootDirectory, { force: true, recursive: true }), + }; + } catch (error) { + rmSync(rootDirectory, { force: true, recursive: true }); + throw error; + } +} + +export type BundledNpmUpgradeDependencies = Readonly<{ + commandRunner?: BundledNpmCommandRunner; + installArchive?: (archivePath: string, commandRunner: BundledNpmCommandRunner) => void; + prepareArchive?: (commandRunner: BundledNpmCommandRunner) => PreparedArchive; +}>; + +function installReviewedNpm(archivePath: string, commandRunner: BundledNpmCommandRunner): void { + commandRunner("npm", [ + "install", + "--global", + "--ignore-scripts", + "--no-audit", + "--no-fund", + archivePath, + ]); +} + +export function upgradeBundledNpm( + npmRoot: string, + dependencies: BundledNpmUpgradeDependencies = {}, +): ReviewedNpmState { + const root = realDirectory(npmRoot, "npm package root"); + const currentVersion = npmVersion(root); + const commandRunner = dependencies.commandRunner ?? run; + + if (currentVersion === REVIEWED_NPM_VERSION) { + const reviewed = verifyReviewedNpm(root); + commandRunner("npm", ["--version"]); + commandRunner("npx", ["--version"]); + return reviewed; + } + if (!REPLACEABLE_NPM_VERSIONS.has(currentVersion)) { + throw new Error( + `npm@${currentVersion} is outside the reviewed upgrade path to npm@${REVIEWED_NPM_VERSION}`, + ); + } + + const prepared = (dependencies.prepareArchive ?? prepareReviewedNpmArchive)(commandRunner); + try { + (dependencies.installArchive ?? installReviewedNpm)(prepared.archivePath, commandRunner); + const reviewed = verifyReviewedNpm(root); + commandRunner("npm", ["--version"]); + commandRunner("npx", ["--version"]); + return reviewed; + } finally { + prepared.cleanup(); + } +} + +function argument(name: string): string { + const index = process.argv.indexOf(name); + const value = index >= 0 ? process.argv[index + 1] : undefined; + if (!value || value.startsWith("--")) throw new Error(`${name} is required`); + return value; +} + +function isMainModule(): boolean { + return process.argv[1] ? fileURLToPath(import.meta.url) === resolve(process.argv[1]) : false; +} + +if (isMainModule()) { + try { + const result = upgradeBundledNpm(argument("--npm-root")); + process.stdout.write( + `Verified npm@${result.npmVersion} with ${Object.entries(result.packages) + .map(([name, versions]) => `${name}@${versions.join(",")}`) + .join(" ")}\n`, + ); + } catch (error) { + console.error(`ERROR: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + } +} diff --git a/src/lib/sandbox-base-image/source-identity.test.ts b/src/lib/sandbox-base-image/source-identity.test.ts index 91f7379e98d..88edae5425a 100644 --- a/src/lib/sandbox-base-image/source-identity.test.ts +++ b/src/lib/sandbox-base-image/source-identity.test.ts @@ -147,6 +147,7 @@ describe("sandbox base-image source identity", () => { "scripts/lib/reviewed-npm-archive.mts", "scripts/checks/node-tar-image-scan.mts", "scripts/patch-bundled-npm-tar.mts", + "scripts/upgrade-bundled-npm.mts", agentDockerfile, ]); }); diff --git a/src/lib/sandbox-base-image/source-identity.ts b/src/lib/sandbox-base-image/source-identity.ts index 4fc487cfe09..ffb4cd8987a 100644 --- a/src/lib/sandbox-base-image/source-identity.ts +++ b/src/lib/sandbox-base-image/source-identity.ts @@ -17,6 +17,7 @@ export const BASE_IMAGE_INPUT_PATHS = [ "scripts/lib/reviewed-npm-archive.mts", "scripts/checks/node-tar-image-scan.mts", "scripts/patch-bundled-npm-tar.mts", + "scripts/upgrade-bundled-npm.mts", ]; export function normalizeBaseImageInputPaths(rootDir: string, paths: string[] = []): string[] { diff --git a/src/lib/sandbox/build-context.ts b/src/lib/sandbox/build-context.ts index b0b3443845a..d662ca27ac5 100644 --- a/src/lib/sandbox/build-context.ts +++ b/src/lib/sandbox/build-context.ts @@ -276,6 +276,10 @@ function stageOptimizedSandboxBuildContext( path.join(rootDir, "scripts", "patch-bundled-npm-tar.mts"), path.join(stagedScriptsDir, "patch-bundled-npm-tar.mts"), ); + fs.copyFileSync( + path.join(rootDir, "scripts", "upgrade-bundled-npm.mts"), + path.join(stagedScriptsDir, "upgrade-bundled-npm.mts"), + ); fs.copyFileSync( path.join(rootDir, "scripts", "verify-wechat-runtime-lock.mts"), path.join(stagedScriptsDir, "verify-wechat-runtime-lock.mts"), diff --git a/test/node-tar-dockerfile-contract.test.ts b/test/node-tar-dockerfile-contract.test.ts index 78f533b601e..4dd38f78926 100644 --- a/test/node-tar-dockerfile-contract.test.ts +++ b/test/node-tar-dockerfile-contract.test.ts @@ -131,3 +131,36 @@ describe("node-tar image remediation contract", () => { ).toBe(true); }); }); + +describe("reviewed npm image remediation contract", () => { + it.each([ + { file: "Dockerfile.base", installsWithNpm: true }, + { file: "agents/hermes/Dockerfile.base", installsWithNpm: true }, + { file: "agents/langchain-deepagents-code/Dockerfile.base", installsWithNpm: false }, + ])("upgrades npm before use in $file", ({ file, installsWithNpm }) => { + const source = completedStage(fs.readFileSync(path.join(repoRoot, file), "utf8")); + const patchRun = source.indexOf( + "RUN node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts", + ); + const upgradeCopy = source.indexOf( + "COPY scripts/upgrade-bundled-npm.mts /scripts/upgrade-bundled-npm.mts", + ); + const upgradeRun = source.indexOf( + "RUN node --experimental-strip-types /scripts/upgrade-bundled-npm.mts", + ); + + expect(upgradeCopy, file).toBeGreaterThanOrEqual(0); + expect(patchRun, file).toBeGreaterThan(upgradeCopy); + expect(upgradeRun, file).toBeGreaterThan(patchRun); + + const executableSource = source.replace(/^\s*#.*$/gmu, (comment) => " ".repeat(comment.length)); + const npmConsumers = [...executableSource.matchAll(/\bnpm\s+(?:ci|install)\b/gu)].map( + (match) => match.index, + ); + expect(npmConsumers.length > 0, file).toBe(installsWithNpm); + expect( + npmConsumers.every((index) => index > upgradeRun), + file, + ).toBe(true); + }); +}); diff --git a/test/sandbox-build-context.test.ts b/test/sandbox-build-context.test.ts index 54780fde371..faa5bda704d 100644 --- a/test/sandbox-build-context.test.ts +++ b/test/sandbox-build-context.test.ts @@ -120,6 +120,7 @@ describe("sandbox build context staging", () => { writeFixture(path.join("scripts", "extract-semver.sh")); writeFixture(path.join("scripts", "patch-openclaw-shared-state-permissions.mts")); writeFixture(path.join("scripts", "patch-bundled-npm-tar.mts")); + writeFixture(path.join("scripts", "upgrade-bundled-npm.mts")); writeFixture(path.join("scripts", "verify-wechat-runtime-lock.mts")); writeFixture(path.join("scripts", "lib", "reviewed-npm-archive.mts"), "fixture\n", 0o700); writeFixture(path.join("scripts", "lib", "reviewed-npm-audit.mts"), "fixture\n", 0o700); @@ -452,6 +453,7 @@ describe("sandbox build context staging", () => { ), ).toBe(true); expect(fs.existsSync(path.join(buildCtx, "scripts", "patch-bundled-npm-tar.mts"))).toBe(true); + expect(fs.existsSync(path.join(buildCtx, "scripts", "upgrade-bundled-npm.mts"))).toBe(true); expect( fs.existsSync(path.join(buildCtx, "scripts", "checks", "node-tar-image-scan.mts")), ).toBe(true); diff --git a/test/upgrade-bundled-npm.test.ts b/test/upgrade-bundled-npm.test.ts new file mode 100644 index 00000000000..405c3a8e2b0 --- /dev/null +++ b/test/upgrade-bundled-npm.test.ts @@ -0,0 +1,161 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { + REVIEWED_NPM_PACKAGES, + REVIEWED_NPM_VERSION, + upgradeBundledNpm, + verifyReviewedNpm, + verifyReviewedNpmArchive, +} from "../scripts/upgrade-bundled-npm.mts"; + +const temporaryDirectories: string[] = []; + +function temporaryDirectory(): string { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-reviewed-npm-")); + temporaryDirectories.push(directory); + return directory; +} + +function writeJson(file: string, value: object): void { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); +} + +function writePackage(root: string, location: string, name: string, version: string): void { + writeJson(path.join(root, "node_modules", location, "package.json"), { name, version }); +} + +function affectedNpm(version: "10.9.8" | "11.13.0"): string { + const root = path.join(temporaryDirectory(), "npm"); + writeJson(path.join(root, "package.json"), { name: "npm", version }); + writePackage(root, "brace-expansion", "brace-expansion", "2.0.2"); + writePackage(root, "picomatch", "picomatch", "4.0.3"); + writePackage(root, "sigstore", "sigstore", version === "10.9.8" ? "3.1.0" : "4.0.0"); + writePackage(root, "tar", "tar", "7.5.20"); + return root; +} + +function reviewedNpm(): string { + const root = path.join(temporaryDirectory(), "npm"); + writeJson(path.join(root, "package.json"), { + engines: { node: "^20.17.0 || >=22.9.0" }, + name: "npm", + version: REVIEWED_NPM_VERSION, + }); + writePackage( + root, + "brace-expansion", + "brace-expansion", + REVIEWED_NPM_PACKAGES["brace-expansion"], + ); + writePackage( + root, + path.join("tinyglobby", "node_modules", "picomatch"), + "picomatch", + REVIEWED_NPM_PACKAGES.picomatch, + ); + writePackage(root, "sigstore", "sigstore", REVIEWED_NPM_PACKAGES.sigstore); + writePackage(root, "tar", "tar", REVIEWED_NPM_PACKAGES.tar); + const binDirectory = path.join(root, "node_modules", ".bin"); + fs.mkdirSync(binDirectory); + fs.symlinkSync("../brace-expansion/index.js", path.join(binDirectory, "brace-expansion")); + return root; +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { force: true, recursive: true }); + } +}); + +describe("reviewed bundled npm upgrade", () => { + it("verifies the complete reviewed dependency set", () => { + const state = verifyReviewedNpm(reviewedNpm()); + + expect(state).toEqual({ + npmVersion: REVIEWED_NPM_VERSION, + packages: { + "brace-expansion": [REVIEWED_NPM_PACKAGES["brace-expansion"]], + picomatch: [REVIEWED_NPM_PACKAGES.picomatch], + sigstore: [REVIEWED_NPM_PACKAGES.sigstore], + tar: [REVIEWED_NPM_PACKAGES.tar], + }, + }); + }); + + it.each([ + "10.9.8", + "11.13.0", + ] as const)("replaces npm %s before running npm or npx", (version) => { + const npmRoot = affectedNpm(version); + const replacementRoot = reviewedNpm(); + const commands: string[] = []; + const archivePath = path.join(temporaryDirectory(), "npm.tgz"); + fs.writeFileSync(archivePath, "reviewed fixture\n"); + + const result = upgradeBundledNpm(npmRoot, { + commandRunner(command) { + commands.push(command); + if (command === "npm" || command === "npx") { + expect(verifyReviewedNpm(npmRoot).npmVersion).toBe(REVIEWED_NPM_VERSION); + } + }, + installArchive(_archive, commandRunner) { + commandRunner("install-reviewed-npm", []); + fs.rmSync(npmRoot, { recursive: true }); + fs.cpSync(replacementRoot, npmRoot, { recursive: true }); + }, + prepareArchive(commandRunner) { + commandRunner("curl", []); + return { + archivePath, + cleanup: () => commands.push("cleanup"), + }; + }, + }); + + expect(result.npmVersion).toBe(REVIEWED_NPM_VERSION); + expect(commands).toEqual(["curl", "install-reviewed-npm", "npm", "npx", "cleanup"]); + }); + + it("does not download npm when the reviewed tree is already installed", () => { + const npmRoot = reviewedNpm(); + const commands: string[] = []; + + expect( + upgradeBundledNpm(npmRoot, { + commandRunner: (command) => commands.push(command), + prepareArchive: () => { + throw new Error("unexpected download"); + }, + }).npmVersion, + ).toBe(REVIEWED_NPM_VERSION); + expect(commands).toEqual(["npm", "npx"]); + }); + + it("fails closed on reviewed-package drift", () => { + const drifted = reviewedNpm(); + writePackage(drifted, "sigstore", "sigstore", "4.1.0"); + expect(() => verifyReviewedNpm(drifted)).toThrow("expected only 4.1.1"); + }); + + it("fails closed on an unreviewed current npm version", () => { + const unreviewed = affectedNpm("10.9.8"); + const manifestPath = path.join(unreviewed, "package.json"); + writeJson(manifestPath, { name: "npm", version: "12.0.1" }); + expect(() => upgradeBundledNpm(unreviewed)).toThrow("outside the reviewed upgrade path"); + }); + + it("rejects an archive whose SHA-512 does not match the reviewed npm release", () => { + const archivePath = path.join(temporaryDirectory(), "npm.tgz"); + fs.writeFileSync(archivePath, "not npm\n"); + expect(() => verifyReviewedNpmArchive(archivePath)).toThrow("integrity mismatch"); + }); +}); From 999dac9acd1f1919765fd864bdc6ecd0a3e9a915 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Fri, 24 Jul 2026 12:57:42 -0700 Subject: [PATCH 2/3] fix(security): annotate npm upgrade layers --- Dockerfile.base | 1 + agents/hermes/Dockerfile.base | 1 + agents/langchain-deepagents-code/Dockerfile.base | 1 + 3 files changed, 3 insertions(+) diff --git a/Dockerfile.base b/Dockerfile.base index d5801030b6f..6204f2f5043 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -244,6 +244,7 @@ RUN node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts \ # Upgrade the complete private npm tree so its sigstore, brace-expansion, and # picomatch packages meet the reviewed security floors. +# hadolint ignore=DL3059 RUN node --experimental-strip-types /scripts/upgrade-bundled-npm.mts \ --npm-root /usr/local/lib/node_modules/npm diff --git a/agents/hermes/Dockerfile.base b/agents/hermes/Dockerfile.base index d0c414e6faf..8afbecdc144 100644 --- a/agents/hermes/Dockerfile.base +++ b/agents/hermes/Dockerfile.base @@ -74,6 +74,7 @@ RUN node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts \ # Upgrade the complete private npm tree before npm processes the # checksum-pinned Hermes release lockfiles. +# hadolint ignore=DL3059 RUN node --experimental-strip-types /scripts/upgrade-bundled-npm.mts \ --npm-root /usr/local/lib/node_modules/npm diff --git a/agents/langchain-deepagents-code/Dockerfile.base b/agents/langchain-deepagents-code/Dockerfile.base index de75f7c274b..d2a914e6e44 100644 --- a/agents/langchain-deepagents-code/Dockerfile.base +++ b/agents/langchain-deepagents-code/Dockerfile.base @@ -44,6 +44,7 @@ RUN node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts \ # Replace the complete private npm tree so managed-terminal npm commands use # the reviewed dependency set. +# hadolint ignore=DL3059 RUN node --experimental-strip-types /scripts/upgrade-bundled-npm.mts \ --npm-root /usr/local/lib/node_modules/npm From d9716f443ba3f7d44fa92e85f714d2cbf71c8895 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Fri, 24 Jul 2026 13:02:22 -0700 Subject: [PATCH 3/3] test(security): keep npm upgrade test linear --- test/upgrade-bundled-npm.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/upgrade-bundled-npm.test.ts b/test/upgrade-bundled-npm.test.ts index 405c3a8e2b0..f7bd4054c04 100644 --- a/test/upgrade-bundled-npm.test.ts +++ b/test/upgrade-bundled-npm.test.ts @@ -99,13 +99,15 @@ describe("reviewed bundled npm upgrade", () => { const commands: string[] = []; const archivePath = path.join(temporaryDirectory(), "npm.tgz"); fs.writeFileSync(archivePath, "reviewed fixture\n"); + const verifyReviewedCommand: Readonly void) | undefined>> = { + npm: () => expect(verifyReviewedNpm(npmRoot).npmVersion).toBe(REVIEWED_NPM_VERSION), + npx: () => expect(verifyReviewedNpm(npmRoot).npmVersion).toBe(REVIEWED_NPM_VERSION), + }; const result = upgradeBundledNpm(npmRoot, { commandRunner(command) { commands.push(command); - if (command === "npm" || command === "npx") { - expect(verifyReviewedNpm(npmRoot).npmVersion).toBe(REVIEWED_NPM_VERSION); - } + verifyReviewedCommand[command]?.(); }, installArchive(_archive, commandRunner) { commandRunner("install-reviewed-npm", []);