Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,7 @@ COPY agents/openclaw/wechat-runtime/package.json /usr/local/lib/nemoclaw/wechat-
COPY agents/openclaw/wechat-runtime/package-lock.json /usr/local/lib/nemoclaw/wechat-runtime/package-lock.json
COPY ci/npm-audit-exceptions.json /scripts/npm-audit-exceptions.json
COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts
COPY scripts/lib/bundled-npm-package.mts /scripts/lib/bundled-npm-package.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-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts
Expand Down Expand Up @@ -2313,6 +2314,7 @@ RUN check_metadata() { \
exit 1; \
fi; \
} \
&& check_metadata /scripts/lib/bundled-npm-package.mts 'root:root:644' \
&& check_metadata /scripts/patch-bundled-npm-brace-expansion.mts 'root:root:755' \
&& check_metadata /scripts/lib/patch-bundled-npm-ip-address.mts 'root:root:755' \
&& check_metadata /scripts/patch-bundled-npm-tar.mts 'root:root:755' \
Expand Down
1 change: 1 addition & 0 deletions Dockerfile.base
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,7 @@ COPY agents/openclaw/mcporter-runtime/package.json \
/usr/local/lib/nemoclaw/mcporter-runtime/
COPY ci/npm-audit-exceptions.json /scripts/npm-audit-exceptions.json
COPY scripts/lib/reviewed-npm-archive.mts \
scripts/lib/bundled-npm-package.mts \
scripts/lib/reviewed-npm-audit.mts \
scripts/lib/openclaw-npm-remediation.mts \
/scripts/lib/
Expand Down
5 changes: 4 additions & 1 deletion agents/hermes/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ RUN set -eu; \
FROM scratch AS hermes-npm-patch-payload

COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts
COPY scripts/lib/bundled-npm-package.mts /scripts/lib/bundled-npm-package.mts
COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts
COPY scripts/lib/patch-bundled-npm-ip-address.mts /scripts/lib/patch-bundled-npm-ip-address.mts
COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts
Expand Down Expand Up @@ -370,7 +371,8 @@ RUN chmod -R a+rX /opt/nemoclaw-hermes-plugin/
RUN find /opt/nemoclaw-hermes-config -type d -exec chmod 755 {} + \
&& find /opt/nemoclaw-hermes-config -type f -exec chmod 444 {} + \
&& chmod 444 /src/lib/hermes-managed-route.ts /src/lib/tool-disclosure.ts \
&& chmod 444 /scripts/lib/reviewed-npm-archive.mts /scripts/lib/openclaw-npm-remediation.mts \
&& chmod 444 /scripts/lib/reviewed-npm-archive.mts /scripts/lib/bundled-npm-package.mts \
/scripts/lib/openclaw-npm-remediation.mts \
/scripts/patch-bundled-npm-brace-expansion.mts /scripts/lib/patch-bundled-npm-ip-address.mts \
/scripts/patch-bundled-npm-tar.mts \
&& chmod -R a+rX /src/lib/messaging
Expand Down Expand Up @@ -1386,6 +1388,7 @@ RUN check_metadata() { \
&& check_absent /sandbox/.nemoclaw/hermes-cron-restore-drain.json \
&& check_absent /sandbox/.nemoclaw/hermes-cron-restore-release-recovery.json \
&& check_metadata /sandbox/.nemoclaw 'root:root 1755' \
&& check_metadata /scripts/lib/bundled-npm-package.mts 'root:root 444' \
&& check_metadata /scripts/patch-bundled-npm-brace-expansion.mts 'root:root 444' \
&& check_metadata /scripts/lib/patch-bundled-npm-ip-address.mts 'root:root 444' \
&& check_metadata /scripts/patch-bundled-npm-tar.mts 'root:root 444' \
Expand Down
1 change: 1 addition & 0 deletions agents/hermes/Dockerfile.base
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ RUN arch="$(dpkg --print-architecture)" \
&& test "$(npm --version)" = "11.16.0"

COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts
COPY scripts/lib/bundled-npm-package.mts /scripts/lib/bundled-npm-package.mts
COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts
COPY scripts/lib/patch-bundled-npm-ip-address.mts /scripts/lib/patch-bundled-npm-ip-address.mts
COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts
Expand Down
1 change: 1 addition & 0 deletions agents/langchain-deepagents-code/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ RUN managed_runtime_assertion_failed() { \
&& install -d -o root -g root -m 0755 /run/nemoclaw

COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts
COPY scripts/lib/bundled-npm-package.mts /scripts/lib/bundled-npm-package.mts
COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts
COPY scripts/lib/patch-bundled-npm-ip-address.mts /scripts/lib/patch-bundled-npm-ip-address.mts
COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts
Expand Down
1 change: 1 addition & 0 deletions agents/langchain-deepagents-code/Dockerfile.base
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ ARG NEMOCLAW_CORPORATE_CA_B64
COPY --from=perl-builder /out /tmp/nemoclaw-native-security

COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts
COPY scripts/lib/bundled-npm-package.mts /scripts/lib/bundled-npm-package.mts
COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts
COPY scripts/lib/patch-bundled-npm-ip-address.mts /scripts/lib/patch-bundled-npm-ip-address.mts
COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts
Expand Down
1 change: 1 addition & 0 deletions agents/pi/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
strings "$binary" | grep -Fq '/usr/local/lib/nemoclaw/managed-bootstrap-trampoline.sh'

# hadolint ignore=DL3006
FROM ${BASE_IMAGE}

Check warning on line 62 in agents/pi/Dockerfile

View workflow job for this annotation

GitHub Actions / Build and validate the Pi candidate managed image (arm64)

Default value for global ARG results in an empty or invalid base image name

InvalidDefaultArgInFrom: Default value for ARG ${BASE_IMAGE} results in empty or invalid base image name More info: https://docs.docker.com/go/dockerfile/rule/invalid-default-arg-in-from/

# The supplied base may end as a non-root runtime user. Reset the build user
# explicitly before installing the root-owned managed-startup handoff.
Expand Down Expand Up @@ -115,6 +115,7 @@
&& install -d -o root -g root -m 0755 /run/nemoclaw

COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts
COPY scripts/lib/bundled-npm-package.mts /scripts/lib/bundled-npm-package.mts
COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts
COPY scripts/lib/patch-bundled-npm-ip-address.mts /scripts/lib/patch-bundled-npm-ip-address.mts
COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts
Expand Down
1 change: 1 addition & 0 deletions agents/pi/Dockerfile.base
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ ARG PI_NPM_INTEGRITY
COPY --from=perl-builder /out /tmp/nemoclaw-native-security

COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts
COPY scripts/lib/bundled-npm-package.mts /scripts/lib/bundled-npm-package.mts
COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts
COPY scripts/lib/patch-bundled-npm-ip-address.mts /scripts/lib/patch-bundled-npm-ip-address.mts
COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts
Expand Down
1 change: 1 addition & 0 deletions ci/full-e2e-cold-path-calibration.json
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@
"scripts/patch-openclaw-device-self-approval.mts",
"scripts/verify-wechat-runtime-lock.mts",
"scripts/lib/reviewed-npm-archive.mts",
"scripts/lib/bundled-npm-package.mts",
"src/lib/sandbox/build-context.ts"
],
"adjustedMetrics": [
Expand Down
115 changes: 115 additions & 0 deletions scripts/lib/bundled-npm-package.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import {
closeSync,
constants,
fstatSync,
lstatSync,
openSync,
readdirSync,
readFileSync,
realpathSync,
} from "node:fs";
import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";

export type JsonObject = Record<string, unknown>;

export function jsonObject(value: unknown, label: string): JsonObject {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error(`${label} must be a JSON object`);
}
return value as JsonObject;
}

export function readJsonObject(file: string, label: string): JsonObject {
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 jsonObject(JSON.parse(readFileSync(descriptor, "utf8")), label);
} catch (error) {
throw new Error(`${label} is invalid: ${String(error)}`);
} finally {
closeSync(descriptor);
}
}

