Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/base-image.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
15 changes: 12 additions & 3 deletions Dockerfile.base
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
#
Expand Down Expand Up @@ -232,13 +234,20 @@ 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.
# hadolint ignore=DL3059
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
Expand Down
12 changes: 10 additions & 2 deletions agents/hermes/Dockerfile.base
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -63,13 +64,20 @@ 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.
# hadolint ignore=DL3059
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
Expand Down
7 changes: 7 additions & 0 deletions agents/langchain-deepagents-code/Dockerfile.base
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down Expand Up @@ -41,6 +42,12 @@ 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.
# hadolint ignore=DL3059
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 \
Expand Down
281 changes: 281 additions & 0 deletions scripts/upgrade-bundled-npm.mts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

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<ReviewedPackageName, string[]>,
): 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<Record<ReviewedPackageName, readonly string[]>>;
}>;

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<ReviewedPackageName, string[]>(
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;
}
}
1 change: 1 addition & 0 deletions src/lib/sandbox-base-image/source-identity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
]);
});
Expand Down
Loading
Loading