From 86e85cc66ef94bc8513cefdf56af7912db364441 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 8 Aug 2026 10:52:15 -0700 Subject: [PATCH 1/3] ci: remove node-tar image scans --- .github/workflows/base-image.yaml | 1 - .github/workflows/managed-images.yaml | 18 -- .github/workflows/sandbox-images-and-e2e.yaml | 93 --------- Dockerfile | 11 +- Dockerfile.base | 8 - agents/hermes/Dockerfile | 15 +- agents/hermes/Dockerfile.base | 10 +- agents/langchain-deepagents-code/Dockerfile | 10 +- .../langchain-deepagents-code/Dockerfile.base | 8 - scripts/checks/node-tar-image-scan.mts | 180 ------------------ ...ckerfile-remote-dashboard-bind-contract.ts | 5 +- .../source-identity.test.ts | 1 - src/lib/sandbox-base-image/source-identity.ts | 1 - src/lib/sandbox/build-context.ts | 4 - test/dashboard-remote-bind-lifecycle.test.ts | 12 +- .../sandbox-images-workflow-boundary.test.ts | 39 +--- test/hermes-final-image-layout.test.ts | 20 +- ...managed-image-publication-workflow.test.ts | 2 - test/node-tar-dockerfile-contract.test.ts | 19 +- test/node-tar-image-scan.test.ts | 128 ------------- test/openclaw-final-image-layout.test.ts | 7 - test/sandbox-build-context.test.ts | 4 - .../e2e/sandbox-images-workflow-boundary.mts | 134 +------------ 23 files changed, 19 insertions(+), 711 deletions(-) delete mode 100755 scripts/checks/node-tar-image-scan.mts delete mode 100644 test/node-tar-image-scan.test.ts diff --git a/.github/workflows/base-image.yaml b/.github/workflows/base-image.yaml index 4f58d6db303..bf0e2284c54 100644 --- a/.github/workflows/base-image.yaml +++ b/.github/workflows/base-image.yaml @@ -65,7 +65,6 @@ on: - "scripts/security/build-perl-security-packages.sh" - "scripts/security/patches/libssh2-1.11.1-cve-2026.patch" - "scripts/security/patches/python3.13-htmlparser-cve-2026-15308.patch" - - "scripts/checks/node-tar-image-scan.mts" - "scripts/patch-bundled-npm-brace-expansion.mts" - "scripts/lib/patch-bundled-npm-ip-address.mts" - "scripts/patch-bundled-npm-tar.mts" diff --git a/.github/workflows/managed-images.yaml b/.github/workflows/managed-images.yaml index 0ef1b6ad2a2..f50200db5e7 100644 --- a/.github/workflows/managed-images.yaml +++ b/.github/workflows/managed-images.yaml @@ -751,26 +751,8 @@ jobs: test "$(stat -c '%u:%g:%a' /usr/local/lib/nemoclaw/managed-startup-image-runtime.cjs)" = "0:0:444" test -x "$REQUIRED_BINARY" test -r /opt/nemoclaw-blueprint/blueprint.yaml - test -r /usr/local/share/nemoclaw/node-tar-inventory.json test ! -e /usr/local/share/nemoclaw/corporate-ca.pem test ! -L /usr/local/share/nemoclaw/corporate-ca.pem - node <<'VALIDATE_NODE_TAR' - const fs = require("node:fs"); - const scan = JSON.parse( - fs.readFileSync("/usr/local/share/nemoclaw/node-tar-inventory.json", "utf8"), - ); - if ( - scan.schema !== 1 || - !Number.isInteger(scan.packageCount) || - scan.packageCount < 1 || - !Array.isArray(scan.packages) || - scan.packages.length !== scan.packageCount || - scan.packages.some((entry) => entry.status !== "fixed") - ) { - throw new Error("completed node-tar scan is missing or unsafe"); - } - VALIDATE_NODE_TAR - test "$NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION" = "1" case "$AGENT" in openclaw) diff --git a/.github/workflows/sandbox-images-and-e2e.yaml b/.github/workflows/sandbox-images-and-e2e.yaml index ec2277f0995..f9beb01b48a 100644 --- a/.github/workflows/sandbox-images-and-e2e.yaml +++ b/.github/workflows/sandbox-images-and-e2e.yaml @@ -94,37 +94,6 @@ jobs: scripts/check-production-build-args.sh "${build_args[@]}" docker build "${build_args[@]}" -t nemoclaw-production . - - name: Scan completed OpenClaw image for node-tar - id: node-tar-scan - shell: bash - run: | - set -euo pipefail - image_id="$(docker image inspect --format '{{.Id}}' nemoclaw-production)" - docker run --rm \ - --network none \ - --read-only \ - --cap-drop ALL \ - --cap-add DAC_READ_SEARCH \ - --security-opt no-new-privileges \ - --pids-limit 64 \ - --memory 256m \ - --entrypoint node \ - -v "${{ github.workspace }}/scripts/checks/node-tar-image-scan.mts:/scripts/checks/node-tar-image-scan.mts:ro" \ - nemoclaw-production \ - --experimental-strip-types /scripts/checks/node-tar-image-scan.mts \ - --root / \ - --image "${image_id}" \ - | tee /tmp/openclaw-node-tar-inventory.json - - - name: Upload OpenClaw node-tar inventory - if: ${{ always() && steps.node-tar-scan.outcome != 'skipped' }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: openclaw-node-tar-inventory - path: /tmp/openclaw-node-tar-inventory.json - if-no-files-found: error - retention-days: 14 - - name: Build sandbox test image (fixtures layered on production) run: docker build -f test/Dockerfile.sandbox --build-arg BASE_IMAGE=nemoclaw-production -t nemoclaw-sandbox-test . @@ -242,37 +211,6 @@ jobs: /opt/hermes/.venv/bin/python -I -c "import ssl; assert ssl.create_default_context().get_ca_certs()" ' - - name: Scan completed Hermes image for node-tar - id: node-tar-scan - shell: bash - run: | - set -euo pipefail - image_id="$(docker image inspect --format '{{.Id}}' nemoclaw-hermes-production)" - docker run --rm \ - --network none \ - --read-only \ - --cap-drop ALL \ - --cap-add DAC_READ_SEARCH \ - --security-opt no-new-privileges \ - --pids-limit 64 \ - --memory 256m \ - --entrypoint node \ - -v "${{ github.workspace }}/scripts/checks/node-tar-image-scan.mts:/scripts/checks/node-tar-image-scan.mts:ro" \ - nemoclaw-hermes-production \ - --experimental-strip-types /scripts/checks/node-tar-image-scan.mts \ - --root / \ - --image "${image_id}" \ - | tee /tmp/hermes-node-tar-inventory.json - - - name: Upload Hermes node-tar inventory - if: ${{ always() && steps.node-tar-scan.outcome != 'skipped' }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: hermes-node-tar-inventory - path: /tmp/hermes-node-tar-inventory.json - if-no-files-found: error - retention-days: 14 - - name: Verify sandbox user can read copied files run: | set -euo pipefail @@ -548,37 +486,6 @@ jobs: scripts/check-production-build-args.sh "${build_args[@]}" docker build "${build_args[@]}" -t nemoclaw-production-arm64 . - - name: Scan completed OpenClaw arm64 image for node-tar - id: node-tar-scan - shell: bash - run: | - set -euo pipefail - image_id="$(docker image inspect --format '{{.Id}}' nemoclaw-production-arm64)" - docker run --rm \ - --network none \ - --read-only \ - --cap-drop ALL \ - --cap-add DAC_READ_SEARCH \ - --security-opt no-new-privileges \ - --pids-limit 64 \ - --memory 256m \ - --entrypoint node \ - -v "${{ github.workspace }}/scripts/checks/node-tar-image-scan.mts:/scripts/checks/node-tar-image-scan.mts:ro" \ - nemoclaw-production-arm64 \ - --experimental-strip-types /scripts/checks/node-tar-image-scan.mts \ - --root / \ - --image "${image_id}" \ - | tee /tmp/openclaw-arm64-node-tar-inventory.json - - - name: Upload OpenClaw arm64 node-tar inventory - if: ${{ always() && steps.node-tar-scan.outcome != 'skipped' }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: openclaw-arm64-node-tar-inventory - path: /tmp/openclaw-arm64-node-tar-inventory.json - if-no-files-found: error - retention-days: 14 - - name: Build sandbox test image on arm64 run: docker build -f test/Dockerfile.sandbox --build-arg BASE_IMAGE=nemoclaw-production-arm64 -t nemoclaw-sandbox-test-arm64 . diff --git a/Dockerfile b/Dockerfile index ed238b72933..0ca4d9e383b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1857,9 +1857,6 @@ RUN set -eu; \ fi; \ fi -# Gate the completed local filesystem too; CI repeats this scan in an isolated -# container and retains evidence keyed to the final image ID. -COPY scripts/checks/node-tar-image-scan.mts /scripts/checks/node-tar-image-scan.mts RUN check_metadata() { \ metadata_path="$1"; \ expected_metadata="$2"; \ @@ -1882,13 +1879,7 @@ RUN check_metadata() { \ && check_metadata /usr/local/bin/nemoclaw-gateway-control 'root:root:700' \ && check_metadata /usr/local/lib/nemoclaw/state-dir-guard.py 'root:root:500' \ && check_metadata /usr/local/share/nemoclaw/state-lock-plan.json 'root:root:444' \ - && check_metadata /usr/local/lib/nemoclaw/preloads/sandbox-safety-net.js 'root:root:644' \ - && check_metadata /scripts/checks/node-tar-image-scan.mts 'root:root:755' \ - && install -d -m 0755 /usr/local/share/nemoclaw \ - && node --experimental-strip-types /scripts/checks/node-tar-image-scan.mts \ - --root / --image build:openclaw \ - > /usr/local/share/nemoclaw/node-tar-inventory.json \ - && chmod 0444 /usr/local/share/nemoclaw/node-tar-inventory.json + && check_metadata /usr/local/lib/nemoclaw/preloads/sandbox-safety-net.js 'root:root:644' # Health check: poll the gateway's /health endpoint so Docker (and Compose) # can detect and restart unhealthy containers in standalone deployments. diff --git a/Dockerfile.base b/Dockerfile.base index 30f4de55de7..b04e39672d4 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -665,11 +665,3 @@ RUN { \ && /usr/bin/setpriv --reuid=sandbox --regid=sandbox --init-groups -- env HOME=/sandbox bash -lc 'command -v brew' | grep -qx /usr/local/bin/brew \ && /usr/bin/setpriv --reuid=sandbox --regid=sandbox --init-groups -- env HOME=/sandbox bash -lc 'brew --prefix' | grep -qx /home/linuxbrew/.linuxbrew \ && /usr/bin/setpriv --reuid=sandbox --regid=sandbox --init-groups -- env HOME=/sandbox bash -lc "case \":\${PATH}:\" in *:/home/linuxbrew/.linuxbrew/bin:*) exit 0 ;; *) exit 1 ;; esac" - -# Gate the exact completed base filesystem before it can be published. -COPY scripts/checks/node-tar-image-scan.mts /scripts/checks/node-tar-image-scan.mts -RUN install -d -m 0755 /usr/local/share/nemoclaw \ - && node --experimental-strip-types /scripts/checks/node-tar-image-scan.mts \ - --root / --image build:openclaw-base \ - > /usr/local/share/nemoclaw/node-tar-inventory.json \ - && chmod 0444 /usr/local/share/nemoclaw/node-tar-inventory.json diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 1c9ab27753c..51cae78cda5 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -145,9 +145,6 @@ COPY agents/hermes/hermes-wrapper.py /usr/local/lib/nemoclaw/hermes-wrapper.py COPY agents/hermes/validate-cli-adapter.py /usr/local/lib/nemoclaw/validate-hermes-cli-adapter.py COPY agents/hermes/hermes-cli-adapter-v1.json /usr/local/share/nemoclaw/hermes-cli-adapter-v1.json -FROM scratch AS hermes-scan-payload - -COPY scripts/checks/node-tar-image-scan.mts /scripts/checks/node-tar-image-scan.mts # hadolint ignore=DL3006 FROM ${BASE_IMAGE} @@ -1135,9 +1132,6 @@ RUN if [ "$NEMOCLAW_DARWIN_VM_COMPAT" = "1" ]; then \ chmod a+rw /sandbox/.bashrc /sandbox/.profile; \ fi -# Gate the completed local filesystem too; CI repeats this scan in an isolated -# container and retains evidence keyed to the final image ID. -COPY --from=hermes-scan-payload / / # These executables are git-tracked 0755 but check out group-writable (0775) # under umask 002, and COPY carries that source mode into the image. Normalize @@ -1146,7 +1140,6 @@ COPY --from=hermes-scan-payload / / RUN chmod 755 \ /usr/local/lib/nemoclaw/hermes-wrapper.py \ /usr/local/lib/nemoclaw/validate-hermes-cli-adapter.py \ - /scripts/checks/node-tar-image-scan.mts \ && chmod 444 /usr/local/share/nemoclaw/hermes-cli-adapter-v1.json RUN check_metadata() { \ @@ -1190,13 +1183,7 @@ RUN check_metadata() { \ && check_metadata /usr/local/lib/nemoclaw/preloads/sandbox-safety-net.js 'root:root 444' \ && check_metadata /usr/local/lib/nemoclaw/hermes-wrapper.py 'root:root 755' \ && check_metadata /usr/local/lib/nemoclaw/validate-hermes-cli-adapter.py 'root:root 755' \ - && check_metadata /usr/local/share/nemoclaw/hermes-cli-adapter-v1.json 'root:root 444' \ - && check_metadata /scripts/checks/node-tar-image-scan.mts 'root:root 755' \ - && install -d -m 0755 /usr/local/share/nemoclaw \ - && node --experimental-strip-types /scripts/checks/node-tar-image-scan.mts \ - --root / --image build:hermes \ - > /usr/local/share/nemoclaw/node-tar-inventory.json \ - && chmod 0444 /usr/local/share/nemoclaw/node-tar-inventory.json + && check_metadata /usr/local/share/nemoclaw/hermes-cli-adapter-v1.json 'root:root 444' # Verify the immutable security package inventory in the completed image. # hadolint ignore=DL4006 diff --git a/agents/hermes/Dockerfile.base b/agents/hermes/Dockerfile.base index 0928f3e4125..b685aaca31d 100644 --- a/agents/hermes/Dockerfile.base +++ b/agents/hermes/Dockerfile.base @@ -564,17 +564,11 @@ RUN chmod -R a+rX /opt/hermes/.venv \ && test -r /opt/hermes/.venv/pyvenv.cfg \ && su -s /bin/sh sandbox -c '/opt/hermes/.venv/bin/python3 -c "import urllib.request"' -# Gate the exact completed base filesystem before it can be published. -COPY scripts/checks/node-tar-image-scan.mts /scripts/checks/node-tar-image-scan.mts +# Reject build-only paths before the base image can be published. RUN set -eu; \ for build_only_path in /opt/hermes/tests /root/.npm /root/.cache/electron /root/.cache/node-gyp /root/.cache/uv; do \ if [ -e "$build_only_path" ] || [ -L "$build_only_path" ]; then \ echo "ERROR: build-only Hermes path leaked into the base image: $build_only_path" >&2; \ exit 1; \ fi; \ - done; \ - install -d -m 0755 /usr/local/share/nemoclaw \ - && node --experimental-strip-types /scripts/checks/node-tar-image-scan.mts \ - --root / --image build:hermes-base \ - > /usr/local/share/nemoclaw/node-tar-inventory.json \ - && chmod 0444 /usr/local/share/nemoclaw/node-tar-inventory.json + done diff --git a/agents/langchain-deepagents-code/Dockerfile b/agents/langchain-deepagents-code/Dockerfile index 84d89a554ae..8db93eb5953 100644 --- a/agents/langchain-deepagents-code/Dockerfile +++ b/agents/langchain-deepagents-code/Dockerfile @@ -354,19 +354,13 @@ RUN if [ "$NEMOCLAW_DARWIN_VM_COMPAT" = "1" ]; then \ chmod a+rw /sandbox/.bashrc /sandbox/.profile; \ fi -# Gate the completed local filesystem for dynamically built managed images. -COPY scripts/checks/node-tar-image-scan.mts /scripts/checks/node-tar-image-scan.mts +# Verify the managed bootstrap files in dynamically built images. RUN test -f /usr/local/bin/nemoclaw-managed-bootstrap \ && test ! -L /usr/local/bin/nemoclaw-managed-bootstrap \ && test "$(stat -c '%U:%G:%a' /usr/local/bin/nemoclaw-managed-bootstrap)" = 'root:root:755' \ && test -f /usr/local/lib/nemoclaw/managed-bootstrap-trampoline.sh \ && test ! -L /usr/local/lib/nemoclaw/managed-bootstrap-trampoline.sh \ - && test "$(stat -c '%U:%G:%a' /usr/local/lib/nemoclaw/managed-bootstrap-trampoline.sh)" = 'root:root:444' \ - && install -d -m 0755 /usr/local/share/nemoclaw \ - && node --experimental-strip-types /scripts/checks/node-tar-image-scan.mts \ - --root / --image build:deepagents-code \ - > /usr/local/share/nemoclaw/node-tar-inventory.json \ - && chmod 0444 /usr/local/share/nemoclaw/node-tar-inventory.json + && test "$(stat -c '%U:%G:%a' /usr/local/lib/nemoclaw/managed-bootstrap-trampoline.sh)" = 'root:root:444' # Verify the immutable security package inventory in the completed image. # hadolint ignore=DL4006 diff --git a/agents/langchain-deepagents-code/Dockerfile.base b/agents/langchain-deepagents-code/Dockerfile.base index 2e42f14bab8..95861917705 100644 --- a/agents/langchain-deepagents-code/Dockerfile.base +++ b/agents/langchain-deepagents-code/Dockerfile.base @@ -336,11 +336,3 @@ ENV HOME=/sandbox \ DEEPAGENTS_CODE_NO_UPDATE_CHECK=1 WORKDIR /sandbox - -# Gate the exact completed base filesystem before it can be published. -COPY scripts/checks/node-tar-image-scan.mts /scripts/checks/node-tar-image-scan.mts -RUN install -d -m 0755 /usr/local/share/nemoclaw \ - && node --experimental-strip-types /scripts/checks/node-tar-image-scan.mts \ - --root / --image build:deepagents-code-base \ - > /usr/local/share/nemoclaw/node-tar-inventory.json \ - && chmod 0444 /usr/local/share/nemoclaw/node-tar-inventory.json diff --git a/scripts/checks/node-tar-image-scan.mts b/scripts/checks/node-tar-image-scan.mts deleted file mode 100755 index cd764da2b71..00000000000 --- a/scripts/checks/node-tar-image-scan.mts +++ /dev/null @@ -1,180 +0,0 @@ -#!/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 { closeSync, constants, fstatSync, openSync, readFileSync, realpathSync } from "node:fs"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -// This script is also bind-mounted into completed images as a standalone file. -// The test suite binds this floor to the patcher's canonical remediation floor. -export const MINIMUM_SAFE_NODE_TAR_VERSION = "7.5.19"; - -type ExactVersion = readonly [number, number, number]; - -export type NodeTarImagePackage = Readonly<{ - aliases: readonly string[]; - device: string; - inode: string; - physicalPath: string; - status: "affected" | "fixed" | "invalid"; - version: string; -}>; - -export type NodeTarImageScan = Readonly<{ - image: string; - minimumVersion: string; - packageCount: number; - packages: readonly NodeTarImagePackage[]; - schema: 1; -}>; - -function parseExactVersion(version: string): ExactVersion | undefined { - const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u.exec(version); - return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : undefined; -} - -function atLeast(version: ExactVersion, minimum: ExactVersion): boolean { - for (let index = 0; index < version.length; index += 1) { - if (version[index] !== minimum[index]) return version[index]! > minimum[index]!; - } - return true; -} - -function findPackageManifests(root: string): string[] { - const result = spawnSync( - "find", - ["-L", root, "-xdev", "-type", "f", "-path", "*/node_modules/tar/package.json", "-print0"], - { encoding: "buffer", maxBuffer: 64 * 1024 * 1024 }, - ); - if (result.error) throw result.error; - if (result.status !== 0) { - throw new Error(`node-tar filesystem search failed: ${result.stderr.toString("utf8").trim()}`); - } - return result.stdout.toString("utf8").split("\0").filter(Boolean).sort(); -} - -function readPackageManifest(manifestPath: string): { - device: string; - inode: string; - physicalPath: string; - value: unknown; -} { - const descriptor = openSync(manifestPath, constants.O_RDONLY | constants.O_NOFOLLOW); - try { - const metadata = fstatSync(descriptor); - if (!metadata.isFile()) throw new Error(`node-tar manifest is not a file: ${manifestPath}`); - const physicalManifestPath = realpathSync(manifestPath); - let value: unknown; - try { - value = JSON.parse(readFileSync(descriptor, "utf8")); - } catch { - value = undefined; - } - return { - device: String(metadata.dev), - inode: String(metadata.ino), - physicalPath: dirname(physicalManifestPath), - value, - }; - } finally { - closeSync(descriptor); - } -} - -export function scanNodeTarImage(root: string, image: string): NodeTarImageScan { - const scanRoot = resolve(root); - const grouped = new Map< - string, - { aliases: Set; device: string; inode: string; physicalPath: string; version: string } - >(); - for (const manifestPath of findPackageManifests(scanRoot)) { - const { device, inode, physicalPath, value: manifest } = readPackageManifest(manifestPath); - const key = physicalPath; - const version = - typeof manifest === "object" && - manifest !== null && - !Array.isArray(manifest) && - (manifest as Record).name === "tar" && - typeof (manifest as Record).version === "string" - ? String((manifest as Record).version) - : ""; - const existing = grouped.get(key); - if (existing && existing.version !== version) { - throw new Error(`node-tar manifest changed while scanning: ${manifestPath}`); - } - const entry = existing ?? { - aliases: new Set(), - device, - inode, - physicalPath, - version, - }; - entry.aliases.add(dirname(manifestPath)); - grouped.set(key, entry); - } - - const minimum = parseExactVersion(MINIMUM_SAFE_NODE_TAR_VERSION)!; - const packages = [...grouped.values()] - .map((entry): NodeTarImagePackage => { - const version = parseExactVersion(entry.version); - return { - aliases: [...entry.aliases].sort(), - device: entry.device, - inode: entry.inode, - physicalPath: entry.physicalPath, - status: version ? (atLeast(version, minimum) ? "fixed" : "affected") : "invalid", - version: entry.version, - }; - }) - .sort((first, second) => first.physicalPath.localeCompare(second.physicalPath)); - - return { - image, - minimumVersion: MINIMUM_SAFE_NODE_TAR_VERSION, - packageCount: packages.length, - packages, - schema: 1, - }; -} - -export function nodeTarImageScanErrors(scan: NodeTarImageScan): string[] { - const errors: string[] = []; - if (scan.packageCount === 0) - errors.push("completed image contains no discoverable node-tar copy"); - for (const entry of scan.packages) { - if (entry.status !== "fixed") { - errors.push( - `${entry.physicalPath} contains ${entry.version ? `tar@${entry.version}` : "invalid tar metadata"}`, - ); - } - } - return errors; -} - -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 scan = scanNodeTarImage(argument("--root"), argument("--image")); - process.stdout.write(`${JSON.stringify(scan, null, 2)}\n`); - const errors = nodeTarImageScanErrors(scan); - if (errors.length > 0) { - for (const error of errors) console.error(`ERROR: ${error}`); - process.exitCode = 1; - } - } catch (error) { - console.error(`ERROR: ${error instanceof Error ? error.message : String(error)}`); - process.exitCode = 1; - } -} diff --git a/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts b/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts index 2229b939955..b824aa4cf4a 100644 --- a/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts +++ b/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts @@ -17,8 +17,6 @@ const OPENCLAW_CONFIG_GENERATOR_RE = const SAFE_VALIDATION_GENERATOR_RE = /^RUN\s+validation_home="\$validation_root\/progressive";\s+HOME=(?:"\$validation_home"|\$validation_home)\s+node\s+--experimental-strip-types\s+\/scripts\/generate-openclaw-config\.mts$/; const PASSIVE_FINAL_STAGE_INSTRUCTION_RE = /^(?:ARG|ENV|WORKDIR|USER|HEALTHCHECK|ENTRYPOINT|CMD)\b/; -const NODE_TAR_IMAGE_SCAN_COPY_RE = - /^COPY scripts\/checks\/node-tar-image-scan\.mts \/scripts\/checks\/node-tar-image-scan\.mts$/; const CONFIG_MODE_RE = /^RUN\s+chmod\s+660\s+\/sandbox\/\.openclaw\/openclaw\.json$/; const CONFIG_HASH_RE = /^RUN\s+sha256sum\s+\/sandbox\/\.openclaw\/openclaw\.json\s+>\s+\/sandbox\/\.openclaw\/\.config-hash(?:\s+&&\s+chmod\s+660\s+\/sandbox\/\.openclaw\/\.config-hash)?(?:\s+&&\s+chown\s+sandbox:sandbox\s+\/sandbox\/\.openclaw\/\.config-hash)?$/; @@ -58,7 +56,7 @@ const CANONICAL_POST_GENERATOR_RUN_SHA256 = new Set([ "a0a554d474cb70087e50686d998915eae06201d6182a2410d3ccc4879e5058e6", "5af905889f94ffed2f6c371111d0589e38eed7b0de54ddb0dd68ad912a23149a", "1197b99bdb996b37a3e4e386a507dfabcdfb2c26a40b015d617f97208668187d", - "922a821187f11a892b03c66af8478ab36041048b37b7111e20620dda8e85557d", + "a619aead6cdf253dc7bf4504267e6b1d724fed672597072394b7400c08f81fd0", "c0b409e1bf4d33a9e44f407c6bd9b0445b2ffd0b796823fe3cfa5989314d6603", "9fcc674a44a152707380cdb09a67f8594f568288406c96f5354f1c87f5b939a6", "83567d1fa0e73bef6a3333383c13ace05e26704964ae6a7a76ee24a2f2be3d7e", @@ -75,7 +73,6 @@ function instructionSha256(text: string): string { const postGeneratorInstructionAllowed = (instruction: DockerfileInstruction): boolean => { const { text } = instruction; if (PASSIVE_FINAL_STAGE_INSTRUCTION_RE.test(text)) return true; - if (NODE_TAR_IMAGE_SCAN_COPY_RE.test(text)) return true; if (SAFE_VALIDATION_GENERATOR_RE.test(text)) return true; if (EXACT_CUSTOM_POST_GENERATOR_RUN_RE.some((pattern) => pattern.test(text))) return true; return CANONICAL_POST_GENERATOR_RUN_SHA256.has(instructionSha256(text)); diff --git a/src/lib/sandbox-base-image/source-identity.test.ts b/src/lib/sandbox-base-image/source-identity.test.ts index 8abec5252f5..dc526963471 100644 --- a/src/lib/sandbox-base-image/source-identity.test.ts +++ b/src/lib/sandbox-base-image/source-identity.test.ts @@ -179,7 +179,6 @@ describe("sandbox base-image source identity", () => { "scripts/security/build-perl-security-packages.sh", "scripts/lib/openclaw-npm-remediation.mts", "scripts/lib/reviewed-npm-archive.mts", - "scripts/checks/node-tar-image-scan.mts", "scripts/patch-bundled-npm-brace-expansion.mts", "scripts/lib/patch-bundled-npm-ip-address.mts", "scripts/patch-bundled-npm-tar.mts", diff --git a/src/lib/sandbox-base-image/source-identity.ts b/src/lib/sandbox-base-image/source-identity.ts index 4516395609d..df9ac5ce6af 100644 --- a/src/lib/sandbox-base-image/source-identity.ts +++ b/src/lib/sandbox-base-image/source-identity.ts @@ -16,7 +16,6 @@ export const BASE_IMAGE_INPUT_PATHS = [ "scripts/security/build-perl-security-packages.sh", "scripts/lib/openclaw-npm-remediation.mts", "scripts/lib/reviewed-npm-archive.mts", - "scripts/checks/node-tar-image-scan.mts", "scripts/patch-bundled-npm-brace-expansion.mts", "scripts/lib/patch-bundled-npm-ip-address.mts", "scripts/patch-bundled-npm-tar.mts", diff --git a/src/lib/sandbox/build-context.ts b/src/lib/sandbox/build-context.ts index 86a9e2451f6..d45244c5e8a 100644 --- a/src/lib/sandbox/build-context.ts +++ b/src/lib/sandbox/build-context.ts @@ -225,10 +225,6 @@ function stageOptimizedSandboxBuildContext( path.join(rootDir, "scripts", "checks", "verify-openshell-policy-boundary-dependencies.mts"), path.join(stagedScriptsDir, "checks", "verify-openshell-policy-boundary-dependencies.mts"), ); - fs.copyFileSync( - path.join(rootDir, "scripts", "checks", "node-tar-image-scan.mts"), - path.join(stagedScriptsDir, "checks", "node-tar-image-scan.mts"), - ); fs.copyFileSync( path.join(rootDir, "scripts", "nemoclaw-start.sh"), path.join(stagedScriptsDir, "nemoclaw-start.sh"), diff --git a/test/dashboard-remote-bind-lifecycle.test.ts b/test/dashboard-remote-bind-lifecycle.test.ts index 63d1a311ba0..877e40ec108 100644 --- a/test/dashboard-remote-bind-lifecycle.test.ts +++ b/test/dashboard-remote-bind-lifecycle.test.ts @@ -141,15 +141,17 @@ describe("remote dashboard bind production lifecycle", () => { } }); - it("rejects config rewrites appended to the checked-in image scan (#6024)", () => { + it("rejects config rewrites appended to checked-in metadata validation (#6024)", () => { vi.stubEnv("NEMOCLAW_DASHBOARD_BIND", "0.0.0.0"); - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-remote-bind-scan-")); + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-remote-bind-metadata-")); const dockerfile = path.join(directory, "Dockerfile"); const stockDockerfile = fs.readFileSync(path.join(process.cwd(), "Dockerfile"), "utf8"); - const scanTail = " && chmod 0444 /usr/local/share/nemoclaw/node-tar-inventory.json"; + const metadataTail = + " && check_metadata /usr/local/lib/nemoclaw/preloads/sandbox-safety-net.js 'root:root:644'"; const mutatedDockerfile = stockDockerfile.replace( - scanTail, - `${scanTail} \\\n && printf '{}' > /sandbox/.openclaw/openclaw.json`, + metadataTail, + `${metadataTail} \\ + && printf '{}' > /sandbox/.openclaw/openclaw.json`, ); fs.writeFileSync(dockerfile, mutatedDockerfile); diff --git a/test/e2e/support/sandbox-images-workflow-boundary.test.ts b/test/e2e/support/sandbox-images-workflow-boundary.test.ts index b76ac91d0f6..40e957dd831 100644 --- a/test/e2e/support/sandbox-images-workflow-boundary.test.ts +++ b/test/e2e/support/sandbox-images-workflow-boundary.test.ts @@ -169,7 +169,7 @@ describe("sandbox image workflow boundary", () => { expect(validateSandboxImagesWorkflow(imageWorkflow, mainWorkflow)).toEqual( expect.arrayContaining([ "Hermes producer must build the production image exactly once with the canonical local-load Buildx action and OS/architecture-scoped GHA cache", - "Hermes producer must prove the no-CA final image uses default trust before completed-image scans", + "Hermes producer must prove the no-CA final image uses default trust after the production image build", ]), ); }); @@ -498,43 +498,6 @@ describe("sandbox image workflow boundary", () => { ); }); - it("requires hardened completed-image node-tar scans and retained evidence", () => { - const { imageWorkflow, mainWorkflow } = readWorkflows(); - const openclaw = imageWorkflow.jobs["build-sandbox-images"]; - const openclawScan = openclaw.steps!.find( - (step) => step.name === "Scan completed OpenClaw image for node-tar", - )!; - openclawScan.run = openclawScan - .run!.replace("--network none", "--network host") - .replaceAll("/scripts/checks/node-tar-image-scan.mts", "/tmp/node-tar-image-scan.mts"); - - const hermes = imageWorkflow.jobs["build-hermes-sandbox-image"]; - const hermesUpload = hermes.steps!.find( - (step) => step.name === "Upload Hermes node-tar inventory", - )!; - hermesUpload.with!["retention-days"] = 1; - - const arm = imageWorkflow.jobs["build-sandbox-images-arm64"]; - const armSteps = arm.steps!; - const armUploadIndex = armSteps.findIndex( - (step) => step.name === "Upload OpenClaw arm64 node-tar inventory", - ); - const [armUpload] = armSteps.splice(armUploadIndex, 1); - const armBoundaryIndex = armSteps.findIndex( - (step) => step.name === "Build sandbox test image on arm64", - ); - armSteps.splice(armBoundaryIndex + 1, 0, armUpload!); - - expect(validateSandboxImagesWorkflow(imageWorkflow, mainWorkflow)).toEqual( - expect.arrayContaining([ - "build-sandbox-images node-tar scan must include --network none", - expect.stringContaining("build-sandbox-images node-tar scan must include -v"), - "build-hermes-sandbox-image must retain its node-tar inventory for 14 days", - "build-sandbox-images-arm64 must scan and retain evidence before the completed image is handed off", - ]), - ); - }); - it("keeps messaging plan image probes isolated, guarded, local, and verified", () => { const { imageWorkflow, mainWorkflow } = readWorkflows(); const probe = imageWorkflow.jobs["messaging-plan-image-boundary"]; diff --git a/test/hermes-final-image-layout.test.ts b/test/hermes-final-image-layout.test.ts index 9a46fb24b22..e965774a47c 100644 --- a/test/hermes-final-image-layout.test.ts +++ b/test/hermes-final-image-layout.test.ts @@ -307,18 +307,11 @@ describe("Hermes final image layout", () => { "COPY agents/hermes/hermes-cli-adapter-v1.json /usr/local/share/nemoclaw/hermes-cli-adapter-v1.json", ], }, - { - stage: "hermes-scan-payload", - copies: [ - "COPY scripts/checks/node-tar-image-scan.mts /scripts/checks/node-tar-image-scan.mts", - ], - }, ] as const; const npmPatchCopy = "COPY --from=hermes-npm-patch-payload / /"; const agentCopy = "COPY --from=hermes-agent-payload / /"; const runtimeCopy = "COPY --from=hermes-runtime-payload / /"; const wrapperCopy = "COPY --from=hermes-wrapper-payload / /"; - const scanCopy = "COPY --from=hermes-scan-payload / /"; expect(finalStageIndex).toBe(stages.length - 1); expect(hasBuildKitRunMount(dockerfile)).toBe(false); @@ -333,13 +326,11 @@ describe("Hermes final image layout", () => { agentCopy, runtimeCopy, wrapperCopy, - scanCopy, ]); const npmPatch = indexOfRequired(finalStage, npmPatchCopy); const agent = indexOfRequired(finalStage, agentCopy); const runtime = indexOfRequired(finalStage, runtimeCopy); const wrapper = indexOfRequired(finalStage, wrapperCopy); - const scan = indexOfRequired(finalStage, scanCopy); const tarPatch = requireSingleReviewedDockerfileRunCommand( finalStage, "node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts", @@ -391,10 +382,6 @@ describe("Hermes final image layout", () => { ); const metadataCheck = indexOfRequired(finalStage, "RUN check_metadata()"); const modeNormalize = indexOfRequired(finalStage, "RUN chmod 755 \\"); - const imageScan = indexOfRequired( - finalStage, - "node --experimental-strip-types /scripts/checks/node-tar-image-scan.mts", - ); expect(npmPatch).toBeLessThan(tarPatch); expect(agent).toBeGreaterThan(certifiInstall); @@ -415,9 +402,7 @@ describe("Hermes final image layout", () => { expect(dockerfile).toContain("src/lib/onboard/managed-bootstrap/image-runtime.ts"); expect(wrapper).toBeGreaterThan(tirithFinalizerHash); expect(wrapper).toBeLessThan(pythonCheck); - expect(scan).toBeGreaterThan(darwinCompatibility); - expect(scan).toBeLessThan(metadataCheck); - expect(modeNormalize).toBeGreaterThan(scan); + expect(modeNormalize).toBeGreaterThan(darwinCompatibility); expect(modeNormalize).toBeLessThan(metadataCheck); for (const metadataContract of [ "/scripts/patch-bundled-npm-brace-expansion.mts 'root:root 444'", @@ -439,12 +424,9 @@ describe("Hermes final image layout", () => { "/usr/local/lib/nemoclaw/hermes-wrapper.py 'root:root 755'", "/usr/local/lib/nemoclaw/validate-hermes-cli-adapter.py 'root:root 755'", "/usr/local/share/nemoclaw/hermes-cli-adapter-v1.json 'root:root 444'", - "/scripts/checks/node-tar-image-scan.mts 'root:root 755'", ]) { expect(finalStage).toContain(`check_metadata ${metadataContract}`); } - expect(metadataCheck).toBeGreaterThan(scan); - expect(metadataCheck).toBeLessThan(imageScan); expect(doctorLayer).toContain( "HERMES_HOME=/sandbox/.hermes /usr/local/bin/hermes doctor --fix", ); diff --git a/test/managed-image-publication-workflow.test.ts b/test/managed-image-publication-workflow.test.ts index 77cc7130631..3b178f33b40 100644 --- a/test/managed-image-publication-workflow.test.ts +++ b/test/managed-image-publication-workflow.test.ts @@ -186,9 +186,7 @@ function publicationBoundaryErrors(baseWorkflow: Workflow, managedWorkflow: Work 'DOCKER_CONFIG="$anonymous_config" docker pull --platform "$PLATFORM" "$reference"', "bootstrap the GHCR package", "/opt/nemoclaw-blueprint/blueprint.yaml", - "/usr/local/share/nemoclaw/node-tar-inventory.json", "/usr/local/share/nemoclaw/corporate-ca.pem", - 'entry.status !== "fixed"', '--entrypoint "$REQUIRED_BINARY"', "io.nvidia.nemoclaw.managed-image.contract", "io.nvidia.nemoclaw.managed-image.startup-profile", diff --git a/test/node-tar-dockerfile-contract.test.ts b/test/node-tar-dockerfile-contract.test.ts index 0e4eca19c38..2aefed47ff9 100644 --- a/test/node-tar-dockerfile-contract.test.ts +++ b/test/node-tar-dockerfile-contract.test.ts @@ -74,9 +74,7 @@ describe("node-tar image remediation contract", () => { expect(patchRun, file).toBeGreaterThan(curlInstall); }); - it.each( - dockerfiles, - )("patches npm before use and scans the completed $file filesystem", (entry) => { + it.each(dockerfiles)("patches npm before use in the completed $file filesystem", (entry) => { const { file, installsPatchDownloader, installsWithNpm } = entry; const dockerfile = fs.readFileSync(path.join(repoRoot, file), "utf8"); const source = completedStage(dockerfile); @@ -85,11 +83,8 @@ describe("node-tar image remediation contract", () => { ); const patchPayloadLayer = patchPayloadStage === undefined ? -1 : source.indexOf(`COPY --from=${patchPayloadStage} / /`); - const scanPayloadLayer = source.indexOf("COPY --from=hermes-scan-payload / /"); const patchInputStage = patchPayloadStage === undefined ? source : namedStage(dockerfile, patchPayloadStage); - const scanInputStage = - scanPayloadLayer >= 0 ? namedStage(dockerfile, "hermes-scan-payload") : source; const flattenedPatchInputStage = patchInputStage.replace(/\\\s*\n/g, " ").replace(/\s+/g, " "); const reviewedCopy = patchInputStage.indexOf("COPY scripts/lib/reviewed-npm-archive.mts"); const patchCopy = patchInputStage.indexOf( @@ -100,14 +95,7 @@ describe("node-tar image remediation contract", () => { patchCommand, npmRootArguments, ).commandStart; - const scanCopy = scanInputStage.indexOf( - "COPY scripts/checks/node-tar-image-scan.mts /scripts/checks/node-tar-image-scan.mts", - ); - const scanRun = source.indexOf( - "node --experimental-strip-types /scripts/checks/node-tar-image-scan.mts", - ); const patchInputReady = patchPayloadLayer >= 0 ? patchPayloadLayer : patchCopy; - const scanInputReady = scanPayloadLayer >= 0 ? scanPayloadLayer : scanCopy; expect(reviewedCopy, file).toBeGreaterThanOrEqual(0); expect( @@ -134,11 +122,6 @@ describe("node-tar image remediation contract", () => { aptInstallCleanup < patchRun, file, ).toBe(installsPatchDownloader); - expect(scanCopy, file).toBeGreaterThanOrEqual(0); - expect(scanInputReady, file).toBeGreaterThan(patchRun); - expect(scanRun, file).toBeGreaterThan(scanInputReady); - expect(source, file).toContain("> /usr/local/share/nemoclaw/node-tar-inventory.json"); - const executableSource = source.replace(/^\s*#.*$/gmu, (comment) => " ".repeat(comment.length)); const npmConsumers = [...executableSource.matchAll(/\bnpm\s+(?:ci|install)\b/gu)].map( (match) => match.index, diff --git a/test/node-tar-image-scan.test.ts b/test/node-tar-image-scan.test.ts deleted file mode 100644 index 1524883ee14..00000000000 --- a/test/node-tar-image-scan.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { spawnSync } from "node:child_process"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { afterEach, describe, expect, it } from "vitest"; - -import { - MINIMUM_SAFE_NODE_TAR_VERSION, - nodeTarImageScanErrors, - scanNodeTarImage, -} from "../scripts/checks/node-tar-image-scan.mts"; -import { MINIMUM_SAFE_TAR_VERSION } from "../scripts/patch-bundled-npm-tar.mts"; - -const temporaryDirectories: string[] = []; - -function temporaryDirectory(): string { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-node-tar-scan-")); - temporaryDirectories.push(directory); - return directory; -} - -function writeTar(root: string, location: string, version: string): string { - const packageRoot = path.join(root, location, "node_modules", "tar"); - fs.mkdirSync(packageRoot, { recursive: true }); - fs.writeFileSync( - path.join(packageRoot, "package.json"), - `${JSON.stringify({ name: "tar", version })}\n`, - ); - return packageRoot; -} - -afterEach(() => { - for (const directory of temporaryDirectories.splice(0)) { - fs.rmSync(directory, { force: true, recursive: true }); - } -}); - -describe("completed-image node-tar scan", () => { - it("runs as a standalone mounted script with the canonical safety floor", () => { - const directory = temporaryDirectory(); - const scanRoot = path.join(directory, "root"); - fs.mkdirSync(scanRoot); - writeTar(scanRoot, "opt/nemoclaw", "7.5.20"); - const standaloneScanner = path.join(directory, "node-tar-image-scan.mts"); - fs.copyFileSync( - path.join(import.meta.dirname, "..", "scripts", "checks", "node-tar-image-scan.mts"), - standaloneScanner, - ); - - const result = spawnSync( - process.execPath, - [ - "--experimental-strip-types", - fs.realpathSync(standaloneScanner), - "--root", - scanRoot, - "--image", - "standalone-fixture", - ], - { encoding: "utf8" }, - ); - - expect(result.status, result.stderr).toBe(0); - expect(MINIMUM_SAFE_NODE_TAR_VERSION).toBe(MINIMUM_SAFE_TAR_VERSION); - expect(JSON.parse(result.stdout)).toMatchObject({ - image: "standalone-fixture", - minimumVersion: MINIMUM_SAFE_TAR_VERSION, - packageCount: 1, - }); - }); - - it("enumerates fixed and affected physical installations", () => { - const root = temporaryDirectory(); - writeTar(root, "opt/nemoclaw", "7.5.20"); - writeTar(root, "usr/local/lib/node_modules/npm", "7.5.13"); - - const scan = scanNodeTarImage(root, "fixture"); - expect(scan.packages.map(({ status, version }) => ({ status, version }))).toEqual([ - { status: "fixed", version: "7.5.20" }, - { status: "affected", version: "7.5.13" }, - ]); - expect(nodeTarImageScanErrors(scan)).toEqual([expect.stringContaining("tar@7.5.13")]); - }); - - it("groups symlink aliases by physical package directory", () => { - const root = temporaryDirectory(); - const physical = writeTar(root, "opt/physical", "7.5.19"); - fs.mkdirSync(path.join(root, "opt/alias", "node_modules"), { recursive: true }); - fs.symlinkSync(physical, path.join(root, "opt/alias", "node_modules", "tar")); - - const scan = scanNodeTarImage(root, "fixture"); - expect(scan.packageCount).toBe(1); - expect(scan.packages[0]?.aliases).toHaveLength(2); - expect(nodeTarImageScanErrors(scan)).toEqual([]); - }); - - it("keeps distinct package directories separate when manifests are hardlinked", () => { - const root = temporaryDirectory(); - const first = writeTar(root, "opt/first", "7.5.20"); - const second = path.join(root, "opt/second", "node_modules", "tar"); - fs.mkdirSync(second, { recursive: true }); - fs.linkSync(path.join(first, "package.json"), path.join(second, "package.json")); - - const scan = scanNodeTarImage(root, "fixture"); - expect(scan.packageCount).toBe(2); - expect(scan.packages.map((entry) => entry.physicalPath)).toEqual([ - fs.realpathSync(first), - fs.realpathSync(second), - ]); - }); - - it("fails closed for invalid metadata and an empty inventory", () => { - const invalidRoot = temporaryDirectory(); - writeTar(invalidRoot, "opt/invalid", "latest"); - const invalid = scanNodeTarImage(invalidRoot, "invalid"); - expect(invalid.packages[0]).toMatchObject({ status: "invalid", version: "latest" }); - expect(nodeTarImageScanErrors(invalid)).toHaveLength(1); - - const empty = scanNodeTarImage(temporaryDirectory(), "empty"); - expect(nodeTarImageScanErrors(empty)).toEqual([ - "completed image contains no discoverable node-tar copy", - ]); - }); -}); diff --git a/test/openclaw-final-image-layout.test.ts b/test/openclaw-final-image-layout.test.ts index 320ca804088..ff5b07bfde5 100644 --- a/test/openclaw-final-image-layout.test.ts +++ b/test/openclaw-final-image-layout.test.ts @@ -121,8 +121,6 @@ describe("OpenClaw final image layout", () => { const pluginCopy = "COPY --from=openclaw-plugin-payload / /"; const patchCopy = "COPY --from=openclaw-patch-payload / /"; const runtimeCopy = "COPY --from=openclaw-runtime-payload / /"; - const scanCopy = - "COPY scripts/checks/node-tar-image-scan.mts /scripts/checks/node-tar-image-scan.mts"; expect(finalStageIndex).toBe(stages.length - 1); expect(hasBuildKitRunMount(dockerfile)).toBe(false); @@ -146,7 +144,6 @@ describe("OpenClaw final image layout", () => { pluginCopy, patchCopy, runtimeCopy, - scanCopy, ]); for (const metadataContract of [ "/scripts/patch-bundled-npm-brace-expansion.mts 'root:root:755'", @@ -161,7 +158,6 @@ describe("OpenClaw final image layout", () => { "/usr/local/lib/nemoclaw/state-dir-guard.py 'root:root:500'", "/usr/local/share/nemoclaw/state-lock-plan.json 'root:root:444'", "/usr/local/lib/nemoclaw/preloads/sandbox-safety-net.js 'root:root:644'", - "/scripts/checks/node-tar-image-scan.mts 'root:root:755'", ]) { expect(finalStage).toContain(`check_metadata ${metadataContract}`); } @@ -170,7 +166,6 @@ describe("OpenClaw final image layout", () => { const plugin = indexOfRequired(finalStage, pluginCopy); const patch = indexOfRequired(finalStage, patchCopy); const runtime = indexOfRequired(finalStage, runtimeCopy); - const scan = indexOfRequired(finalStage, scanCopy); const tarPatch = indexOfRequired( finalStage, "RUN node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts", @@ -220,7 +215,6 @@ describe("OpenClaw final image layout", () => { "&& install -d -o root -g root -m 0755 /run/nemoclaw", ); const runtimeChmod = indexOfRequired(finalStage, "RUN chmod 755 /usr/local/bin/nemoclaw-start"); - const metadataCheck = indexOfRequired(finalStage, "RUN check_metadata()"); expect(dependency).toBeLessThan(tarPatch); expect(tarPatch).toBeLessThan(braceExpansionPatch); @@ -247,6 +241,5 @@ describe("OpenClaw final image layout", () => { ); expect(dockerfile).toContain("src/lib/onboard/managed-bootstrap/image-runtime.ts"); expect(runtime).toBeLessThan(runtimeChmod); - expect(scan).toBeLessThan(metadataCheck); }); }); diff --git a/test/sandbox-build-context.test.ts b/test/sandbox-build-context.test.ts index 6942f5f7e96..73e9495e3e1 100644 --- a/test/sandbox-build-context.test.ts +++ b/test/sandbox-build-context.test.ts @@ -119,7 +119,6 @@ describe("sandbox build context staging", () => { writeFixture( path.join("scripts", "checks", "verify-openshell-policy-boundary-dependencies.mts"), ); - writeFixture(path.join("scripts", "checks", "node-tar-image-scan.mts")); writeFixture(path.join("scripts", "lib", "sandbox-init.sh")); writeFixture(path.join("scripts", "lib", "entrypoint-env-wrapper.sh")); writeFixture(path.join("scripts", "lib", "gateway-supervisor.sh")); @@ -672,9 +671,6 @@ describe("sandbox build context staging", () => { fs.existsSync(path.join(buildCtx, "scripts", "lib", "patch-bundled-npm-ip-address.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); expect( fs.existsSync(path.join(buildCtx, "scripts", "patch-openclaw-device-self-approval.ts")), ).toBe(false); diff --git a/tools/e2e/sandbox-images-workflow-boundary.mts b/tools/e2e/sandbox-images-workflow-boundary.mts index b1058190bc4..c355f3c4b13 100644 --- a/tools/e2e/sandbox-images-workflow-boundary.mts +++ b/tools/e2e/sandbox-images-workflow-boundary.mts @@ -119,45 +119,6 @@ const GUARDED_PRODUCTION_BUILD_CONTRACTS: readonly GuardedProductionBuildContrac }, ]; -type NodeTarImageScanContract = Readonly<{ - artifactName: string; - boundaryStepName: string; - evidencePath: string; - jobName: "build-hermes-sandbox-image" | "build-sandbox-images" | "build-sandbox-images-arm64"; - scanStepName: string; - target: string; - uploadStepName: string; -}>; - -const NODE_TAR_IMAGE_SCAN_CONTRACTS: readonly NodeTarImageScanContract[] = [ - { - artifactName: "openclaw-node-tar-inventory", - boundaryStepName: "Save images to tarballs", - evidencePath: "/tmp/openclaw-node-tar-inventory.json", - jobName: "build-sandbox-images", - scanStepName: "Scan completed OpenClaw image for node-tar", - target: "nemoclaw-production", - uploadStepName: "Upload OpenClaw node-tar inventory", - }, - { - artifactName: "hermes-node-tar-inventory", - boundaryStepName: "Save Hermes production image", - evidencePath: "/tmp/hermes-node-tar-inventory.json", - jobName: "build-hermes-sandbox-image", - scanStepName: "Scan completed Hermes image for node-tar", - target: "nemoclaw-hermes-production", - uploadStepName: "Upload Hermes node-tar inventory", - }, - { - artifactName: "openclaw-arm64-node-tar-inventory", - boundaryStepName: "Build sandbox test image on arm64", - evidencePath: "/tmp/openclaw-arm64-node-tar-inventory.json", - jobName: "build-sandbox-images-arm64", - scanStepName: "Scan completed OpenClaw arm64 image for node-tar", - target: "nemoclaw-production-arm64", - uploadStepName: "Upload OpenClaw arm64 node-tar inventory", - }, -]; type WorkflowRecord = Record; @@ -495,12 +456,10 @@ function validateGuardedProductionBuild( steps(job).filter((step) => step.name === HERMES_DEFAULT_TRUST_STEP_NAME).length !== 1 || defaultTrust.shell !== "bash" || requiredDefaultTrustFragments.some((fragment) => !defaultTrustRun.includes(fragment)) || - stepIndex(job, action.name ?? "") >= stepIndex(job, HERMES_DEFAULT_TRUST_STEP_NAME) || - stepIndex(job, HERMES_DEFAULT_TRUST_STEP_NAME) >= - stepIndex(job, "Scan completed Hermes image for node-tar") + stepIndex(job, action.name ?? "") >= stepIndex(job, HERMES_DEFAULT_TRUST_STEP_NAME) ) { errors.push( - "Hermes producer must prove the no-CA final image uses default trust before completed-image scans", + "Hermes producer must prove the no-CA final image uses default trust after the production image build", ); } return; @@ -528,94 +487,6 @@ function validateGuardedProductionBuildContracts( } } -function validateNodeTarImageScan( - errors: string[], - workflow: SandboxImagesWorkflow, - contract: NodeTarImageScanContract, -): void { - const job = workflow.jobs[contract.jobName] ?? {}; - const scan = requireStep(errors, contract.jobName, job, contract.scanStepName); - const upload = requireStep(errors, contract.jobName, job, contract.uploadStepName); - if (steps(job).filter((step) => step.name === contract.scanStepName).length !== 1) { - errors.push(`${contract.jobName} must scan its completed image exactly once`); - } - if (scan.id !== "node-tar-scan" || scan.shell !== "bash") { - errors.push(`${contract.jobName} node-tar scan must expose its outcome from a bash step`); - } - - const run = normalizedShell(scan.run); - const requiredFragments = [ - "set -euo pipefail", - `image_id="$(docker image inspect --format '{{.Id}}' ${contract.target})"`, - "docker run --rm", - "--network none", - "--read-only", - "--cap-drop ALL", - "--cap-add DAC_READ_SEARCH", - "--security-opt no-new-privileges", - "--pids-limit 64", - "--memory 256m", - "--entrypoint node", - '-v "${{ github.workspace }}/scripts/checks/node-tar-image-scan.mts:/scripts/checks/node-tar-image-scan.mts:ro"', - contract.target, - "--experimental-strip-types /scripts/checks/node-tar-image-scan.mts", - "--root /", - '--image "${image_id}"', - `| tee ${contract.evidencePath}`, - ]; - for (const fragment of requiredFragments) { - if (!run.includes(fragment)) { - errors.push(`${contract.jobName} node-tar scan must include ${fragment}`); - } - } - if (run.includes("--privileged") || run.includes("/var/run/docker.sock")) { - errors.push(`${contract.jobName} node-tar scan must remain isolated from host privileges`); - } - - if ( - steps(job).filter((step) => step.name === contract.uploadStepName).length !== 1 || - upload.if !== "${{ always() && steps.node-tar-scan.outcome != 'skipped' }}" || - !FULL_SHA_ACTION.test(upload.uses ?? "") || - !String(upload.uses ?? "").startsWith("actions/upload-artifact@") || - !isDeepStrictEqual(record(upload.with), { - name: contract.artifactName, - path: contract.evidencePath, - "if-no-files-found": "error", - "retention-days": 14, - }) - ) { - errors.push(`${contract.jobName} must retain its node-tar inventory for 14 days`); - } - - const buildIndex = stepIndex( - job, - contract.jobName === "build-hermes-sandbox-image" - ? "Build Hermes production image" - : contract.jobName === "build-sandbox-images-arm64" - ? "Build production image on arm64" - : "Build production image", - ); - const scanIndex = stepIndex(job, contract.scanStepName); - const uploadIndex = stepIndex(job, contract.uploadStepName); - const boundaryIndex = stepIndex(job, contract.boundaryStepName); - if ( - buildIndex < 0 || - scanIndex <= buildIndex || - uploadIndex <= scanIndex || - boundaryIndex <= uploadIndex - ) { - errors.push( - `${contract.jobName} must scan and retain evidence before the completed image is handed off`, - ); - } -} - -function validateNodeTarImageScans(errors: string[], workflow: SandboxImagesWorkflow): void { - for (const contract of NODE_TAR_IMAGE_SCAN_CONTRACTS) { - validateNodeTarImageScan(errors, workflow, contract); - } -} - function normalizedShell(run: string | undefined): string { return (run ?? "") .replace(/\\\r?\n\s*/gu, " ") @@ -1252,7 +1123,6 @@ export function validateSandboxImagesWorkflow( } validateSecretScopeAndRegistryWrites(errors, workflow); validateGuardedProductionBuildContracts(errors, workflow); - validateNodeTarImageScans(errors, workflow); validateHermesExportSwap(errors, workflow); validateMessagingPlanImageBoundary(errors, workflow); validateRuntimeImageReuse(errors, workflow); From f52401fcf49dab1134091e2c5709889894e8447c Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 8 Aug 2026 10:59:11 -0700 Subject: [PATCH 2/3] docs(security): update node-tar remediation evidence --- docs/security/sandbox-base-2026-07-25-dependency-review.md | 7 ++++--- test/node-tar-dockerfile-contract.test.ts | 4 +++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/security/sandbox-base-2026-07-25-dependency-review.md b/docs/security/sandbox-base-2026-07-25-dependency-review.md index a7ef9f9e08f..bb26a3ab6b8 100644 --- a/docs/security/sandbox-base-2026-07-25-dependency-review.md +++ b/docs/security/sandbox-base-2026-07-25-dependency-review.md @@ -5,7 +5,7 @@ Date: 2026-07-25 -Last updated: 2026-08-03 +Last updated: 2026-08-08 ## Scope @@ -107,7 +107,8 @@ The replacement helper: 10. invokes npm and npx only after the fixed package is active. All managed base images apply the helper after the complete npm upgrade. -Their final images reassert the same idempotent contract so the scanned filesystem, not an intermediate stage, owns the dependency boundary. +Each final image reruns the idempotent helper against `/usr/local/lib/node_modules/npm`, so the final image stage owns the bundled npm dependency boundary. +The image build does not scan the complete filesystem for other `tar` copies or retain a node-tar inventory artifact. ### Perl component versions @@ -193,4 +194,4 @@ Required evidence for the final pull-request head: - real reviewed npm archive replacement using the registry artifact; - repository formatting and type checks; - amd64 and arm64 builds for the OpenClaw, Hermes, and Deep Agents Code base images; and -- completed-image dependency inventory. +- final-stage npm remediation ordering checks for OpenClaw, Hermes, and Deep Agents Code. diff --git a/test/node-tar-dockerfile-contract.test.ts b/test/node-tar-dockerfile-contract.test.ts index 2aefed47ff9..6cef4000710 100644 --- a/test/node-tar-dockerfile-contract.test.ts +++ b/test/node-tar-dockerfile-contract.test.ts @@ -74,7 +74,9 @@ describe("node-tar image remediation contract", () => { expect(patchRun, file).toBeGreaterThan(curlInstall); }); - it.each(dockerfiles)("patches npm before use in the completed $file filesystem", (entry) => { + it.each( + dockerfiles, + )("places bundled npm tar remediation in the final $file stage before any npm consumers", (entry) => { const { file, installsPatchDownloader, installsWithNpm } = entry; const dockerfile = fs.readFileSync(path.join(repoRoot, file), "utf8"); const source = completedStage(dockerfile); From 75fc1cf116c74c4548829c47f30ef5bbab0a8b94 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 8 Aug 2026 11:03:35 -0700 Subject: [PATCH 3/3] docs(security): clarify removed image inventory --- .../security/sandbox-base-2026-07-25-dependency-review.md | 8 +++++--- test/e2e/support/sandbox-images-workflow-boundary.test.ts | 2 +- tools/e2e/sandbox-images-workflow-boundary.mts | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/security/sandbox-base-2026-07-25-dependency-review.md b/docs/security/sandbox-base-2026-07-25-dependency-review.md index bb26a3ab6b8..e57b1223fe4 100644 --- a/docs/security/sandbox-base-2026-07-25-dependency-review.md +++ b/docs/security/sandbox-base-2026-07-25-dependency-review.md @@ -5,7 +5,7 @@ Date: 2026-07-25 -Last updated: 2026-08-08 +Last updated: August 8, 2026 ## Scope @@ -107,8 +107,10 @@ The replacement helper: 10. invokes npm and npx only after the fixed package is active. All managed base images apply the helper after the complete npm upgrade. -Each final image reruns the idempotent helper against `/usr/local/lib/node_modules/npm`, so the final image stage owns the bundled npm dependency boundary. -The image build does not scan the complete filesystem for other `tar` copies or retain a node-tar inventory artifact. +Each final image reruns the idempotent helper against `/usr/local/lib/node_modules/npm`. +The final image stage therefore owns the bundled npm dependency boundary. +NemoClaw image builds and image workflows do not scan the complete image filesystem for other `node_modules/tar` packages. +They do not create or retain a node-tar inventory artifact. ### Perl component versions diff --git a/test/e2e/support/sandbox-images-workflow-boundary.test.ts b/test/e2e/support/sandbox-images-workflow-boundary.test.ts index 40e957dd831..b64f523f65c 100644 --- a/test/e2e/support/sandbox-images-workflow-boundary.test.ts +++ b/test/e2e/support/sandbox-images-workflow-boundary.test.ts @@ -169,7 +169,7 @@ describe("sandbox image workflow boundary", () => { expect(validateSandboxImagesWorkflow(imageWorkflow, mainWorkflow)).toEqual( expect.arrayContaining([ "Hermes producer must build the production image exactly once with the canonical local-load Buildx action and OS/architecture-scoped GHA cache", - "Hermes producer must prove the no-CA final image uses default trust after the production image build", + "Hermes producer must verify that the final image uses default trust when no corporate CA is supplied.", ]), ); }); diff --git a/tools/e2e/sandbox-images-workflow-boundary.mts b/tools/e2e/sandbox-images-workflow-boundary.mts index c355f3c4b13..4efbdc9ab84 100644 --- a/tools/e2e/sandbox-images-workflow-boundary.mts +++ b/tools/e2e/sandbox-images-workflow-boundary.mts @@ -459,7 +459,7 @@ function validateGuardedProductionBuild( stepIndex(job, action.name ?? "") >= stepIndex(job, HERMES_DEFAULT_TRUST_STEP_NAME) ) { errors.push( - "Hermes producer must prove the no-CA final image uses default trust after the production image build", + "Hermes producer must verify that the final image uses default trust when no corporate CA is supplied.", ); } return;