export function requireRealDirectory(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);
}

export function rejectUnsafePackageTree(root: string, label: string): void {
for (const entry of readdirSync(root, { withFileTypes: true })) {
if (entry.isSymbolicLink() || (!entry.isDirectory() && !entry.isFile())) {
throw new Error(`${label} contains an unsafe member: ${entry.name}`);
}
if (entry.isDirectory()) rejectUnsafePackageTree(join(root, entry.name), label);
}
}

function isContainedBinSymlink(
nodeModulesRoot: string,
directory: string,
entryName: string,
): boolean {
if (basename(directory) !== ".bin") return false;
try {
const target = realpathSync(join(directory, entryName));
const targetRelative = relative(nodeModulesRoot, target);
return (
targetRelative !== "" &&
targetRelative !== ".." &&
!targetRelative.startsWith(`..${sep}`) &&
!isAbsolute(targetRelative) &&
lstatSync(target).isFile()
);
} catch {
return false;
}
}

export function collectBundledPackageVersions(options: {
ignoredDirectoryPrefixes: readonly string[];
nodeModulesRoot: string;
packageName: string;
}): string[] {
const versions: string[] = [];
const visit = (directory: string): void => {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
if (entry.isSymbolicLink()) {
if (!isContainedBinSymlink(options.nodeModulesRoot, directory, entry.name)) {
throw new Error(`npm package contains an unsafe symlink: ${join(directory, entry.name)}`);
}
continue;
}
if (
entry.isDirectory() &&
options.ignoredDirectoryPrefixes.some((prefix) => entry.name.startsWith(prefix))
) {
continue;
}
const child = join(directory, entry.name);
if (!entry.isDirectory() && !entry.isFile()) {
throw new Error(`npm package contains an unsafe member: ${child}`);
}
if (entry.isDirectory()) {
visit(child);
continue;
}
if (entry.name !== "package.json") continue;
const manifest = readJsonObject(child, "npm bundled package manifest");
if (manifest.name !== options.packageName) continue;
if (typeof manifest.version !== "string") {
throw new Error(`npm bundled ${options.packageName} version is invalid`);
}
versions.push(manifest.version);
}
};
visit(options.nodeModulesRoot);
return versions;
}
123 changes: 17 additions & 106 deletions scripts/lib/patch-bundled-npm-ip-address.mts
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,26 @@ import {
constants,
cpSync,
fstatSync,
lstatSync,
mkdirSync,
mkdtempSync,
openSync,
readdirSync,
readFileSync,
realpathSync,
renameSync,
rmSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";

import {
type JsonObject,
collectBundledPackageVersions,
jsonObject as record,
readJsonObject as readJson,
rejectUnsafePackageTree,
requireRealDirectory as realDirectory,
} from "./bundled-npm-package.mts";

export const AFFECTED_IP_ADDRESS_VERSION = "10.2.0";
export const FIXED_IP_ADDRESS_VERSION = "10.3.1";
export const FIXED_IP_ADDRESS_INTEGRITY =
Expand All @@ -37,45 +43,6 @@ const REVIEWED_IP_ADDRESS_VERSIONS = new Set([
FIXED_IP_ADDRESS_VERSION,
]);

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 rejectUnsafeTree(root: string): void {
for (const entry of readdirSync(root, { withFileTypes: true })) {
if (entry.isSymbolicLink() || (!entry.isDirectory() && !entry.isFile())) {
throw new Error(`replacement ip-address package contains an unsafe member: ${entry.name}`);
}
if (entry.isDirectory()) rejectUnsafeTree(join(root, entry.name));
}
}

function removeBackup(backupPath: string): void {
try {
rmSync(backupPath, { force: true, recursive: true });
Expand All @@ -91,66 +58,7 @@ function removeBackup(backupPath: string): void {
}
}

function isContainedBinSymlink(
nodeModulesRoot: string,
directory: string,
entryName: string,
): boolean {
if (basename(directory) !== ".bin") return false;
try {
const target = realpathSync(join(directory, entryName));
const targetRelative = relative(nodeModulesRoot, target);
return (
targetRelative !== "" &&
targetRelative !== ".." &&
!targetRelative.startsWith(`..${sep}`) &&
!isAbsolute(targetRelative) &&
lstatSync(target).isFile()
);
} catch {
return false;
}
}

function collectIpAddressVersions(
directory: string,
nodeModulesRoot: string,
versions: string[],
): void {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
if (entry.isSymbolicLink()) {
if (!isContainedBinSymlink(nodeModulesRoot, directory, entry.name)) {
throw new Error(`npm package contains an unsafe symlink: ${join(directory, entry.name)}`);
}
continue;
}
if (
entry.isDirectory() &&
(entry.name.startsWith(".ip-address.nemoclaw-stage-") ||
entry.name.startsWith("ip-address.nemoclaw-backup-"))
) {
continue;
}
if (!entry.isDirectory() && !entry.isFile()) {
throw new Error(`npm package contains an unsafe member: ${join(directory, entry.name)}`);
}
const child = join(directory, entry.name);
if (entry.isDirectory()) {
collectIpAddressVersions(child, nodeModulesRoot, versions);
continue;
}
if (entry.name !== "package.json") continue;
const manifest = readJson(child, "npm bundled package manifest");
if (manifest.name === "ip-address") {
if (typeof manifest.version !== "string") {
throw new Error("npm bundled ip-address version is invalid");
}
versions.push(manifest.version);
}
}
}

function verifyIpAddressManifest(manifest: JsonRecord): string {
function verifyIpAddressManifest(manifest: JsonObject): string {
const version = manifest.version;
const engines = record(manifest.engines, "npm bundled ip-address engines");
if (
Expand Down Expand Up @@ -196,9 +104,12 @@ export function inspectBundledNpmIpAddress(npmRoot: string): BundledNpmIpAddress
"npm bundled ip-address manifest",
),
);
const versions: string[] = [];
const nodeModulesRoot = realDirectory(join(root, "node_modules"), "npm node_modules root");
collectIpAddressVersions(nodeModulesRoot, nodeModulesRoot, versions);
const versions = collectBundledPackageVersions({
ignoredDirectoryPrefixes: [".ip-address.nemoclaw-stage-", "ip-address.nemoclaw-backup-"],
nodeModulesRoot,
packageName: "ip-address",
});
if (versions.length !== 1 || versions[0] !== version) {
throw new Error(`npm bundled ip-address layout has drifted: ${JSON.stringify(versions)}`);
}
Expand Down Expand Up @@ -226,7 +137,7 @@ export function patchBundledNpmIpAddress(options: {
}): BundledNpmIpAddressState {
const npmRoot = realDirectory(options.npmRoot, "npm package root");
const replacementRoot = realDirectory(options.replacementRoot, "replacement ip-address root");
rejectUnsafeTree(replacementRoot);
rejectUnsafePackageTree(replacementRoot, "replacement ip-address package");
const replacementVersion = verifyIpAddressManifest(
readJson(join(replacementRoot, "package.json"), "replacement ip-address manifest"),
);
Expand Down
Loading
Loading