From aa6eee83d0158b8f30a62bd5fdda67abf0a99d4d Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Wed, 29 Jul 2026 12:33:27 -0700 Subject: [PATCH 01/15] fix(security): extend Perl remediation to sibling images --- .github/workflows/base-image.yaml | 173 ++++++++++-- Dockerfile.base | 108 +------- agents/hermes/Dockerfile.base | 40 ++- .../langchain-deepagents-code/Dockerfile.base | 40 ++- ci/source-shape-test-budget.json | 5 - ...anaged-base-perl-5.44-dependency-review.md | 104 ++++++++ .../security/build-perl-security-packages.sh | 134 ++++++++++ test/dcode-base-image-workflow.test.ts | 140 +++++++++- test/perl-critical-cve-remediation.test.ts | 247 +++++++++++------- 9 files changed, 751 insertions(+), 240 deletions(-) create mode 100644 docs/security/managed-base-perl-5.44-dependency-review.md create mode 100755 scripts/security/build-perl-security-packages.sh diff --git a/.github/workflows/base-image.yaml b/.github/workflows/base-image.yaml index 31f78ed127a..bc1f6441b4b 100644 --- a/.github/workflows/base-image.yaml +++ b/.github/workflows/base-image.yaml @@ -39,6 +39,7 @@ on: - "scripts/lib/openclaw-npm-remediation.mts" - "scripts/lib/reviewed-npm-audit.mts" - "scripts/security/build-native-security-packages.sh" + - "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" @@ -65,9 +66,9 @@ env: REGISTRY: ghcr.io jobs: - # Build OpenClaw on native runners because its complete Perl regression suite - # is prohibitively slow under QEMU. Publish only immutable per-platform - # digests here; the dependent manifest job updates user-facing tags atomically. + # The complete Perl suite approaches the image-job timeout under QEMU arm64 emulation. + # Build OpenClaw on native runners and publish only immutable per-platform digests here. + # The dependent manifest job updates user-facing tags atomically. build-openclaw-platforms: name: Build OpenClaw base image (${{ matrix.arch }}) if: github.repository == 'NVIDIA/NemoClaw' @@ -178,32 +179,50 @@ jobs: if-no-files-found: error retention-days: 1 - # Hermes and Deep Agents Code still use the existing QEMU multi-platform - # publisher. Their build behavior and timeout remain unchanged. - build-and-push: - name: Build and push ${{ matrix.display_name }} base image + # The complete Perl suite approaches the image-job timeout under QEMU arm64 emulation. + # Build each sibling image on native architecture runners and publish only immutable platform digests. + # The manifest job updates tags after both builds pass. + build-sibling-platforms: + name: Build ${{ matrix.display_name }} base image (${{ matrix.arch }}) if: github.repository == 'NVIDIA/NemoClaw' - runs-on: ubuntu-latest - timeout-minutes: 90 + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 strategy: fail-fast: false matrix: include: - agent: hermes display_name: Hermes + arch: amd64 + platform: linux/amd64 + runner: ubuntu-24.04 + dockerfile: agents/hermes/Dockerfile.base + image: nvidia/nemoclaw/hermes-sandbox-base + - agent: hermes + display_name: Hermes + arch: arm64 + platform: linux/arm64 + runner: ubuntu-24.04-arm dockerfile: agents/hermes/Dockerfile.base image: nvidia/nemoclaw/hermes-sandbox-base - agent: langchain-deepagents-code display_name: Deep Agents Code + arch: amd64 + platform: linux/amd64 + runner: ubuntu-24.04 + dockerfile: agents/langchain-deepagents-code/Dockerfile.base + image: nvidia/nemoclaw/langchain-deepagents-code-sandbox-base + - agent: langchain-deepagents-code + display_name: Deep Agents Code + arch: arm64 + platform: linux/arm64 + runner: ubuntu-24.04-arm dockerfile: agents/langchain-deepagents-code/Dockerfile.base image: nvidia/nemoclaw/langchain-deepagents-code-sandbox-base steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Set up QEMU (arm64 emulation) - uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 - - name: Set up Docker Buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 @@ -221,10 +240,6 @@ jobs: DOCKER_METADATA_SHORT_SHA_LENGTH: 8 with: images: ${{ env.REGISTRY }}/${{ matrix.image }} - tags: | - type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} - type=ref,event=tag - type=sha,prefix=,format=short - name: Validate production Docker build args id: production-build-args @@ -256,19 +271,133 @@ jobs: fi printf 'openclaw_build_arg=%s\n' "$openclaw_build_arg" >> "$GITHUB_OUTPUT" - - name: Build and push + - name: Build and push platform digest + id: build uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . file: ${{ matrix.dockerfile }} - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ steps.meta.outputs.tags }} + platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} - cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ matrix.image }}:buildcache - cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ matrix.image }}:buildcache,mode=max + outputs: type=image,name=${{ env.REGISTRY }}/${{ matrix.image }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ matrix.image }}:buildcache-${{ matrix.arch }} + cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ matrix.image }}:buildcache-${{ matrix.arch }},mode=max build-args: ${{ steps.production-build-args.outputs.openclaw_build_arg }} + - name: Export platform digest + env: + DIGEST: ${{ steps.build.outputs.digest }} + run: | + set -euo pipefail + if [[ ! "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "ERROR: build did not return a valid sha256 digest: $DIGEST" >&2 + exit 1 + fi + mkdir -p "$RUNNER_TEMP/digests" + touch "$RUNNER_TEMP/digests/${DIGEST#sha256:}" + + - name: Upload platform digest + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ matrix.agent }}-base-digest-${{ matrix.arch }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + build-and-push: + name: Build and push ${{ matrix.display_name }} base image + if: github.repository == 'NVIDIA/NemoClaw' + needs: build-sibling-platforms + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + include: + - agent: hermes + display_name: Hermes + image: nvidia/nemoclaw/hermes-sandbox-base + - agent: langchain-deepagents-code + display_name: Deep Agents Code + image: nvidia/nemoclaw/langchain-deepagents-code-sandbox-base + steps: + - name: Download platform digests + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: ${{ matrix.agent }}-base-digest-* + path: ${{ runner.temp }}/digests + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + - name: Log in to GHCR + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + env: + DOCKER_METADATA_SHORT_SHA_LENGTH: 8 + with: + images: ${{ env.REGISTRY }}/${{ matrix.image }} + tags: | + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + type=ref,event=tag + type=sha,prefix=,format=short + + - name: Create and verify multi-platform manifest + env: + IMAGE: ${{ env.REGISTRY }}/${{ matrix.image }} + TAGS: ${{ steps.meta.outputs.tags }} + run: | + set -euo pipefail + shopt -s nullglob + digest_files=("$RUNNER_TEMP"/digests/*) + if [ "${#digest_files[@]}" -ne 2 ]; then + echo "ERROR: expected exactly two platform digests, found ${#digest_files[@]}." >&2 + exit 1 + fi + + sources=() + for digest_file in "${digest_files[@]}"; do + digest="$(basename "$digest_file")" + if [[ ! "$digest" =~ ^[0-9a-f]{64}$ ]]; then + echo "ERROR: invalid platform digest artifact: $digest" >&2 + exit 1 + fi + sources+=("$IMAGE@sha256:$digest") + done + + mapfile -t tags <<< "$TAGS" + tag_args=() + for tag in "${tags[@]}"; do + if [ -n "$tag" ]; then + tag_args+=(--tag "$tag") + fi + done + if [ "${#tag_args[@]}" -eq 0 ]; then + echo "ERROR: metadata did not produce any publication tags." >&2 + exit 1 + fi + + docker buildx imagetools create "${tag_args[@]}" "${sources[@]}" + first_tag="${tags[0]}" + actual_platforms="$( + docker buildx imagetools inspect "$first_tag" --raw \ + | jq -r '.manifests[] | select(.platform.os == "linux") | .platform.architecture' \ + | sort -u \ + | paste -sd, - + )" + if [ "$actual_platforms" != "amd64,arm64" ]; then + echo "ERROR: published manifest has unexpected platforms: $actual_platforms" >&2 + exit 1 + fi + # Preserve the established required-check name while making tag publication # contingent on both native platform builds succeeding. build-and-push-openclaw: diff --git a/Dockerfile.base b/Dockerfile.base index a4210ee6bde..6b1abf83184 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -78,117 +78,27 @@ COPY scripts/security/patches/python3.13-htmlparser-cve-2026-15308.patch /script RUN bash /scripts/security/build-native-security-packages.sh /out -# Debian trixie has not published a Perl package containing the upstream -# fixes for CVE-2026-12087, CVE-2026-13221, and CVE-2026-57433. Build the -# fixed upstream release as native Debian packages so dpkg dependencies and -# vulnerability inventory both describe the runtime that is actually used. -FROM node:22-trixie-slim@sha256:e6d9a389d34ff9678438af985c9913fbd1eb6ed36e80fea56644f4b4f6dd70ba AS perl-builder +# Debian trixie has not published a Perl package containing the reviewed +# upstream fixes. Build the fixed release as native Debian packages so dpkg +# dependencies and vulnerability inventory describe the executed runtime. +FROM native-security-builder AS perl-builder ARG PERL_VERSION ARG PERL_SHA256 ARG PERL_PACKAGE_REVISION -COPY --from=native-security-builder /out /tmp/security-packages +COPY scripts/security/build-perl-security-packages.sh /scripts/security/build-perl-security-packages.sh RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential=12.12 \ - ca-certificates=20250419 \ - curl=8.14.1-2+deb13u4 \ netbase=6.5 \ - xz-utils=5.8.1-1+deb13u1 \ && rm -rf /var/lib/apt/lists/* -WORKDIR /tmp/perl-source - -# Pin the reviewed d_syscallproto result for trixie's libc so both native -# architectures use the same known declaration instead of relying on a -# Configure probe that previously returned a false negative under QEMU. -# Remove this override only after the pinned base image and Perl release report -# d_syscallproto=define from native Configure probes on amd64 and arm64. -# Perl's test_harness runs the same upstream suite while TEST_JOBS lets its TAP -# scheduler use each native runner efficiently instead of serializing every -# script in QEMU. -# ExtUtils::Constant's test recursively invokes make and produced an incomplete -# TAP plan when it overlapped another test locally, so run it alone first and -# exclude exactly that already-passed file from the parallel pass. -# Remove this split only after the unsplit parallel harness passes in two -# consecutive amd64 and arm64 base-image builds; keep the selection-equivalence -# check below until that removal condition is met. -RUN curl --proto '=https' --tlsv1.2 -fsSL \ - --retry 5 --retry-all-errors --retry-delay 2 --connect-timeout 15 --max-time 120 \ - -o /tmp/perl.tar.xz "https://www.cpan.org/src/5.0/perl-${PERL_VERSION}.tar.xz" \ - && printf '%s %s\n' "${PERL_SHA256}" /tmp/perl.tar.xz > /tmp/perl.sha256 \ - && sha256sum -c /tmp/perl.sha256 \ - && tar -xJf /tmp/perl.tar.xz -C /tmp/perl-source --strip-components=1 \ - && ./Configure -des \ - -Dprefix=/usr \ - -Dvendorprefix=/usr \ - -Dsiteprefix=/usr/local \ - -Dusethreads \ - -Duse64bitall \ - -Dd_syscallproto=define \ - -Dman1dir=none \ - -Dman3dir=none \ - && make -j"$(nproc)" \ - && make test_prep \ - && env -C t PERL_TEST_HARNESS_ASAP=1 ./perl harness -dumptests \ - > /tmp/perl-tests-full \ - && env -C t ./perl harness -dumptests \ - ../cpan/ExtUtils-Constant/t/Constant.t \ - > /tmp/perl-tests-serial \ - && env -C t PERL_TEST_HARNESS_ASAP=1 ./perl harness -dumptests \ - '--nre=^[.][.]/cpan/ExtUtils-Constant/t/Constant[.]t$' \ - > /tmp/perl-tests-parallel \ - && sort /tmp/perl-tests-full > /tmp/perl-tests-full.sorted \ - && sort /tmp/perl-tests-serial /tmp/perl-tests-parallel \ - > /tmp/perl-tests-combined.sorted \ - && cmp /tmp/perl-tests-full.sorted /tmp/perl-tests-combined.sorted \ - && test "$(grep -Fxc \ - 'cpan/ExtUtils-Constant/t/Constant.t' \ - /tmp/perl-tests-combined.sorted)" -eq 1 \ - && TEST_JOBS=1 TEST_ARGS='../cpan/ExtUtils-Constant/t/Constant.t' make test_harness \ - && TEST_JOBS="$(nproc)" PERL_TEST_HARNESS_ASAP=1 \ - TEST_ARGS='--nre=^[.][.]/cpan/ExtUtils-Constant/t/Constant[.]t$' \ - make -j"$(nproc)" test_harness \ - && make install DESTDIR=/tmp/perl-root - -RUN package_version="${PERL_VERSION}-${PERL_PACKAGE_REVISION}" \ - && architecture="$(dpkg --print-architecture)" \ - && mkdir -p /tmp/perl-root/DEBIAN /tmp/perl-meta/DEBIAN \ - && printf '%s\n' \ - 'Package: perl-base' \ - "Version: ${package_version}" \ - "Architecture: ${architecture}" \ - 'Essential: yes' \ - 'Priority: required' \ - 'Section: perl' \ - 'Multi-Arch: allowed' \ - 'Maintainer: NVIDIA NemoClaw Maintainers' \ - "Provides: libperl5.40 (= ${package_version}), perl-modules-5.40 (= ${package_version})" \ - 'Conflicts: libperl5.40, perl-modules-5.40' \ - "Breaks: perl (<< ${package_version})" \ - "Replaces: libperl5.40, perl-modules-5.40, perl (<< ${package_version})" \ - 'Description: Perl 5 language interpreter built for the NemoClaw sandbox' \ - > /tmp/perl-root/DEBIAN/control \ - && printf '%s\n' \ - 'Package: perl' \ - "Version: ${package_version}" \ - "Architecture: ${architecture}" \ - 'Priority: standard' \ - 'Section: perl' \ - 'Multi-Arch: allowed' \ - "Depends: perl-base (= ${package_version})" \ - 'Maintainer: NVIDIA NemoClaw Maintainers' \ - 'Description: Perl 5 language interpreter metapackage for the NemoClaw sandbox' \ - > /tmp/perl-meta/DEBIAN/control \ - && dpkg-deb --build --root-owner-group \ - /tmp/perl-root /tmp/security-packages/perl-base.deb \ - && dpkg-deb --build --root-owner-group \ - /tmp/perl-meta /tmp/security-packages/perl.deb +RUN bash /scripts/security/build-perl-security-packages.sh \ + /out "${PERL_VERSION}" "${PERL_SHA256}" "${PERL_PACKAGE_REVISION}" FROM node:22-trixie-slim@sha256:e6d9a389d34ff9678438af985c9913fbd1eb6ed36e80fea56644f4b4f6dd70ba -COPY --from=perl-builder /tmp/security-packages /tmp/nemoclaw-native-security +COPY --from=perl-builder /out /tmp/nemoclaw-native-security # OpenShell blocks the link-local EC2 Instance Metadata Service. Keep AWS SDK # credential chains from attempting an impossible metadata discovery path. @@ -315,6 +225,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ "vim-tiny=2:9.2.0782-1" \ "libssh2-1t64=1.11.1-1+deb13u1+nemoclaw1" \ "nemoclaw-python3.13-htmlparser-fix=3.13.5-2+deb13u4+nemoclaw1" \ + "perl-base=5.44.0-1nemoclaw1" \ + "perl=5.44.0-1nemoclaw1" \ > /usr/local/share/nemoclaw/security-packages.txt \ && chown root:root /usr/local/share/nemoclaw/security-packages.txt \ && chmod 0444 /usr/local/share/nemoclaw/security-packages.txt \ diff --git a/agents/hermes/Dockerfile.base b/agents/hermes/Dockerfile.base index dd3ba135a95..0daaa2d6064 100644 --- a/agents/hermes/Dockerfile.base +++ b/agents/hermes/Dockerfile.base @@ -19,6 +19,10 @@ # 6. New .hermes subdirectory — add mkdir/chmod below # ──────────────────────────────────────────────────────────────── +ARG PERL_VERSION=5.44.0 +ARG PERL_SHA256=505cf43912e9480495c344c70260452e32aa2a73c546a026b3f100053b23ce91 +ARG PERL_PACKAGE_REVISION=1nemoclaw1 + FROM node:24-trixie-slim@sha256:05c08ce4291e9a58f59456a7985176defb12cdd42271f35ff81a3e167ea61d4c AS native-security-builder RUN apt-get update && apt-get install -y --no-install-recommends \ @@ -38,9 +42,24 @@ COPY scripts/security/patches/python3.13-htmlparser-cve-2026-15308.patch /script RUN bash /scripts/security/build-native-security-packages.sh /out +FROM native-security-builder AS perl-builder + +ARG PERL_VERSION +ARG PERL_SHA256 +ARG PERL_PACKAGE_REVISION + +COPY scripts/security/build-perl-security-packages.sh /scripts/security/build-perl-security-packages.sh + +RUN apt-get update && apt-get install -y --no-install-recommends \ + netbase=6.5 \ + && rm -rf /var/lib/apt/lists/* + +RUN bash /scripts/security/build-perl-security-packages.sh \ + /out "${PERL_VERSION}" "${PERL_SHA256}" "${PERL_PACKAGE_REVISION}" + FROM node:24-trixie-slim@sha256:05c08ce4291e9a58f59456a7985176defb12cdd42271f35ff81a3e167ea61d4c -COPY --from=native-security-builder /out /tmp/nemoclaw-native-security +COPY --from=perl-builder /out /tmp/nemoclaw-native-security ENV DEBIAN_FRONTEND=noninteractive @@ -145,6 +164,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ "$security_deb_dir/vim-tiny.deb" \ /tmp/nemoclaw-native-security/libssh2-1t64.deb \ /tmp/nemoclaw-native-security/nemoclaw-python3.13-htmlparser-fix.deb \ + && apt-get install -y --no-install-recommends \ + /tmp/nemoclaw-native-security/perl-base.deb \ + /tmp/nemoclaw-native-security/perl.deb \ && test "$(dpkg-query -W -f='${Version}' libexpat1)" = "2.8.2-1" \ && test "$(dpkg-query -W -f='${Version}' libonig5)" = "6.9.9-1+b1" \ && test "$(dpkg-query -W -f='${Version}' libjq1)" = "1.8.2-1" \ @@ -164,6 +186,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && python3 -c "import sys; from pathlib import Path; import html.parser; Path(html.parser.__file__).resolve() == Path('/usr/lib/python3.13/html/parser.py').resolve() or sys.exit('html.parser loaded from an unexpected path'); from html.parser import HTMLParser; p=HTMLParser(); [p.feed('') for _ in range(20000)]; p._pending == [] or sys.exit('empty feeds accumulated pending entries'); p.feed(''); p.close(); p.rawdata == '' or sys.exit('incremental parsing retained raw data')" \ && python3 -c "import ctypes, sys; lib=ctypes.CDLL('libssh2.so.1'); lib.libssh2_version.restype=ctypes.c_char_p; lib.libssh2_version(0) == b'1.11.1' or sys.exit('unexpected libssh2 runtime version')" \ && vim.tiny --version | head -n 1 | grep -Eq '^VIM - Vi IMproved 9[.]2 ' \ + && test "$(perl -e 'print $^V')" = "v5.44.0" \ + && test "$(perl -MSocket -e 'print Socket->VERSION')" = "2.041" \ + && test "$(perl -MStorable -e 'print Storable->VERSION')" = "3.41" \ + && test "$(perl -MHTTP::Tiny -e 'print HTTP::Tiny->VERSION')" = "0.096" \ + && test "$(perl -MIO::Compress::Base -e 'print IO::Compress::Base->VERSION')" = "2.223" \ + && test "$(perl -MIO::Uncompress::Unzip -e 'print IO::Uncompress::Unzip->VERSION')" = "2.223" \ + && test "$(perl -MFile::GlobMapper -e 'print File::GlobMapper->VERSION')" = "1.001" \ + && perl -MSocket=pack_ip_mreq_source -e \ + 'eval { pack_ip_mreq_source("\0" x 4, "\0" x 3) }; die "short source accepted" unless $@ =~ /Bad arg length/' \ + && perl -e \ + 'my $x = join "|", "aaa".."mzz"; my $y = join "|", "naa".."zzz"; use re "Debug"; "fnord" =~ m/(?:$x)|(?:$y)/' \ && install -d -o root -g root -m 0755 /usr/local/share/nemoclaw \ && printf '%s\n' \ "architecture=$arch" \ @@ -175,12 +208,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ "vim-tiny=2:9.2.0782-1" \ "libssh2-1t64=1.11.1-1+deb13u1+nemoclaw1" \ "nemoclaw-python3.13-htmlparser-fix=3.13.5-2+deb13u4+nemoclaw1" \ + "perl-base=5.44.0-1nemoclaw1" \ + "perl=5.44.0-1nemoclaw1" \ > /usr/local/share/nemoclaw/security-packages.txt \ && chown root:root /usr/local/share/nemoclaw/security-packages.txt \ && chmod 0444 /usr/local/share/nemoclaw/security-packages.txt \ && rm -rf /tmp/nemoclaw-native-security \ && rm -rf "$security_deb_dir" \ - && rm -rf /var/lib/apt/lists/* + && rm -rf /var/lib/apt/lists/* \ + && test -z "$(dpkg --audit)" COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts diff --git a/agents/langchain-deepagents-code/Dockerfile.base b/agents/langchain-deepagents-code/Dockerfile.base index 05a8209f0b5..0063fa226c6 100644 --- a/agents/langchain-deepagents-code/Dockerfile.base +++ b/agents/langchain-deepagents-code/Dockerfile.base @@ -7,6 +7,10 @@ # Node for NemoClaw build-time config generation, Python, shell tools, and a # hash-locked deepagents-code install with the NVIDIA provider extra. +ARG PERL_VERSION=5.44.0 +ARG PERL_SHA256=505cf43912e9480495c344c70260452e32aa2a73c546a026b3f100053b23ce91 +ARG PERL_PACKAGE_REVISION=1nemoclaw1 + FROM node:22-trixie-slim@sha256:e6d9a389d34ff9678438af985c9913fbd1eb6ed36e80fea56644f4b4f6dd70ba AS native-security-builder RUN apt-get update && apt-get install -y --no-install-recommends \ @@ -26,9 +30,24 @@ COPY scripts/security/patches/python3.13-htmlparser-cve-2026-15308.patch /script RUN bash /scripts/security/build-native-security-packages.sh /out +FROM native-security-builder AS perl-builder + +ARG PERL_VERSION +ARG PERL_SHA256 +ARG PERL_PACKAGE_REVISION + +COPY scripts/security/build-perl-security-packages.sh /scripts/security/build-perl-security-packages.sh + +RUN apt-get update && apt-get install -y --no-install-recommends \ + netbase=6.5 \ + && rm -rf /var/lib/apt/lists/* + +RUN bash /scripts/security/build-perl-security-packages.sh \ + /out "${PERL_VERSION}" "${PERL_SHA256}" "${PERL_PACKAGE_REVISION}" + FROM node:22-trixie-slim@sha256:e6d9a389d34ff9678438af985c9913fbd1eb6ed36e80fea56644f4b4f6dd70ba -COPY --from=native-security-builder /out /tmp/nemoclaw-native-security +COPY --from=perl-builder /out /tmp/nemoclaw-native-security COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts @@ -120,6 +139,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ "$security_deb_dir/vim-tiny.deb" \ /tmp/nemoclaw-native-security/libssh2-1t64.deb \ /tmp/nemoclaw-native-security/nemoclaw-python3.13-htmlparser-fix.deb \ + && apt-get install -y --no-install-recommends \ + /tmp/nemoclaw-native-security/perl-base.deb \ + /tmp/nemoclaw-native-security/perl.deb \ && test "$(dpkg-query -W -f='${Version}' libexpat1)" = "2.8.2-1" \ && test "$(dpkg-query -W -f='${Version}' libonig5)" = "6.9.9-1+b1" \ && test "$(dpkg-query -W -f='${Version}' libjq1)" = "1.8.2-1" \ @@ -139,6 +161,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && python3 -c "import sys; from pathlib import Path; import html.parser; Path(html.parser.__file__).resolve() == Path('/usr/lib/python3.13/html/parser.py').resolve() or sys.exit('html.parser loaded from an unexpected path'); from html.parser import HTMLParser; p=HTMLParser(); [p.feed('') for _ in range(20000)]; p._pending == [] or sys.exit('empty feeds accumulated pending entries'); p.feed(''); p.close(); p.rawdata == '' or sys.exit('incremental parsing retained raw data')" \ && python3 -c "import ctypes, sys; lib=ctypes.CDLL('libssh2.so.1'); lib.libssh2_version.restype=ctypes.c_char_p; lib.libssh2_version(0) == b'1.11.1' or sys.exit('unexpected libssh2 runtime version')" \ && vim.tiny --version | head -n 1 | grep -Eq '^VIM - Vi IMproved 9[.]2 ' \ + && test "$(perl -e 'print $^V')" = "v5.44.0" \ + && test "$(perl -MSocket -e 'print Socket->VERSION')" = "2.041" \ + && test "$(perl -MStorable -e 'print Storable->VERSION')" = "3.41" \ + && test "$(perl -MHTTP::Tiny -e 'print HTTP::Tiny->VERSION')" = "0.096" \ + && test "$(perl -MIO::Compress::Base -e 'print IO::Compress::Base->VERSION')" = "2.223" \ + && test "$(perl -MIO::Uncompress::Unzip -e 'print IO::Uncompress::Unzip->VERSION')" = "2.223" \ + && test "$(perl -MFile::GlobMapper -e 'print File::GlobMapper->VERSION')" = "1.001" \ + && perl -MSocket=pack_ip_mreq_source -e \ + 'eval { pack_ip_mreq_source("\0" x 4, "\0" x 3) }; die "short source accepted" unless $@ =~ /Bad arg length/' \ + && perl -e \ + 'my $x = join "|", "aaa".."mzz"; my $y = join "|", "naa".."zzz"; use re "Debug"; "fnord" =~ m/(?:$x)|(?:$y)/' \ && install -d -o root -g root -m 0755 /usr/local/share/nemoclaw \ && printf '%s\n' \ "architecture=$arch" \ @@ -150,12 +183,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ "vim-tiny=2:9.2.0782-1" \ "libssh2-1t64=1.11.1-1+deb13u1+nemoclaw1" \ "nemoclaw-python3.13-htmlparser-fix=3.13.5-2+deb13u4+nemoclaw1" \ + "perl-base=5.44.0-1nemoclaw1" \ + "perl=5.44.0-1nemoclaw1" \ > /usr/local/share/nemoclaw/security-packages.txt \ && chown root:root /usr/local/share/nemoclaw/security-packages.txt \ && chmod 0444 /usr/local/share/nemoclaw/security-packages.txt \ && rm -rf /tmp/nemoclaw-native-security \ && rm -rf "$security_deb_dir" \ - && rm -rf /var/lib/apt/lists/* + && rm -rf /var/lib/apt/lists/* \ + && test -z "$(dpkg --audit)" # Node remains available to the managed terminal at runtime, so remediate # npm's private node-tar copy after curl is installed even though Deep Agents diff --git a/ci/source-shape-test-budget.json b/ci/source-shape-test-budget.json index f3b1e698fad..8b54699e676 100644 --- a/ci/source-shape-test-budget.json +++ b/ci/source-shape-test-budget.json @@ -406,11 +406,6 @@ "test": "runs PR audits from trusted code and keeps the main audit on the checked-in action", "category": "security" }, - { - "file": "test/perl-critical-cve-remediation.test.ts", - "test": "builds the fixed upstream release from a checksum-pinned source archive (#7338)", - "category": "security" - }, { "file": "test/platform-vitest-main-workflow.test.ts", "test": "pins and verifies the Node.js archive in the trusted WSL helper", diff --git a/docs/security/managed-base-perl-5.44-dependency-review.md b/docs/security/managed-base-perl-5.44-dependency-review.md new file mode 100644 index 00000000000..6c454bfe031 --- /dev/null +++ b/docs/security/managed-base-perl-5.44-dependency-review.md @@ -0,0 +1,104 @@ + + +# Managed base-image Perl 5.44 review + +Date: 2026-07-29 + +## Scope + +This review extends the existing Perl 5.44.0 remediation to the Hermes and Deep Agents Code base images. + +The OpenClaw base image already installs the reviewed packages. +The sibling images used Debian Perl 5.40.1-6 before this change. + +This change does not cross an upstream Perl release range. +It preserves these reviewed identities: + +- source release: Perl 5.44.0; +- source SHA-256: `505cf43912e9480495c344c70260452e32aa2a73c546a026b3f100053b23ce91`; +- package revision: `1nemoclaw1`; and +- package versions: `perl-base=5.44.0-1nemoclaw1` and `perl=5.44.0-1nemoclaw1`. + +The public vulnerability set includes `CVE-2026-12087`, `CVE-2026-13221`, `CVE-2026-48959`, `CVE-2026-48961`, `CVE-2026-48962`, `CVE-2026-57432`, `CVE-2026-57433`, and `CVE-2026-7017`. + +## Artifact flow + +`scripts/security/build-perl-security-packages.sh` owns the source download, checksum verification, configuration, test selection, install, and Debian package metadata. + +Each managed base image uses this flow: + +1. Build the existing libssh2 and Python security packages. +2. Build Perl from the checksum-pinned CPAN archive. +3. Run the complete upstream test selection. +4. Build native `perl-base` and `perl` Debian packages. +5. Install both packages before deleting the build artifacts. +6. Execute exact interpreter, module, behavior, and dpkg assertions. + +The package metadata replaces the Debian `libperl5.40` and `perl-modules-5.40` ownership without leaving unmanaged files. + +## Build topology + +The complete Perl suite approaches the image-job timeout under QEMU arm64 emulation. + +The base-image publisher now builds these image and platform pairs on native runners: + +- OpenClaw on amd64 and arm64; +- Hermes on amd64 and arm64; and +- Deep Agents Code on amd64 and arm64. + +All six platform builds run independently. +Each final publisher creates tags only after both platform digests for its image pass. +The publisher then verifies that the manifest contains amd64 and arm64. + +## Runtime proof + +Each completed image must report these values: + +| Component | Required value | +|---|---| +| Perl | `v5.44.0` | +| Socket | `2.041` | +| Storable | `3.41` | +| HTTP::Tiny | `0.096` | +| IO::Compress::Base | `2.223` | +| IO::Uncompress::Unzip | `2.223` | +| File::GlobMapper | `1.001` | + +The image build also executes the reviewed Socket argument-length rejection and regular-expression behavior checks. +`dpkg --audit` must return no output. + +## Concern ledger + +| ID | Surface | Failure mode | Disposition | Evidence | +|---|---|---|---|---| +| PERL-01 | Source identity | A moving or modified archive changes the runtime behind the package version. | Pin | The CPAN URL contains `5.44.0`, and SHA-256 verification precedes extraction. | +| PERL-02 | Test coverage | A sibling image installs an untested native build. | Test | The shared builder runs the complete selection-equivalent upstream suite before packaging. | +| PERL-03 | Package ownership | Replacing Perl leaves conflicting Debian package ownership. | Guard | Package metadata declares `Provides`, `Conflicts`, `Breaks`, and `Replaces`; every image runs `dpkg --audit`. | +| PERL-04 | Image selection | One managed image continues to copy packages from the older native-only stage. | Guard | All three Dockerfiles copy `/out` from `perl-builder`. | +| PERL-05 | Runtime selection | The expected package exists but another interpreter or module executes. | Runtime proof | Every final image executes the interpreter, module, Socket, and regex checks. | +| PERL-06 | Platform execution | arm64 emulation exceeds the job timeout or hides architecture-specific failure. | Migrate | Hermes and Deep Agents Code use native amd64 and arm64 platform jobs with atomic manifests. | +| PERL-07 | Published identity | One platform tag becomes visible before its sibling platform passes. | Guard | Platform jobs push by digest. Manifest jobs publish tags after both digest artifacts exist. | + +## Downstream boundaries + +The change does not modify agent configuration, credentials, network policy, runtime entrypoints, persistent state, or image selection. +It changes the Perl files and dpkg identities inside the three existing managed base images. + +Rollback selects an earlier immutable base-image digest. +The change does not add a data migration or a compatibility fallback. + +## Verification + +The repository tests verify: + +- one shared package definition and checksum identity; +- complete upstream test selection before packaging; +- package ownership and cleanup order; +- exact runtime and module assertions in all three images; +- native amd64 and arm64 jobs for both sibling images; and +- atomic multi-platform manifest publication. + +The remaining external gates are the six production platform builds, manifest publication, and a vulnerability rescan of the published image digests. diff --git a/scripts/security/build-perl-security-packages.sh b/scripts/security/build-perl-security-packages.sh new file mode 100755 index 00000000000..f572446eb68 --- /dev/null +++ b/scripts/security/build-perl-security-packages.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +if (($# != 4)); then + printf 'Usage: %s OUTPUT_DIR PERL_VERSION PERL_SHA256 PACKAGE_REVISION\n' "$0" >&2 + exit 64 +fi + +readonly output_dir="$1" +readonly perl_version="$2" +readonly perl_sha256="$3" +readonly package_revision="$4" +build_root="$(mktemp -d /tmp/nemoclaw-perl-security.XXXXXX)" +readonly build_root +readonly source_archive="${build_root}/perl.tar.xz" +readonly source_dir="${build_root}/perl-source" +readonly perl_root="${build_root}/perl-root" +readonly perl_meta="${build_root}/perl-meta" + +cleanup() { + rm -rf "${build_root}" +} +trap cleanup EXIT + +mkdir -p "${output_dir}" "${source_dir}" + +# Pin the reviewed d_syscallproto result for trixie's libc so both native +# architectures use the same known declaration instead of relying on a +# Configure probe that previously returned a false negative under QEMU. +# Remove this override only after the pinned base image and Perl release report +# d_syscallproto=define from native Configure probes on amd64 and arm64. +# Perl's test_harness runs the same upstream suite while TEST_JOBS lets its TAP +# scheduler use each native runner efficiently instead of serializing every +# script in QEMU. +# ExtUtils::Constant's test recursively invokes make and produced an incomplete +# TAP plan when it overlapped another test locally, so run it alone first and +# exclude exactly that already-passed file from the parallel pass. +# Remove this split only after the unsplit parallel harness passes in two +# consecutive amd64 and arm64 base-image builds; keep the selection-equivalence +# check below until that removal condition is met. +curl --proto '=https' --tlsv1.2 -fsSL \ + --retry 5 --retry-all-errors --retry-delay 2 \ + --connect-timeout 15 --max-time 120 \ + -o "${source_archive}" \ + "https://www.cpan.org/src/5.0/perl-${perl_version}.tar.xz" +printf '%s %s\n' "${perl_sha256}" "${source_archive}" >"${build_root}/perl.sha256" +sha256sum -c "${build_root}/perl.sha256" +tar -xJf "${source_archive}" -C "${source_dir}" --strip-components=1 + +( + cd "${source_dir}" + ./Configure -des \ + -Dprefix=/usr \ + -Dvendorprefix=/usr \ + -Dsiteprefix=/usr/local \ + -Dusethreads \ + -Duse64bitall \ + -Dd_syscallproto=define \ + -Dman1dir=none \ + -Dman3dir=none + make -j"$(nproc)" + make test_prep + env -C t PERL_TEST_HARNESS_ASAP=1 ./perl harness -dumptests \ + >"${build_root}/perl-tests-full" + env -C t ./perl harness -dumptests \ + ../cpan/ExtUtils-Constant/t/Constant.t \ + >"${build_root}/perl-tests-serial" + env -C t PERL_TEST_HARNESS_ASAP=1 ./perl harness -dumptests \ + '--nre=^[.][.]/cpan/ExtUtils-Constant/t/Constant[.]t$' \ + >"${build_root}/perl-tests-parallel" + sort "${build_root}/perl-tests-full" \ + >"${build_root}/perl-tests-full.sorted" + sort \ + "${build_root}/perl-tests-serial" \ + "${build_root}/perl-tests-parallel" \ + >"${build_root}/perl-tests-combined.sorted" + cmp \ + "${build_root}/perl-tests-full.sorted" \ + "${build_root}/perl-tests-combined.sorted" + test "$( + grep -Fxc \ + 'cpan/ExtUtils-Constant/t/Constant.t' \ + "${build_root}/perl-tests-combined.sorted" + )" -eq 1 + TEST_JOBS=1 \ + TEST_ARGS='../cpan/ExtUtils-Constant/t/Constant.t' \ + make test_harness + TEST_JOBS="$(nproc)" \ + PERL_TEST_HARNESS_ASAP=1 \ + TEST_ARGS='--nre=^[.][.]/cpan/ExtUtils-Constant/t/Constant[.]t$' \ + make -j"$(nproc)" test_harness + make install DESTDIR="${perl_root}" +) + +package_version="${perl_version}-${package_revision}" +readonly package_version +architecture="$(dpkg --print-architecture)" +readonly architecture +mkdir -p "${perl_root}/DEBIAN" "${perl_meta}/DEBIAN" +printf '%s\n' \ + 'Package: perl-base' \ + "Version: ${package_version}" \ + "Architecture: ${architecture}" \ + 'Essential: yes' \ + 'Priority: required' \ + 'Section: perl' \ + 'Multi-Arch: allowed' \ + 'Maintainer: NVIDIA NemoClaw Maintainers' \ + "Provides: libperl5.40 (= ${package_version}), perl-modules-5.40 (= ${package_version})" \ + 'Conflicts: libperl5.40, perl-modules-5.40' \ + "Breaks: perl (<< ${package_version})" \ + "Replaces: libperl5.40, perl-modules-5.40, perl (<< ${package_version})" \ + 'Description: Perl 5 language interpreter built for the NemoClaw sandbox' \ + >"${perl_root}/DEBIAN/control" +printf '%s\n' \ + 'Package: perl' \ + "Version: ${package_version}" \ + "Architecture: ${architecture}" \ + 'Priority: standard' \ + 'Section: perl' \ + 'Multi-Arch: allowed' \ + "Depends: perl-base (= ${package_version})" \ + 'Maintainer: NVIDIA NemoClaw Maintainers' \ + 'Description: Perl 5 language interpreter metapackage for the NemoClaw sandbox' \ + >"${perl_meta}/DEBIAN/control" +dpkg-deb --build --root-owner-group \ + "${perl_root}" "${output_dir}/perl-base.deb" +dpkg-deb --build --root-owner-group \ + "${perl_meta}" "${output_dir}/perl.deb" +test "$(dpkg-deb -f "${output_dir}/perl-base.deb" Version)" = "${package_version}" +test "$(dpkg-deb -f "${output_dir}/perl.deb" Version)" = "${package_version}" diff --git a/test/dcode-base-image-workflow.test.ts b/test/dcode-base-image-workflow.test.ts index a691017019c..619aa0b606c 100644 --- a/test/dcode-base-image-workflow.test.ts +++ b/test/dcode-base-image-workflow.test.ts @@ -193,11 +193,13 @@ function validatePublishers(candidate: Workflow): string[] { const dockerActions = steps.filter((step) => step.uses?.startsWith("docker/")); const tags = String(metadata?.with?.tags ?? ""); const metadataImage = renderMatrixValue(metadata?.with?.images, matrix); - const expectedCacheRef = `${metadataImage}:buildcache`; + const expectedCacheRef = `${metadataImage}:buildcache-${matrix.arch}`; const cacheFrom = registryCacheEntries(renderMatrixValue(build.with?.["cache-from"], matrix)); const cacheTo = registryCacheEntries(renderMatrixValue(build.with?.["cache-to"], matrix)); const importedCacheRef = cacheFrom[0]?.ref; const exportedCacheRef = cacheTo[0]?.ref; + const expectedOutput = + "type=image,name=${{ env.REGISTRY }}/${{ matrix.image }},push-by-digest=true,name-canonical=true,push=true"; return [ ...(guardIndex < 0 || guardIndex >= buildIndex @@ -210,11 +212,7 @@ function validatePublishers(candidate: Workflow): string[] { ? [`${jobName} must derive publication metadata with docker/metadata-action`] : []), ...(metadataImage.length === 0 ? [`${jobName} must declare a publication image`] : []), - ...(!tags.includes("type=ref,event=tag") || - !tags.includes("type=raw,value=latest") || - !tags.includes("type=sha,prefix=,format=short") - ? [`${jobName} must publish release, latest, and commit tags`] - : []), + ...(tags.length > 0 ? [`${jobName} platform build must not publish mutable tags`] : []), ...dockerActions .filter((step) => !FULL_SHA_ACTION.test(step.uses ?? "")) .map((step) => `${jobName} Docker action must use a full commit SHA: ${step.uses}`), @@ -222,13 +220,17 @@ function validatePublishers(candidate: Workflow): string[] { ? [`${jobName} build-push action must use a full commit SHA`] : []), ...(build.with?.context !== "." ? [`${jobName} must publish from repository context`] : []), - ...(build.with?.platforms !== "linux/amd64,linux/arm64" - ? [`${jobName} must publish both supported architectures`] + ...(build.with?.platforms !== "${{ matrix.platform }}" + ? [`${jobName} must build its selected native platform`] + : []), + ...(build.with?.outputs !== expectedOutput + ? [`${jobName} must push an immutable platform digest`] : []), - ...(build.with?.push !== true ? [`${jobName} must push the built image`] : []), - ...(build.with?.tags !== "${{ steps.meta.outputs.tags }}" || - build.with?.labels !== "${{ steps.meta.outputs.labels }}" - ? [`${jobName} must publish the reviewed metadata outputs`] + ...(build.with?.tags !== undefined || build.with?.push !== undefined + ? [`${jobName} platform build must not publish tags directly`] + : []), + ...(build.with?.labels !== "${{ steps.meta.outputs.labels }}" + ? [`${jobName} must use the reviewed metadata labels`] : []), ...(cacheFrom.length !== 1 || !importedCacheRef ? [`${jobName} cache-from must declare exactly one registry cache ref`] @@ -268,17 +270,32 @@ describe("base-image publication behavior", () => { expect( publishers.map(({ dockerfile, matrix }) => ({ agent: matrix.agent, + arch: matrix.arch, dockerfile, image: matrix.image, })), ).toEqual([ { agent: "hermes", + arch: "amd64", dockerfile: "agents/hermes/Dockerfile.base", image: "nvidia/nemoclaw/hermes-sandbox-base", }, + { + agent: "hermes", + arch: "arm64", + dockerfile: "agents/hermes/Dockerfile.base", + image: "nvidia/nemoclaw/hermes-sandbox-base", + }, + { + agent: "langchain-deepagents-code", + arch: "amd64", + dockerfile: "agents/langchain-deepagents-code/Dockerfile.base", + image: "nvidia/nemoclaw/langchain-deepagents-code-sandbox-base", + }, { agent: "langchain-deepagents-code", + arch: "arm64", dockerfile: "agents/langchain-deepagents-code/Dockerfile.base", image: "nvidia/nemoclaw/langchain-deepagents-code-sandbox-base", }, @@ -311,7 +328,7 @@ describe("base-image publication behavior", () => { `${mutatedPublisher.jobName} must validate production build args before publishing`, `${mutatedPublisher.jobName} Docker action must use a full commit SHA: docker/build-push-action@v7`, `${mutatedPublisher.jobName} build-push action must use a full commit SHA`, - `${mutatedPublisher.jobName} must push the built image`, + `${mutatedPublisher.jobName} platform build must not publish tags directly`, `${mutatedPublisher.jobName} cache-from must declare exactly one registry cache ref`, `${mutatedPublisher.jobName} must import and export the same registry cache ref`, `${mutatedPublisher.jobName} must export its registry cache in max mode`, @@ -438,6 +455,103 @@ describe("base-image publication behavior", () => { } }); + it("publishes sibling images atomically from native architecture runners", () => { + const publishers = publisherJobs(workflow); + const platformJob = workflow.jobs?.["build-sibling-platforms"]; + const manifestJob = workflow.jobs?.["build-and-push"]; + + expect(platformJob?.["timeout-minutes"]).toBe(60); + expect(platformJob?.["runs-on"]).toBe("${{ matrix.runner }}"); + expect(platformJob?.strategy?.["fail-fast"]).toBe(false); + expect( + publishers.map(({ matrix }) => ({ + agent: matrix.agent, + arch: matrix.arch, + platform: matrix.platform, + runner: matrix.runner, + })), + ).toEqual([ + { + agent: "hermes", + arch: "amd64", + platform: "linux/amd64", + runner: "ubuntu-24.04", + }, + { + agent: "hermes", + arch: "arm64", + platform: "linux/arm64", + runner: "ubuntu-24.04-arm", + }, + { + agent: "langchain-deepagents-code", + arch: "amd64", + platform: "linux/amd64", + runner: "ubuntu-24.04", + }, + { + agent: "langchain-deepagents-code", + arch: "arm64", + platform: "linux/arm64", + runner: "ubuntu-24.04-arm", + }, + ]); + + for (const { job, build, matrix } of publishers) { + const steps = job.steps ?? []; + const digestUpload = steps.find((step) => step.name === "Upload platform digest"); + + expect(steps.some((step) => step.uses?.startsWith("docker/setup-qemu-action@"))).toBe(false); + expect(build.with?.platforms).toBe("${{ matrix.platform }}"); + expect(build.with?.outputs).toBe( + "type=image,name=${{ env.REGISTRY }}/${{ matrix.image }},push-by-digest=true,name-canonical=true,push=true", + ); + expect(digestUpload?.with?.name).toBe("${{ matrix.agent }}-base-digest-${{ matrix.arch }}"); + expect(renderMatrixValue(digestUpload?.with?.name, matrix)).toBe( + `${matrix.agent}-base-digest-${matrix.arch}`, + ); + } + + expect(manifestJob?.needs).toBe("build-sibling-platforms"); + expect(manifestJob?.["timeout-minutes"]).toBe(10); + expect( + manifestJob?.steps?.some((step) => step.uses?.startsWith("docker/build-push-action@")), + ).toBe(false); + expect(manifestJob?.strategy?.matrix?.include).toEqual([ + { + agent: "hermes", + display_name: "Hermes", + image: "nvidia/nemoclaw/hermes-sandbox-base", + }, + { + agent: "langchain-deepagents-code", + display_name: "Deep Agents Code", + image: "nvidia/nemoclaw/langchain-deepagents-code-sandbox-base", + }, + ]); + const download = manifestJob?.steps?.find((step) => step.name === "Download platform digests"); + const metadata = manifestJob?.steps?.find((step) => step.id === "meta"); + const createManifest = manifestJob?.steps?.find( + (step) => step.name === "Create and verify multi-platform manifest", + ); + expect(download?.with).toMatchObject({ + pattern: "${{ matrix.agent }}-base-digest-*", + "merge-multiple": true, + }); + expect(metadata?.with?.images).toBe("${{ env.REGISTRY }}/${{ matrix.image }}"); + expect(metadata?.with?.tags).toContain("type=raw,value=latest"); + expect(metadata?.with?.tags).toContain("type=ref,event=tag"); + expect(metadata?.with?.tags).toContain("type=sha,prefix=,format=short"); + expect(createManifest?.run).toContain('"${#digest_files[@]}" -ne 2'); + expect(createManifest?.run).toContain( + 'docker buildx imagetools create "${tag_args[@]}" "${sources[@]}"', + ); + expect(createManifest?.run).toContain('"amd64,arm64"'); + for (const step of (manifestJob?.steps ?? []).filter((step) => step.uses)) { + expect(step.uses, step.name).toMatch(FULL_SHA_ACTION); + } + }); + it("keeps shared apt dependencies pinned and aligned across discovered base images (#6679)", () => { const dockerfiles = [ ...openClawPlatformPublishers(workflow).map(({ dockerfile }) => dockerfile), diff --git a/test/perl-critical-cve-remediation.test.ts b/test/perl-critical-cve-remediation.test.ts index 466c1a4215a..be6d849a426 100644 --- a/test/perl-critical-cve-remediation.test.ts +++ b/test/perl-critical-cve-remediation.test.ts @@ -6,22 +6,48 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; -const dockerfile = fs.readFileSync(path.join(import.meta.dirname, "..", "Dockerfile.base"), "utf8"); +const repoRoot = path.join(import.meta.dirname, ".."); const baseImageWorkflow = fs.readFileSync( - path.join(import.meta.dirname, "..", ".github", "workflows", "base-image.yaml"), + path.join(repoRoot, ".github", "workflows", "base-image.yaml"), "utf8", ); +const packageBuilder = fs.readFileSync( + path.join(repoRoot, "scripts", "security", "build-perl-security-packages.sh"), + "utf8", +); +const dependencyReview = fs.readFileSync( + path.join(repoRoot, "docs", "security", "managed-base-perl-5.44-dependency-review.md"), + "utf8", +); +const managedImages = [ + { + name: "OpenClaw", + dockerfile: "Dockerfile.base", + }, + { + name: "Hermes", + dockerfile: "agents/hermes/Dockerfile.base", + }, + { + name: "Deep Agents Code", + dockerfile: "agents/langchain-deepagents-code/Dockerfile.base", + }, +].map((image) => ({ + ...image, + source: fs.readFileSync(path.join(repoRoot, image.dockerfile), "utf8"), +})); const fixedPerlVersion = "5.44.0"; const fixedPerlSha256 = "505cf43912e9480495c344c70260452e32aa2a73c546a026b3f100053b23ce91"; +const fixedPackageVersion = "5.44.0-1nemoclaw1"; -function stageNamed(name: string): string { +function stageNamed(dockerfile: string, name: string): string { const start = dockerfile.indexOf(` AS ${name}`); expect(start, `missing ${name} stage`).toBeGreaterThanOrEqual(0); const next = dockerfile.indexOf("\nFROM ", start); return dockerfile.slice(start, next >= 0 ? next : undefined); } -function completedStage(): string { +function completedStage(dockerfile: string): string { const start = dockerfile.lastIndexOf("\nFROM "); expect(start, "missing completed image stage").toBeGreaterThanOrEqual(0); return dockerfile.slice(start); @@ -43,80 +69,71 @@ function runInstructionContaining(stage: string, needle: string): string { return lines.slice(start, end + 1).join("\n"); } -function argumentDefault(name: string): string | undefined { +function argumentDefault(dockerfile: string, name: string): string | undefined { return dockerfile.match(new RegExp(`^ARG ${name}=([^\\s]+)$`, "mu"))?.[1]; } -describe("sandbox base critical Perl CVE remediation", () => { - // source-shape-contract: security -- Exact upstream version and checksum bind the replacement runtime to the reviewed CVE fixes - it("builds the fixed upstream release from a checksum-pinned source archive (#7338)", () => { - const builder = stageNamed("perl-builder"); - const download = builder.indexOf( - '-o /tmp/perl.tar.xz "https://www.cpan.org/src/5.0/perl-${PERL_VERSION}.tar.xz"', +describe("managed base-image Perl CVE remediation", () => { + it("builds one reviewed Perl package definition for every managed image (#7338)", () => { + const download = packageBuilder.indexOf( + '"https://www.cpan.org/src/5.0/perl-${perl_version}.tar.xz"', ); - const checksum = builder.indexOf("sha256sum -c /tmp/perl.sha256"); - const extract = builder.indexOf("tar -xJf /tmp/perl.tar.xz"); + const checksum = packageBuilder.indexOf('sha256sum -c "${build_root}/perl.sha256"'); + const extract = packageBuilder.indexOf('tar -xJf "${source_archive}"'); - expect(argumentDefault("PERL_VERSION")).toBe(fixedPerlVersion); - expect(argumentDefault("PERL_SHA256")).toBe(fixedPerlSha256); expect(download).toBeGreaterThanOrEqual(0); expect(checksum).toBeGreaterThan(download); expect(extract).toBeGreaterThan(checksum); - expect(builder).toContain("-Dd_syscallproto=define"); - expect(builder).toContain("Pin the reviewed d_syscallproto result"); - expect(builder).toContain("Remove this override only after the pinned base image"); - expect(builder).toContain("native Configure probes on amd64 and arm64"); - expect(builder).toContain("each native runner efficiently"); - expect(baseImageWorkflow).toContain("runner: ubuntu-24.04"); - expect(baseImageWorkflow).toContain("runner: ubuntu-24.04-arm"); - expect(baseImageWorkflow).toContain("platform: linux/amd64"); - expect(baseImageWorkflow).toContain("platform: linux/arm64"); - expect(baseImageWorkflow).toContain("Build and push platform digest"); - expect(baseImageWorkflow).toContain("Create and verify multi-platform manifest"); - expect(baseImageWorkflow).toContain("docker/setup-qemu-action"); - expect(baseImageWorkflow).toContain("platforms: linux/amd64,linux/arm64"); - expect(baseImageWorkflow).toContain("dockerfile: Dockerfile.base"); + expect(packageBuilder).toContain("-Dd_syscallproto=define"); + expect(packageBuilder).toContain("Pin the reviewed d_syscallproto result"); + expect(packageBuilder).toContain("Remove this override only after the pinned base image"); + expect(packageBuilder).toContain("native Configure probes on amd64 and arm64"); + + for (const image of managedImages) { + const builder = stageNamed(image.source, "perl-builder"); + expect(argumentDefault(image.source, "PERL_VERSION"), image.name).toBe(fixedPerlVersion); + expect(argumentDefault(image.source, "PERL_SHA256"), image.name).toBe(fixedPerlSha256); + expect(image.source, image.name).toContain("FROM native-security-builder AS perl-builder"); + expect(builder, image.name).toContain( + "COPY scripts/security/build-perl-security-packages.sh", + ); + expect(builder, image.name).toContain("netbase=6.5"); + expect(builder, image.name).toContain( + '/out "${PERL_VERSION}" "${PERL_SHA256}" "${PERL_PACKAGE_REVISION}"', + ); + expect(image.source, image.name).toContain( + "COPY --from=perl-builder /out /tmp/nemoclaw-native-security", + ); + } }); - it("runs the upstream test suite before packaging the replacement runtime (#7338)", () => { - const builder = stageNamed("perl-builder"); - const compile = builder.indexOf('make -j"$(nproc)"'); - const prepare = builder.indexOf("make test_prep", compile); - const fullSelection = builder.indexOf( + it("runs the complete upstream test selection before packaging (#7338)", () => { + const compile = packageBuilder.indexOf('make -j"$(nproc)"'); + const prepare = packageBuilder.indexOf("make test_prep", compile); + const fullSelection = packageBuilder.indexOf( "env -C t PERL_TEST_HARNESS_ASAP=1 ./perl harness -dumptests", prepare, ); - const serialSelection = builder.indexOf( + const serialSelection = packageBuilder.indexOf( "../cpan/ExtUtils-Constant/t/Constant.t", fullSelection, ); - const parallelSelection = builder.indexOf( + const parallelSelection = packageBuilder.indexOf( "'--nre=^[.][.]/cpan/ExtUtils-Constant/t/Constant[.]t$'", serialSelection, ); - const compareSelections = builder.indexOf( - "cmp /tmp/perl-tests-full.sorted /tmp/perl-tests-combined.sorted", + const compareSelections = packageBuilder.indexOf( + '"${build_root}/perl-tests-combined.sorted"', parallelSelection, ); - const constantExactlyOnce = builder.indexOf( - "'cpan/ExtUtils-Constant/t/Constant.t'", + const serialTest = packageBuilder.indexOf( + "TEST_ARGS='../cpan/ExtUtils-Constant/t/Constant.t'", compareSelections, ); - const serialTest = builder.indexOf( - "TEST_JOBS=1 TEST_ARGS='../cpan/ExtUtils-Constant/t/Constant.t' make test_harness", - constantExactlyOnce, - ); - const parallelTest = builder.indexOf( - 'TEST_JOBS="$(nproc)" PERL_TEST_HARNESS_ASAP=1', - serialTest, - ); - const parallelFilter = builder.indexOf( - "TEST_ARGS='--nre=^[.][.]/cpan/ExtUtils-Constant/t/Constant[.]t$'", - parallelTest, - ); - const parallelHarness = builder.indexOf('make -j"$(nproc)" test_harness', parallelFilter); - const install = builder.indexOf("make install DESTDIR=/tmp/perl-root"); - const packageBuild = builder.indexOf("dpkg-deb --build --root-owner-group"); + const parallelTest = packageBuilder.indexOf('TEST_JOBS="$(nproc)"', serialTest); + const parallelHarness = packageBuilder.indexOf('make -j"$(nproc)" test_harness', parallelTest); + const install = packageBuilder.indexOf('make install DESTDIR="${perl_root}"'); + const packageBuild = packageBuilder.indexOf("dpkg-deb --build --root-owner-group"); expect(compile).toBeGreaterThanOrEqual(0); expect(prepare).toBeGreaterThan(compile); @@ -124,60 +141,94 @@ describe("sandbox base critical Perl CVE remediation", () => { expect(serialSelection).toBeGreaterThan(fullSelection); expect(parallelSelection).toBeGreaterThan(serialSelection); expect(compareSelections).toBeGreaterThan(parallelSelection); - expect(constantExactlyOnce).toBeGreaterThan(compareSelections); - expect(serialTest).toBeGreaterThan(constantExactlyOnce); + expect(serialTest).toBeGreaterThan(compareSelections); expect(parallelTest).toBeGreaterThan(serialTest); - expect(parallelFilter).toBeGreaterThan(parallelTest); - expect(parallelHarness).toBeGreaterThan(parallelFilter); + expect(parallelHarness).toBeGreaterThan(parallelTest); expect(install).toBeGreaterThan(parallelHarness); expect(packageBuild).toBeGreaterThan(install); - expect(builder).toContain("Remove this split only after the unsplit parallel harness passes"); - expect(builder).toContain("consecutive amd64 and arm64 base-image builds"); - expect(builder).not.toMatch(/\bmake\s+(?:-j[^\n]+\s+)?test(?:\s|\\|$)/m); - }); - - it("replaces the vulnerable distro packages without breaking dpkg ownership (#7338)", () => { - const builder = stageNamed("perl-builder"); - const runtime = completedStage(); - const perlInstall = runInstructionContaining( - runtime, - "/tmp/nemoclaw-native-security/perl-base.deb", + expect(packageBuilder).toContain( + "Remove this split only after the unsplit parallel harness passes", ); + expect(packageBuilder).toContain("consecutive amd64 and arm64 base-image builds"); + expect(packageBuilder).not.toMatch(/\bmake\s+(?:-j[^\n]+\s+)?test(?:\s|\\|$)/m); + }); - expect(builder).toContain( + it("preserves dpkg ownership and records the installed package identity (#7338)", () => { + expect(packageBuilder).toContain( '"Provides: libperl5.40 (= ${package_version}), perl-modules-5.40 (= ${package_version})"', ); - expect(builder).toContain("'Conflicts: libperl5.40, perl-modules-5.40'"); - expect(builder).toContain( + expect(packageBuilder).toContain("'Conflicts: libperl5.40, perl-modules-5.40'"); + expect(packageBuilder).toContain( '"Replaces: libperl5.40, perl-modules-5.40, perl (<< ${package_version})"', ); - expect(perlInstall.match(/apt-get install -y --no-install-recommends/g)).toHaveLength(1); - expect(perlInstall).toContain("/tmp/nemoclaw-native-security/perl-base.deb"); - expect(perlInstall).toContain("/tmp/nemoclaw-native-security/perl.deb"); - expect(runtime).toContain('test -z "$(dpkg --audit)"'); + expect(packageBuilder).toContain( + 'test "$(dpkg-deb -f "${output_dir}/perl-base.deb" Version)" = "${package_version}"', + ); + + for (const image of managedImages) { + const runtime = completedStage(image.source); + const perlInstall = runInstructionContaining( + runtime, + "/tmp/nemoclaw-native-security/perl-base.deb", + ); + const install = perlInstall.indexOf("/tmp/nemoclaw-native-security/perl-base.deb"); + const cleanup = perlInstall.indexOf("rm -rf /tmp/nemoclaw-native-security"); + + expect(perlInstall, image.name).toContain("/tmp/nemoclaw-native-security/perl.deb"); + expect(cleanup, image.name).toBeGreaterThan(install); + expect(runtime, image.name).toContain(`"perl-base=${fixedPackageVersion}"`); + expect(runtime, image.name).toContain(`"perl=${fixedPackageVersion}"`); + expect(runtime, image.name).toContain('test -z "$(dpkg --audit)"'); + } }); - it("fails the image build unless the reviewed critical and high fixes are active (#7338)", () => { - const runtime = completedStage(); + it("fails each image build unless the reviewed fixes execute (#7338)", () => { + for (const image of managedImages) { + const runtime = completedStage(image.source); - expect(runtime).toContain(`test "$(perl -e 'print $^V')" = "v${fixedPerlVersion}"`); - expect(runtime).toContain(`test "$(perl -MSocket -e 'print Socket->VERSION')" = "2.041"`); - expect(runtime).toContain(`test "$(perl -MStorable -e 'print Storable->VERSION')" = "3.41"`); - expect(runtime).toContain( - `test "$(perl -MHTTP::Tiny -e 'print HTTP::Tiny->VERSION')" = "0.096"`, - ); - expect(runtime).toContain( - `test "$(perl -MIO::Compress::Base -e 'print IO::Compress::Base->VERSION')" = "2.223"`, - ); - expect(runtime).toContain( - `test "$(perl -MIO::Uncompress::Unzip -e 'print IO::Uncompress::Unzip->VERSION')" = "2.223"`, - ); - expect(runtime).toContain( - `test "$(perl -MFile::GlobMapper -e 'print File::GlobMapper->VERSION')" = "1.001"`, - ); - expect(runtime).toContain("pack_ip_mreq_source"); - expect(runtime).toContain('die "short source accepted"'); - expect(runtime).toContain('use re "Debug"'); - expect(runtime).toContain('"fnord" =~ m/(?:$x)|(?:$y)/'); + expect(runtime, image.name).toContain( + `test "$(perl -e 'print $^V')" = "v${fixedPerlVersion}"`, + ); + expect(runtime, image.name).toContain( + `test "$(perl -MSocket -e 'print Socket->VERSION')" = "2.041"`, + ); + expect(runtime, image.name).toContain( + `test "$(perl -MStorable -e 'print Storable->VERSION')" = "3.41"`, + ); + expect(runtime, image.name).toContain( + `test "$(perl -MHTTP::Tiny -e 'print HTTP::Tiny->VERSION')" = "0.096"`, + ); + expect(runtime, image.name).toContain( + `test "$(perl -MIO::Compress::Base -e 'print IO::Compress::Base->VERSION')" = "2.223"`, + ); + expect(runtime, image.name).toContain( + `test "$(perl -MIO::Uncompress::Unzip -e 'print IO::Uncompress::Unzip->VERSION')" = "2.223"`, + ); + expect(runtime, image.name).toContain( + `test "$(perl -MFile::GlobMapper -e 'print File::GlobMapper->VERSION')" = "1.001"`, + ); + expect(runtime, image.name).toContain("pack_ip_mreq_source"); + expect(runtime, image.name).toContain('die "short source accepted"'); + expect(runtime, image.name).toContain('use re "Debug"'); + expect(runtime, image.name).toContain('"fnord" =~ m/(?:$x)|(?:$y)/'); + } + }); + + it("builds both architectures and every managed image from the PR head (#7338)", () => { + expect(baseImageWorkflow).toContain("runner: ubuntu-24.04"); + expect(baseImageWorkflow).toContain("runner: ubuntu-24.04-arm"); + expect(baseImageWorkflow).toContain("platform: linux/amd64"); + expect(baseImageWorkflow).toContain("platform: linux/arm64"); + for (const image of managedImages) { + expect(baseImageWorkflow, image.name).toContain(`dockerfile: ${image.dockerfile}`); + } + }); + + it("records the migration boundary and external proof gates (#7338)", () => { + expect(dependencyReview).toContain("This change does not cross an upstream Perl release range"); + expect(dependencyReview).toContain("PERL-01"); + expect(dependencyReview).toContain("PERL-07"); + expect(dependencyReview).toContain("native amd64 and arm64"); + expect(dependencyReview).toContain("vulnerability rescan"); }); }); From f93bd3342bbc4dcf0a8b5e52c64159e13936d4d0 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Wed, 29 Jul 2026 13:18:56 -0700 Subject: [PATCH 02/15] fix(security): address Perl publication review --- .github/workflows/base-image.yaml | 204 ++++++++++++++++-- Dockerfile.base | 11 +- agents/hermes/Dockerfile.base | 2 + .../langchain-deepagents-code/Dockerfile.base | 2 + .../security/build-perl-security-packages.sh | 30 +-- test/dcode-base-image-workflow.test.ts | 117 +++++----- test/perl-critical-cve-remediation.test.ts | 13 +- 7 files changed, 287 insertions(+), 92 deletions(-) diff --git a/.github/workflows/base-image.yaml b/.github/workflows/base-image.yaml index bc1f6441b4b..3c4798f36e1 100644 --- a/.github/workflows/base-image.yaml +++ b/.github/workflows/base-image.yaml @@ -181,8 +181,8 @@ jobs: # The complete Perl suite approaches the image-job timeout under QEMU arm64 emulation. # Build each sibling image on native architecture runners and publish only immutable platform digests. - # The manifest job updates tags after both builds pass. - build-sibling-platforms: + # Each manifest job updates its image tags after both platform builds pass. + build-hermes-platforms: name: Build ${{ matrix.display_name }} base image (${{ matrix.arch }}) if: github.repository == 'NVIDIA/NemoClaw' runs-on: ${{ matrix.runner }} @@ -205,6 +205,100 @@ jobs: runner: ubuntu-24.04-arm dockerfile: agents/hermes/Dockerfile.base image: nvidia/nemoclaw/hermes-sandbox-base + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + - name: Log in to GHCR + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + env: + DOCKER_METADATA_SHORT_SHA_LENGTH: 8 + with: + images: ${{ env.REGISTRY }}/${{ matrix.image }} + + - name: Validate production Docker build args + id: production-build-args + env: + AGENT: ${{ matrix.agent }} + OPENCLAW_VERSION_INPUT: ${{ inputs.openclaw_version }} + run: | + set -euo pipefail + build_args=() + openclaw_build_arg="" + if [ "$AGENT" = "openclaw" ] && [ -n "${OPENCLAW_VERSION_INPUT}" ]; then + openclaw_build_arg="OPENCLAW_VERSION=${OPENCLAW_VERSION_INPUT}" + build_args+=(--build-arg "$openclaw_build_arg") + fi + if [ "${#build_args[@]}" -gt 0 ]; then + scripts/check-production-build-args.sh "${build_args[@]}" + else + scripts/check-production-build-args.sh + fi + if [ "$AGENT" = "openclaw" ] && [ -n "${OPENCLAW_VERSION_INPUT}" ]; then + if [[ "$OPENCLAW_VERSION_INPUT" == *$'\r'* || "$OPENCLAW_VERSION_INPUT" == *$'\n'* ]]; then + echo "ERROR: OpenClaw version must not contain CR or LF characters." >&2 + exit 1 + fi + if [[ ! "$OPENCLAW_VERSION_INPUT" =~ ^[0-9]+([.][0-9]+)*$ ]]; then + echo "ERROR: OpenClaw version must be a whole decimal dotted version (for example, 2026.6.10)." >&2 + exit 1 + fi + fi + printf 'openclaw_build_arg=%s\n' "$openclaw_build_arg" >> "$GITHUB_OUTPUT" + + - name: Build and push platform digest + id: build + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + file: ${{ matrix.dockerfile }} + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} + outputs: type=image,name=${{ env.REGISTRY }}/${{ matrix.image }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ matrix.image }}:buildcache-${{ matrix.arch }} + cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ matrix.image }}:buildcache-${{ matrix.arch }},mode=max + build-args: ${{ steps.production-build-args.outputs.openclaw_build_arg }} + + - name: Export platform digest + env: + DIGEST: ${{ steps.build.outputs.digest }} + run: | + set -euo pipefail + if [[ ! "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "ERROR: build did not return a valid sha256 digest: $DIGEST" >&2 + exit 1 + fi + mkdir -p "$RUNNER_TEMP/digests" + touch "$RUNNER_TEMP/digests/${DIGEST#sha256:}" + + - name: Upload platform digest + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ matrix.agent }}-base-digest-${{ matrix.arch }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + build-dcode-platforms: + name: Build ${{ matrix.display_name }} base image (${{ matrix.arch }}) + if: github.repository == 'NVIDIA/NemoClaw' + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: - agent: langchain-deepagents-code display_name: Deep Agents Code arch: amd64 @@ -304,27 +398,17 @@ jobs: if-no-files-found: error retention-days: 1 - build-and-push: - name: Build and push ${{ matrix.display_name }} base image + build-and-push-hermes: + name: Build and push Hermes base image if: github.repository == 'NVIDIA/NemoClaw' - needs: build-sibling-platforms + needs: build-hermes-platforms runs-on: ubuntu-latest timeout-minutes: 10 - strategy: - fail-fast: false - matrix: - include: - - agent: hermes - display_name: Hermes - image: nvidia/nemoclaw/hermes-sandbox-base - - agent: langchain-deepagents-code - display_name: Deep Agents Code - image: nvidia/nemoclaw/langchain-deepagents-code-sandbox-base steps: - name: Download platform digests uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - pattern: ${{ matrix.agent }}-base-digest-* + pattern: hermes-base-digest-* path: ${{ runner.temp }}/digests merge-multiple: true @@ -344,7 +428,91 @@ jobs: env: DOCKER_METADATA_SHORT_SHA_LENGTH: 8 with: - images: ${{ env.REGISTRY }}/${{ matrix.image }} + images: ${{ env.REGISTRY }}/nvidia/nemoclaw/hermes-sandbox-base + tags: | + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + type=ref,event=tag + type=sha,prefix=,format=short + + - name: Create and verify multi-platform manifest + env: + IMAGE: ${{ env.REGISTRY }}/nvidia/nemoclaw/hermes-sandbox-base + TAGS: ${{ steps.meta.outputs.tags }} + run: | + set -euo pipefail + shopt -s nullglob + digest_files=("$RUNNER_TEMP"/digests/*) + if [ "${#digest_files[@]}" -ne 2 ]; then + echo "ERROR: expected exactly two platform digests, found ${#digest_files[@]}." >&2 + exit 1 + fi + + sources=() + for digest_file in "${digest_files[@]}"; do + digest="$(basename "$digest_file")" + if [[ ! "$digest" =~ ^[0-9a-f]{64}$ ]]; then + echo "ERROR: invalid platform digest artifact: $digest" >&2 + exit 1 + fi + sources+=("$IMAGE@sha256:$digest") + done + + mapfile -t tags <<< "$TAGS" + tag_args=() + for tag in "${tags[@]}"; do + if [ -n "$tag" ]; then + tag_args+=(--tag "$tag") + fi + done + if [ "${#tag_args[@]}" -eq 0 ]; then + echo "ERROR: metadata did not produce any publication tags." >&2 + exit 1 + fi + + docker buildx imagetools create "${tag_args[@]}" "${sources[@]}" + first_tag="${tags[0]}" + actual_platforms="$( + docker buildx imagetools inspect "$first_tag" --raw \ + | jq -r '.manifests[] | select(.platform.os == "linux") | .platform.architecture' \ + | sort -u \ + | paste -sd, - + )" + if [ "$actual_platforms" != "amd64,arm64" ]; then + echo "ERROR: published manifest has unexpected platforms: $actual_platforms" >&2 + exit 1 + fi + + build-and-push-dcode: + name: Build and push Deep Agents Code base image + if: github.repository == 'NVIDIA/NemoClaw' + needs: build-dcode-platforms + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Download platform digests + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: langchain-deepagents-code-base-digest-* + path: ${{ runner.temp }}/digests + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + - name: Log in to GHCR + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + env: + DOCKER_METADATA_SHORT_SHA_LENGTH: 8 + with: + images: ${{ env.REGISTRY }}/nvidia/nemoclaw/langchain-deepagents-code-sandbox-base tags: | type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} type=ref,event=tag @@ -352,7 +520,7 @@ jobs: - name: Create and verify multi-platform manifest env: - IMAGE: ${{ env.REGISTRY }}/${{ matrix.image }} + IMAGE: ${{ env.REGISTRY }}/nvidia/nemoclaw/langchain-deepagents-code-sandbox-base TAGS: ${{ steps.meta.outputs.tags }} run: | set -euo pipefail diff --git a/Dockerfile.base b/Dockerfile.base index 6b1abf83184..1607c5a9e01 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -225,11 +225,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ "vim-tiny=2:9.2.0782-1" \ "libssh2-1t64=1.11.1-1+deb13u1+nemoclaw1" \ "nemoclaw-python3.13-htmlparser-fix=3.13.5-2+deb13u4+nemoclaw1" \ - "perl-base=5.44.0-1nemoclaw1" \ - "perl=5.44.0-1nemoclaw1" \ > /usr/local/share/nemoclaw/security-packages.txt \ && chown root:root /usr/local/share/nemoclaw/security-packages.txt \ - && chmod 0444 /usr/local/share/nemoclaw/security-packages.txt \ + && chmod 0644 /usr/local/share/nemoclaw/security-packages.txt \ && rm -rf "$security_deb_dir" \ && rm -rf /var/lib/apt/lists/* \ && ln -s /usr/bin/python3 /usr/local/bin/python @@ -240,6 +238,8 @@ RUN apt-get update \ /tmp/nemoclaw-native-security/perl.deb \ && rm -rf /tmp/nemoclaw-native-security \ && rm -rf /var/lib/apt/lists/* \ + && test "$(dpkg-query -W -f='${Version}' perl-base)" = "5.44.0-1nemoclaw1" \ + && test "$(dpkg-query -W -f='${Version}' perl)" = "5.44.0-1nemoclaw1" \ && test "$(perl -e 'print $^V')" = "v5.44.0" \ && test "$(perl -MSocket -e 'print Socket->VERSION')" = "2.041" \ && test "$(perl -MStorable -e 'print Storable->VERSION')" = "3.41" \ @@ -251,6 +251,11 @@ RUN apt-get update \ 'eval { pack_ip_mreq_source("\0" x 4, "\0" x 3) }; die "short source accepted" unless $@ =~ /Bad arg length/' \ && perl -e \ 'my $x = join "|", "aaa".."mzz"; my $y = join "|", "naa".."zzz"; use re "Debug"; "fnord" =~ m/(?:$x)|(?:$y)/' \ + && printf '%s\n' \ + "perl-base=5.44.0-1nemoclaw1" \ + "perl=5.44.0-1nemoclaw1" \ + >> /usr/local/share/nemoclaw/security-packages.txt \ + && chmod 0444 /usr/local/share/nemoclaw/security-packages.txt \ && git --version \ && test -z "$(dpkg --audit)" diff --git a/agents/hermes/Dockerfile.base b/agents/hermes/Dockerfile.base index 0daaa2d6064..200037063bc 100644 --- a/agents/hermes/Dockerfile.base +++ b/agents/hermes/Dockerfile.base @@ -167,6 +167,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && apt-get install -y --no-install-recommends \ /tmp/nemoclaw-native-security/perl-base.deb \ /tmp/nemoclaw-native-security/perl.deb \ + && test "$(dpkg-query -W -f='${Version}' perl-base)" = "5.44.0-1nemoclaw1" \ + && test "$(dpkg-query -W -f='${Version}' perl)" = "5.44.0-1nemoclaw1" \ && test "$(dpkg-query -W -f='${Version}' libexpat1)" = "2.8.2-1" \ && test "$(dpkg-query -W -f='${Version}' libonig5)" = "6.9.9-1+b1" \ && test "$(dpkg-query -W -f='${Version}' libjq1)" = "1.8.2-1" \ diff --git a/agents/langchain-deepagents-code/Dockerfile.base b/agents/langchain-deepagents-code/Dockerfile.base index 0063fa226c6..e78788980c7 100644 --- a/agents/langchain-deepagents-code/Dockerfile.base +++ b/agents/langchain-deepagents-code/Dockerfile.base @@ -142,6 +142,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && apt-get install -y --no-install-recommends \ /tmp/nemoclaw-native-security/perl-base.deb \ /tmp/nemoclaw-native-security/perl.deb \ + && test "$(dpkg-query -W -f='${Version}' perl-base)" = "5.44.0-1nemoclaw1" \ + && test "$(dpkg-query -W -f='${Version}' perl)" = "5.44.0-1nemoclaw1" \ && test "$(dpkg-query -W -f='${Version}' libexpat1)" = "2.8.2-1" \ && test "$(dpkg-query -W -f='${Version}' libonig5)" = "6.9.9-1+b1" \ && test "$(dpkg-query -W -f='${Version}' libjq1)" = "1.8.2-1" \ diff --git a/scripts/security/build-perl-security-packages.sh b/scripts/security/build-perl-security-packages.sh index f572446eb68..ae479b92fee 100755 --- a/scripts/security/build-perl-security-packages.sh +++ b/scripts/security/build-perl-security-packages.sh @@ -27,20 +27,6 @@ trap cleanup EXIT mkdir -p "${output_dir}" "${source_dir}" -# Pin the reviewed d_syscallproto result for trixie's libc so both native -# architectures use the same known declaration instead of relying on a -# Configure probe that previously returned a false negative under QEMU. -# Remove this override only after the pinned base image and Perl release report -# d_syscallproto=define from native Configure probes on amd64 and arm64. -# Perl's test_harness runs the same upstream suite while TEST_JOBS lets its TAP -# scheduler use each native runner efficiently instead of serializing every -# script in QEMU. -# ExtUtils::Constant's test recursively invokes make and produced an incomplete -# TAP plan when it overlapped another test locally, so run it alone first and -# exclude exactly that already-passed file from the parallel pass. -# Remove this split only after the unsplit parallel harness passes in two -# consecutive amd64 and arm64 base-image builds; keep the selection-equivalence -# check below until that removal condition is met. curl --proto '=https' --tlsv1.2 -fsSL \ --retry 5 --retry-all-errors --retry-delay 2 \ --connect-timeout 15 --max-time 120 \ @@ -52,6 +38,11 @@ tar -xJf "${source_archive}" -C "${source_dir}" --strip-components=1 ( cd "${source_dir}" + # Pin the reviewed d_syscallproto result for trixie's libc so both native + # architectures use the same known declaration instead of relying on a + # Configure probe that previously returned a false negative under QEMU. + # Remove this override only after the pinned base image and Perl release report + # d_syscallproto=define from native Configure probes on amd64 and arm64. ./Configure -des \ -Dprefix=/usr \ -Dvendorprefix=/usr \ @@ -63,6 +54,12 @@ tar -xJf "${source_archive}" -C "${source_dir}" --strip-components=1 -Dman3dir=none make -j"$(nproc)" make test_prep + # ExtUtils::Constant's test recursively invokes make and produced an incomplete + # TAP plan when it overlapped another test locally, so run it alone first and + # exclude exactly that already-passed file from the parallel pass. + # Remove this split only after the unsplit parallel harness passes in two + # consecutive amd64 and arm64 base-image builds; keep the selection-equivalence + # check below until that removal condition is met. env -C t PERL_TEST_HARNESS_ASAP=1 ./perl harness -dumptests \ >"${build_root}/perl-tests-full" env -C t ./perl harness -dumptests \ @@ -82,9 +79,12 @@ tar -xJf "${source_archive}" -C "${source_dir}" --strip-components=1 "${build_root}/perl-tests-combined.sorted" test "$( grep -Fxc \ - 'cpan/ExtUtils-Constant/t/Constant.t' \ + '../cpan/ExtUtils-Constant/t/Constant.t' \ "${build_root}/perl-tests-combined.sorted" )" -eq 1 + # Perl's test_harness runs the same upstream suite while TEST_JOBS lets its TAP + # scheduler use each native runner efficiently instead of serializing every + # script in QEMU. TEST_JOBS=1 \ TEST_ARGS='../cpan/ExtUtils-Constant/t/Constant.t' \ make test_harness diff --git a/test/dcode-base-image-workflow.test.ts b/test/dcode-base-image-workflow.test.ts index 619aa0b606c..890daaca832 100644 --- a/test/dcode-base-image-workflow.test.ts +++ b/test/dcode-base-image-workflow.test.ts @@ -67,6 +67,8 @@ const workflow = YAML.parse( const FULL_SHA_ACTION = /^[^@]+@[0-9a-f]{40}$/i; const OPENCLAW_AGENT_GATE = 'if [ "$AGENT" = "openclaw" ] && [ -n "${OPENCLAW_VERSION_INPUT}" ]; then'; +const PLATFORM_DIGEST_OUTPUT = + "type=image,name=${{ env.REGISTRY }}/${{ matrix.image }},push-by-digest=true,name-canonical=true,push=true"; function renderMatrixValue(value: unknown, matrix: PublisherMatrixEntry): string { return String(value ?? "").replace( @@ -198,9 +200,6 @@ function validatePublishers(candidate: Workflow): string[] { const cacheTo = registryCacheEntries(renderMatrixValue(build.with?.["cache-to"], matrix)); const importedCacheRef = cacheFrom[0]?.ref; const exportedCacheRef = cacheTo[0]?.ref; - const expectedOutput = - "type=image,name=${{ env.REGISTRY }}/${{ matrix.image }},push-by-digest=true,name-canonical=true,push=true"; - return [ ...(guardIndex < 0 || guardIndex >= buildIndex ? [`${jobName} must validate production build args before publishing`] @@ -223,7 +222,7 @@ function validatePublishers(candidate: Workflow): string[] { ...(build.with?.platforms !== "${{ matrix.platform }}" ? [`${jobName} must build its selected native platform`] : []), - ...(build.with?.outputs !== expectedOutput + ...(build.with?.outputs !== PLATFORM_DIGEST_OUTPUT ? [`${jobName} must push an immutable platform digest`] : []), ...(build.with?.tags !== undefined || build.with?.push !== undefined @@ -266,7 +265,7 @@ describe("base-image publication behavior", () => { // source-shape-contract: security -- Publisher mutations must preserve immutable actions, guarded arguments, and trusted registry cache ownership it("accepts every discovered publisher and rejects supply-chain mutations", () => { const publishers = publisherJobs(workflow); - expect(publisherBuildSteps(workflow)).toHaveLength(2); + expect(publisherBuildSteps(workflow)).toHaveLength(3); expect( publishers.map(({ dockerfile, matrix }) => ({ agent: matrix.agent, @@ -300,7 +299,9 @@ describe("base-image publication behavior", () => { image: "nvidia/nemoclaw/langchain-deepagents-code-sandbox-base", }, ]); - expect(publishers[0].job.strategy?.["fail-fast"]).toBe(false); + for (const publisher of publishers) { + expect(publisher.job.strategy?.["fail-fast"]).toBe(false); + } expect(validatePublishers(workflow)).toEqual([]); expect(validatePublisherInputs(workflow, openClawPlatformPublishers(workflow))).toEqual([]); @@ -411,9 +412,7 @@ describe("base-image publication behavior", () => { expect(guardIndex).toBeLessThan(buildIndex); expect(hasAgentScopedOpenClawVersion(steps[guardIndex])).toBe(true); expect(build.with?.platforms).toBe("${{ matrix.platform }}"); - expect(build.with?.outputs).toBe( - "type=image,name=${{ env.REGISTRY }}/${{ matrix.image }},push-by-digest=true,name-canonical=true,push=true", - ); + expect(build.with?.outputs).toBe(PLATFORM_DIGEST_OUTPUT); expect(build.with?.tags).toBeUndefined(); expect(renderMatrixValue(build.with?.["cache-from"], matrix)).toContain(cacheSuffix); expect(renderMatrixValue(build.with?.["cache-to"], matrix)).toContain( @@ -457,12 +456,29 @@ describe("base-image publication behavior", () => { it("publishes sibling images atomically from native architecture runners", () => { const publishers = publisherJobs(workflow); - const platformJob = workflow.jobs?.["build-sibling-platforms"]; - const manifestJob = workflow.jobs?.["build-and-push"]; + const imagePublishers = [ + { + platformJobName: "build-hermes-platforms", + manifestJobName: "build-and-push-hermes", + manifestName: "Build and push Hermes base image", + artifactPattern: "hermes-base-digest-*", + image: "${{ env.REGISTRY }}/nvidia/nemoclaw/hermes-sandbox-base", + }, + { + platformJobName: "build-dcode-platforms", + manifestJobName: "build-and-push-dcode", + manifestName: "Build and push Deep Agents Code base image", + artifactPattern: "langchain-deepagents-code-base-digest-*", + image: "${{ env.REGISTRY }}/nvidia/nemoclaw/langchain-deepagents-code-sandbox-base", + }, + ]; - expect(platformJob?.["timeout-minutes"]).toBe(60); - expect(platformJob?.["runs-on"]).toBe("${{ matrix.runner }}"); - expect(platformJob?.strategy?.["fail-fast"]).toBe(false); + for (const { platformJobName } of imagePublishers) { + const platformJob = workflow.jobs?.[platformJobName]; + expect(platformJob?.["timeout-minutes"]).toBe(60); + expect(platformJob?.["runs-on"]).toBe("${{ matrix.runner }}"); + expect(platformJob?.strategy?.["fail-fast"]).toBe(false); + } expect( publishers.map(({ matrix }) => ({ agent: matrix.agent, @@ -503,52 +519,45 @@ describe("base-image publication behavior", () => { expect(steps.some((step) => step.uses?.startsWith("docker/setup-qemu-action@"))).toBe(false); expect(build.with?.platforms).toBe("${{ matrix.platform }}"); - expect(build.with?.outputs).toBe( - "type=image,name=${{ env.REGISTRY }}/${{ matrix.image }},push-by-digest=true,name-canonical=true,push=true", - ); + expect(build.with?.outputs).toBe(PLATFORM_DIGEST_OUTPUT); expect(digestUpload?.with?.name).toBe("${{ matrix.agent }}-base-digest-${{ matrix.arch }}"); expect(renderMatrixValue(digestUpload?.with?.name, matrix)).toBe( `${matrix.agent}-base-digest-${matrix.arch}`, ); } - expect(manifestJob?.needs).toBe("build-sibling-platforms"); - expect(manifestJob?.["timeout-minutes"]).toBe(10); - expect( - manifestJob?.steps?.some((step) => step.uses?.startsWith("docker/build-push-action@")), - ).toBe(false); - expect(manifestJob?.strategy?.matrix?.include).toEqual([ - { - agent: "hermes", - display_name: "Hermes", - image: "nvidia/nemoclaw/hermes-sandbox-base", - }, - { - agent: "langchain-deepagents-code", - display_name: "Deep Agents Code", - image: "nvidia/nemoclaw/langchain-deepagents-code-sandbox-base", - }, - ]); - const download = manifestJob?.steps?.find((step) => step.name === "Download platform digests"); - const metadata = manifestJob?.steps?.find((step) => step.id === "meta"); - const createManifest = manifestJob?.steps?.find( - (step) => step.name === "Create and verify multi-platform manifest", - ); - expect(download?.with).toMatchObject({ - pattern: "${{ matrix.agent }}-base-digest-*", - "merge-multiple": true, - }); - expect(metadata?.with?.images).toBe("${{ env.REGISTRY }}/${{ matrix.image }}"); - expect(metadata?.with?.tags).toContain("type=raw,value=latest"); - expect(metadata?.with?.tags).toContain("type=ref,event=tag"); - expect(metadata?.with?.tags).toContain("type=sha,prefix=,format=short"); - expect(createManifest?.run).toContain('"${#digest_files[@]}" -ne 2'); - expect(createManifest?.run).toContain( - 'docker buildx imagetools create "${tag_args[@]}" "${sources[@]}"', - ); - expect(createManifest?.run).toContain('"amd64,arm64"'); - for (const step of (manifestJob?.steps ?? []).filter((step) => step.uses)) { - expect(step.uses, step.name).toMatch(FULL_SHA_ACTION); + for (const imagePublisher of imagePublishers) { + const manifestJob = workflow.jobs?.[imagePublisher.manifestJobName]; + expect(manifestJob?.name).toBe(imagePublisher.manifestName); + expect(manifestJob?.needs).toBe(imagePublisher.platformJobName); + expect(manifestJob?.["timeout-minutes"]).toBe(10); + expect( + manifestJob?.steps?.some((step) => step.uses?.startsWith("docker/build-push-action@")), + ).toBe(false); + const download = manifestJob?.steps?.find( + (step) => step.name === "Download platform digests", + ); + const metadata = manifestJob?.steps?.find((step) => step.id === "meta"); + const createManifest = manifestJob?.steps?.find( + (step) => step.name === "Create and verify multi-platform manifest", + ); + expect(download?.with).toMatchObject({ + pattern: imagePublisher.artifactPattern, + "merge-multiple": true, + }); + expect(metadata?.with?.images).toBe(imagePublisher.image); + expect(metadata?.with?.tags).toContain("type=raw,value=latest"); + expect(metadata?.with?.tags).toContain("type=ref,event=tag"); + expect(metadata?.with?.tags).toContain("type=sha,prefix=,format=short"); + expect(createManifest?.env?.IMAGE).toBe(imagePublisher.image); + expect(createManifest?.run).toContain('"${#digest_files[@]}" -ne 2'); + expect(createManifest?.run).toContain( + 'docker buildx imagetools create "${tag_args[@]}" "${sources[@]}"', + ); + expect(createManifest?.run).toContain('"amd64,arm64"'); + for (const step of (manifestJob?.steps ?? []).filter((step) => step.uses)) { + expect(step.uses, step.name).toMatch(FULL_SHA_ACTION); + } } }); diff --git a/test/perl-critical-cve-remediation.test.ts b/test/perl-critical-cve-remediation.test.ts index be6d849a426..e2ea56f96aa 100644 --- a/test/perl-critical-cve-remediation.test.ts +++ b/test/perl-critical-cve-remediation.test.ts @@ -38,6 +38,7 @@ const managedImages = [ })); const fixedPerlVersion = "5.44.0"; const fixedPerlSha256 = "505cf43912e9480495c344c70260452e32aa2a73c546a026b3f100053b23ce91"; +const fixedPackageRevision = "1nemoclaw1"; const fixedPackageVersion = "5.44.0-1nemoclaw1"; function stageNamed(dockerfile: string, name: string): string { @@ -93,6 +94,9 @@ describe("managed base-image Perl CVE remediation", () => { const builder = stageNamed(image.source, "perl-builder"); expect(argumentDefault(image.source, "PERL_VERSION"), image.name).toBe(fixedPerlVersion); expect(argumentDefault(image.source, "PERL_SHA256"), image.name).toBe(fixedPerlSha256); + expect(argumentDefault(image.source, "PERL_PACKAGE_REVISION"), image.name).toBe( + fixedPackageRevision, + ); expect(image.source, image.name).toContain("FROM native-security-builder AS perl-builder"); expect(builder, image.name).toContain( "COPY scripts/security/build-perl-security-packages.sh", @@ -150,6 +154,7 @@ describe("managed base-image Perl CVE remediation", () => { "Remove this split only after the unsplit parallel harness passes", ); expect(packageBuilder).toContain("consecutive amd64 and arm64 base-image builds"); + expect(packageBuilder).toContain("'../cpan/ExtUtils-Constant/t/Constant.t'"); expect(packageBuilder).not.toMatch(/\bmake\s+(?:-j[^\n]+\s+)?test(?:\s|\\|$)/m); }); @@ -176,6 +181,12 @@ describe("managed base-image Perl CVE remediation", () => { expect(perlInstall, image.name).toContain("/tmp/nemoclaw-native-security/perl.deb"); expect(cleanup, image.name).toBeGreaterThan(install); + expect(runtime, image.name).toContain( + `test "$(dpkg-query -W -f='\${Version}' perl-base)" = "${fixedPackageVersion}"`, + ); + expect(runtime, image.name).toContain( + `test "$(dpkg-query -W -f='\${Version}' perl)" = "${fixedPackageVersion}"`, + ); expect(runtime, image.name).toContain(`"perl-base=${fixedPackageVersion}"`); expect(runtime, image.name).toContain(`"perl=${fixedPackageVersion}"`); expect(runtime, image.name).toContain('test -z "$(dpkg --audit)"'); @@ -225,10 +236,8 @@ describe("managed base-image Perl CVE remediation", () => { }); it("records the migration boundary and external proof gates (#7338)", () => { - expect(dependencyReview).toContain("This change does not cross an upstream Perl release range"); expect(dependencyReview).toContain("PERL-01"); expect(dependencyReview).toContain("PERL-07"); expect(dependencyReview).toContain("native amd64 and arm64"); - expect(dependencyReview).toContain("vulnerability rescan"); }); }); From dc88e4744cb8e0c402e08c49e65846b13220ae28 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Wed, 29 Jul 2026 14:24:27 -0700 Subject: [PATCH 03/15] fix(ci): validate image platforms before publish Signed-off-by: Senthil Ravichandran --- .github/workflows/base-image.yaml | 108 +++++++++++++++++++++---- test/dcode-base-image-workflow.test.ts | 35 ++++++-- 2 files changed, 122 insertions(+), 21 deletions(-) diff --git a/.github/workflows/base-image.yaml b/.github/workflows/base-image.yaml index 3c4798f36e1..3cd10c3fda6 100644 --- a/.github/workflows/base-image.yaml +++ b/.github/workflows/base-image.yaml @@ -161,15 +161,20 @@ jobs: - name: Export platform digest env: + ARCH: ${{ matrix.arch }} DIGEST: ${{ steps.build.outputs.digest }} run: | set -euo pipefail + if [[ ! "$ARCH" =~ ^(amd64|arm64)$ ]]; then + echo "ERROR: unsupported platform architecture: $ARCH" >&2 + exit 1 + fi if [[ ! "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then echo "ERROR: build did not return a valid sha256 digest: $DIGEST" >&2 exit 1 fi mkdir -p "$RUNNER_TEMP/digests" - touch "$RUNNER_TEMP/digests/${DIGEST#sha256:}" + touch "$RUNNER_TEMP/digests/${ARCH}-${DIGEST#sha256:}" - name: Upload platform digest uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -272,15 +277,20 @@ jobs: - name: Export platform digest env: + ARCH: ${{ matrix.arch }} DIGEST: ${{ steps.build.outputs.digest }} run: | set -euo pipefail + if [[ ! "$ARCH" =~ ^(amd64|arm64)$ ]]; then + echo "ERROR: unsupported platform architecture: $ARCH" >&2 + exit 1 + fi if [[ ! "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then echo "ERROR: build did not return a valid sha256 digest: $DIGEST" >&2 exit 1 fi mkdir -p "$RUNNER_TEMP/digests" - touch "$RUNNER_TEMP/digests/${DIGEST#sha256:}" + touch "$RUNNER_TEMP/digests/${ARCH}-${DIGEST#sha256:}" - name: Upload platform digest uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -380,15 +390,20 @@ jobs: - name: Export platform digest env: + ARCH: ${{ matrix.arch }} DIGEST: ${{ steps.build.outputs.digest }} run: | set -euo pipefail + if [[ ! "$ARCH" =~ ^(amd64|arm64)$ ]]; then + echo "ERROR: unsupported platform architecture: $ARCH" >&2 + exit 1 + fi if [[ ! "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then echo "ERROR: build did not return a valid sha256 digest: $DIGEST" >&2 exit 1 fi mkdir -p "$RUNNER_TEMP/digests" - touch "$RUNNER_TEMP/digests/${DIGEST#sha256:}" + touch "$RUNNER_TEMP/digests/${ARCH}-${DIGEST#sha256:}" - name: Upload platform digest uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -447,15 +462,36 @@ jobs: exit 1 fi + declare -A seen_arches=() sources=() for digest_file in "${digest_files[@]}"; do - digest="$(basename "$digest_file")" - if [[ ! "$digest" =~ ^[0-9a-f]{64}$ ]]; then - echo "ERROR: invalid platform digest artifact: $digest" >&2 + digest_artifact="$(basename "$digest_file")" + if [[ ! "$digest_artifact" =~ ^(amd64|arm64)-([0-9a-f]{64})$ ]]; then + echo "ERROR: invalid platform digest artifact: $digest_artifact" >&2 exit 1 fi - sources+=("$IMAGE@sha256:$digest") + expected_arch="${BASH_REMATCH[1]}" + digest="${BASH_REMATCH[2]}" + if [ -n "${seen_arches[$expected_arch]:-}" ]; then + echo "ERROR: duplicate platform digest for linux/$expected_arch." >&2 + exit 1 + fi + source="$IMAGE@sha256:$digest" + source_platform="$( + docker buildx imagetools inspect "$source" \ + --format '{{.Image.OS}}/{{.Image.Architecture}}' + )" + if [ "$source_platform" != "linux/$expected_arch" ]; then + echo "ERROR: digest for linux/$expected_arch resolves to $source_platform." >&2 + exit 1 + fi + seen_arches["$expected_arch"]=1 + sources+=("$source") done + if [ "${seen_arches[amd64]:-0}" -ne 1 ] || [ "${seen_arches[arm64]:-0}" -ne 1 ]; then + echo "ERROR: expected one validated digest for linux/amd64 and linux/arm64." >&2 + exit 1 + fi mapfile -t tags <<< "$TAGS" tag_args=() @@ -531,15 +567,36 @@ jobs: exit 1 fi + declare -A seen_arches=() sources=() for digest_file in "${digest_files[@]}"; do - digest="$(basename "$digest_file")" - if [[ ! "$digest" =~ ^[0-9a-f]{64}$ ]]; then - echo "ERROR: invalid platform digest artifact: $digest" >&2 + digest_artifact="$(basename "$digest_file")" + if [[ ! "$digest_artifact" =~ ^(amd64|arm64)-([0-9a-f]{64})$ ]]; then + echo "ERROR: invalid platform digest artifact: $digest_artifact" >&2 exit 1 fi - sources+=("$IMAGE@sha256:$digest") + expected_arch="${BASH_REMATCH[1]}" + digest="${BASH_REMATCH[2]}" + if [ -n "${seen_arches[$expected_arch]:-}" ]; then + echo "ERROR: duplicate platform digest for linux/$expected_arch." >&2 + exit 1 + fi + source="$IMAGE@sha256:$digest" + source_platform="$( + docker buildx imagetools inspect "$source" \ + --format '{{.Image.OS}}/{{.Image.Architecture}}' + )" + if [ "$source_platform" != "linux/$expected_arch" ]; then + echo "ERROR: digest for linux/$expected_arch resolves to $source_platform." >&2 + exit 1 + fi + seen_arches["$expected_arch"]=1 + sources+=("$source") done + if [ "${seen_arches[amd64]:-0}" -ne 1 ] || [ "${seen_arches[arm64]:-0}" -ne 1 ]; then + echo "ERROR: expected one validated digest for linux/amd64 and linux/arm64." >&2 + exit 1 + fi mapfile -t tags <<< "$TAGS" tag_args=() @@ -617,15 +674,36 @@ jobs: exit 1 fi + declare -A seen_arches=() sources=() for digest_file in "${digest_files[@]}"; do - digest="$(basename "$digest_file")" - if [[ ! "$digest" =~ ^[0-9a-f]{64}$ ]]; then - echo "ERROR: invalid platform digest artifact: $digest" >&2 + digest_artifact="$(basename "$digest_file")" + if [[ ! "$digest_artifact" =~ ^(amd64|arm64)-([0-9a-f]{64})$ ]]; then + echo "ERROR: invalid platform digest artifact: $digest_artifact" >&2 exit 1 fi - sources+=("$IMAGE@sha256:$digest") + expected_arch="${BASH_REMATCH[1]}" + digest="${BASH_REMATCH[2]}" + if [ -n "${seen_arches[$expected_arch]:-}" ]; then + echo "ERROR: duplicate platform digest for linux/$expected_arch." >&2 + exit 1 + fi + source="$IMAGE@sha256:$digest" + source_platform="$( + docker buildx imagetools inspect "$source" \ + --format '{{.Image.OS}}/{{.Image.Architecture}}' + )" + if [ "$source_platform" != "linux/$expected_arch" ]; then + echo "ERROR: digest for linux/$expected_arch resolves to $source_platform." >&2 + exit 1 + fi + seen_arches["$expected_arch"]=1 + sources+=("$source") done + if [ "${seen_arches[amd64]:-0}" -ne 1 ] || [ "${seen_arches[arm64]:-0}" -ne 1 ]; then + echo "ERROR: expected one validated digest for linux/amd64 and linux/arm64." >&2 + exit 1 + fi mapfile -t tags <<< "$TAGS" tag_args=() diff --git a/test/dcode-base-image-workflow.test.ts b/test/dcode-base-image-workflow.test.ts index 890daaca832..3418e53e510 100644 --- a/test/dcode-base-image-workflow.test.ts +++ b/test/dcode-base-image-workflow.test.ts @@ -418,7 +418,9 @@ describe("base-image publication behavior", () => { expect(renderMatrixValue(build.with?.["cache-to"], matrix)).toContain( `${cacheSuffix},mode=max`, ); + expect(digestExport?.env?.ARCH).toBe("${{ matrix.arch }}"); expect(digestExport?.run).toContain("^sha256:[0-9a-f]{64}$"); + expect(digestExport?.run).toContain('touch "$RUNNER_TEMP/digests/${ARCH}-${DIGEST#sha256:}"'); expect(digestUpload?.with?.name).toBe("openclaw-base-digest-${{ matrix.arch }}"); for (const step of steps.filter((step) => step.uses)) { expect(step.uses, `${matrix.arch}: ${step.name}`).toMatch(FULL_SHA_ACTION); @@ -444,11 +446,21 @@ describe("base-image publication behavior", () => { expect(metadata?.with?.tags).toContain("type=raw,value=latest"); expect(metadata?.with?.tags).toContain("type=ref,event=tag"); expect(metadata?.with?.tags).toContain("type=sha,prefix=,format=short"); - expect(createManifest?.run).toContain('"${#digest_files[@]}" -ne 2'); - expect(createManifest?.run).toContain( + const openClawManifestScript = createManifest?.run ?? ""; + expect(openClawManifestScript).toContain('"${#digest_files[@]}" -ne 2'); + expect(openClawManifestScript).toContain("^(amd64|arm64)-([0-9a-f]{64})$"); + expect(openClawManifestScript).toContain("--format '{{.Image.OS}}/{{.Image.Architecture}}'"); + expect(openClawManifestScript).toContain( + 'if [ "$source_platform" != "linux/$expected_arch" ]; then', + ); + expect(openClawManifestScript).toContain("duplicate platform digest"); + expect(openClawManifestScript.indexOf("source_platform=")).toBeLessThan( + openClawManifestScript.indexOf("docker buildx imagetools create"), + ); + expect(openClawManifestScript).toContain( 'docker buildx imagetools create "${tag_args[@]}" "${sources[@]}"', ); - expect(createManifest?.run).toContain('"amd64,arm64"'); + expect(openClawManifestScript).toContain('"amd64,arm64"'); for (const step of (manifestJob?.steps ?? []).filter((step) => step.uses)) { expect(step.uses, step.name).toMatch(FULL_SHA_ACTION); } @@ -515,11 +527,14 @@ describe("base-image publication behavior", () => { for (const { job, build, matrix } of publishers) { const steps = job.steps ?? []; + const digestExport = steps.find((step) => step.name === "Export platform digest"); const digestUpload = steps.find((step) => step.name === "Upload platform digest"); expect(steps.some((step) => step.uses?.startsWith("docker/setup-qemu-action@"))).toBe(false); expect(build.with?.platforms).toBe("${{ matrix.platform }}"); expect(build.with?.outputs).toBe(PLATFORM_DIGEST_OUTPUT); + expect(digestExport?.env?.ARCH).toBe("${{ matrix.arch }}"); + expect(digestExport?.run).toContain('touch "$RUNNER_TEMP/digests/${ARCH}-${DIGEST#sha256:}"'); expect(digestUpload?.with?.name).toBe("${{ matrix.agent }}-base-digest-${{ matrix.arch }}"); expect(renderMatrixValue(digestUpload?.with?.name, matrix)).toBe( `${matrix.agent}-base-digest-${matrix.arch}`, @@ -550,11 +565,19 @@ describe("base-image publication behavior", () => { expect(metadata?.with?.tags).toContain("type=ref,event=tag"); expect(metadata?.with?.tags).toContain("type=sha,prefix=,format=short"); expect(createManifest?.env?.IMAGE).toBe(imagePublisher.image); - expect(createManifest?.run).toContain('"${#digest_files[@]}" -ne 2'); - expect(createManifest?.run).toContain( + const manifestScript = createManifest?.run ?? ""; + expect(manifestScript).toContain('"${#digest_files[@]}" -ne 2'); + expect(manifestScript).toContain("^(amd64|arm64)-([0-9a-f]{64})$"); + expect(manifestScript).toContain("--format '{{.Image.OS}}/{{.Image.Architecture}}'"); + expect(manifestScript).toContain('if [ "$source_platform" != "linux/$expected_arch" ]; then'); + expect(manifestScript).toContain("duplicate platform digest"); + expect(manifestScript.indexOf("source_platform=")).toBeLessThan( + manifestScript.indexOf("docker buildx imagetools create"), + ); + expect(manifestScript).toContain( 'docker buildx imagetools create "${tag_args[@]}" "${sources[@]}"', ); - expect(createManifest?.run).toContain('"amd64,arm64"'); + expect(manifestScript).toContain('"amd64,arm64"'); for (const step of (manifestJob?.steps ?? []).filter((step) => step.uses)) { expect(step.uses, step.name).toMatch(FULL_SHA_ACTION); } From 44901ae3a226821cd9ea4314aebd34a35a84efd9 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Wed, 29 Jul 2026 17:31:10 -0700 Subject: [PATCH 04/15] fix(ci): validate Perl image remediation Signed-off-by: Senthil Ravichandran --- Dockerfile | 5 ++++ agents/hermes/Dockerfile | 5 ++++ agents/langchain-deepagents-code/Dockerfile | 5 ++++ scripts/check-dcode-profile-import-gate.sh | 7 +++--- .../security/build-perl-security-packages.sh | 3 ++- test/helpers/base-apt-security-functions.ts | 19 +++++++++++++- test/hermes-share-mount-deps.test.ts | 3 ++- ...deepagents-code-profile-build-gate.test.ts | 5 +++- test/perl-critical-cve-remediation.test.ts | 2 ++ test/sandbox-base-security-packages.test.ts | 25 +++++++++++++------ 10 files changed, 65 insertions(+), 14 deletions(-) diff --git a/Dockerfile b/Dockerfile index 2a52642546b..d78fbe53529 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1740,6 +1740,8 @@ RUN set -eu; \ "vim-tiny=2:9.2.0782-1" \ "libssh2-1t64=1.11.1-1+deb13u1+nemoclaw1" \ "nemoclaw-python3.13-htmlparser-fix=3.13.5-2+deb13u4+nemoclaw1" \ + "perl-base=5.44.0-1nemoclaw1" \ + "perl=5.44.0-1nemoclaw1" \ | cmp -s - "$security_inventory"; \ test "$(dpkg-query -W -f='${Version}' libexpat1)" = "2.8.2-1"; \ test "$(dpkg-query -W -f='${Version}' libonig5)" = "6.9.9-1+b1"; \ @@ -1749,6 +1751,9 @@ RUN set -eu; \ test "$(dpkg-query -W -f='${Version}' vim-tiny)" = "2:9.2.0782-1"; \ test "$(dpkg-query -W -f='${Version}' libssh2-1t64)" = "1.11.1-1+deb13u1+nemoclaw1"; \ test "$(dpkg-query -W -f='${Version}' nemoclaw-python3.13-htmlparser-fix)" = "3.13.5-2+deb13u4+nemoclaw1"; \ + test "$(dpkg-query -W -f='${Version}' perl-base)" = "5.44.0-1nemoclaw1"; \ + test "$(dpkg-query -W -f='${Version}' perl)" = "5.44.0-1nemoclaw1"; \ + test "$(perl -e 'print $^V')" = "v5.44.0"; \ ldd /usr/bin/jq | grep -Eq 'libonig[.]so[.]5'; \ test "$(jq --version)" = "jq-1.8.2"; \ printf '%s\n' '{"sandbox":"healthy"}' | jq -e '.sandbox == "healthy"' >/dev/null; \ diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 843545fff2f..2f5865aa71d 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -1188,6 +1188,8 @@ RUN set -eu; \ "vim-tiny=2:9.2.0782-1" \ "libssh2-1t64=1.11.1-1+deb13u1+nemoclaw1" \ "nemoclaw-python3.13-htmlparser-fix=3.13.5-2+deb13u4+nemoclaw1" \ + "perl-base=5.44.0-1nemoclaw1" \ + "perl=5.44.0-1nemoclaw1" \ | cmp -s - "$security_inventory"; \ test "$(dpkg-query -W -f='${Version}' libexpat1)" = "2.8.2-1"; \ test "$(dpkg-query -W -f='${Version}' libonig5)" = "6.9.9-1+b1"; \ @@ -1197,6 +1199,9 @@ RUN set -eu; \ test "$(dpkg-query -W -f='${Version}' vim-tiny)" = "2:9.2.0782-1"; \ test "$(dpkg-query -W -f='${Version}' libssh2-1t64)" = "1.11.1-1+deb13u1+nemoclaw1"; \ test "$(dpkg-query -W -f='${Version}' nemoclaw-python3.13-htmlparser-fix)" = "3.13.5-2+deb13u4+nemoclaw1"; \ + test "$(dpkg-query -W -f='${Version}' perl-base)" = "5.44.0-1nemoclaw1"; \ + test "$(dpkg-query -W -f='${Version}' perl)" = "5.44.0-1nemoclaw1"; \ + test "$(perl -e 'print $^V')" = "v5.44.0"; \ ldd /usr/bin/jq | grep -Eq 'libonig[.]so[.]5'; \ test "$(jq --version)" = "jq-1.8.2"; \ printf '%s\n' '{"sandbox":"healthy"}' | jq -e '.sandbox == "healthy"' >/dev/null; \ diff --git a/agents/langchain-deepagents-code/Dockerfile b/agents/langchain-deepagents-code/Dockerfile index 73147ad3c92..d0041f29ad9 100644 --- a/agents/langchain-deepagents-code/Dockerfile +++ b/agents/langchain-deepagents-code/Dockerfile @@ -239,6 +239,8 @@ RUN set -eu; \ "vim-tiny=2:9.2.0782-1" \ "libssh2-1t64=1.11.1-1+deb13u1+nemoclaw1" \ "nemoclaw-python3.13-htmlparser-fix=3.13.5-2+deb13u4+nemoclaw1" \ + "perl-base=5.44.0-1nemoclaw1" \ + "perl=5.44.0-1nemoclaw1" \ | cmp -s - "$security_inventory"; \ test "$(dpkg-query -W -f='${Version}' libexpat1)" = "2.8.2-1"; \ test "$(dpkg-query -W -f='${Version}' libonig5)" = "6.9.9-1+b1"; \ @@ -248,6 +250,9 @@ RUN set -eu; \ test "$(dpkg-query -W -f='${Version}' vim-tiny)" = "2:9.2.0782-1"; \ test "$(dpkg-query -W -f='${Version}' libssh2-1t64)" = "1.11.1-1+deb13u1+nemoclaw1"; \ test "$(dpkg-query -W -f='${Version}' nemoclaw-python3.13-htmlparser-fix)" = "3.13.5-2+deb13u4+nemoclaw1"; \ + test "$(dpkg-query -W -f='${Version}' perl-base)" = "5.44.0-1nemoclaw1"; \ + test "$(dpkg-query -W -f='${Version}' perl)" = "5.44.0-1nemoclaw1"; \ + test "$(perl -e 'print $^V')" = "v5.44.0"; \ ldd /usr/bin/jq | grep -Eq 'libonig[.]so[.]5'; \ test "$(jq --version)" = "jq-1.8.2"; \ printf '%s\n' '{"sandbox":"healthy"}' | jq -e '.sandbox == "healthy"' >/dev/null; \ diff --git a/scripts/check-dcode-profile-import-gate.sh b/scripts/check-dcode-profile-import-gate.sh index b65b31b8be9..dced3db25de 100755 --- a/scripts/check-dcode-profile-import-gate.sh +++ b/scripts/check-dcode-profile-import-gate.sh @@ -30,15 +30,16 @@ cd "${repo_root}" # test/Dockerfile.dcode-profile-missing-dependencies, and # agents/langchain-deepagents-code/Dockerfile. Those three reviewed Dockerfiles # contain no secret-bearing ARGs. NEMOCLAW_CORPORATE_CA_B64 carries public trust -# anchors and is not passed to this gate. Only BASE_IMAGE is passed via -# --build-arg, always as a public, non-secret image reference. +# anchors, and the PERL_* arguments pin public source and package identities. +# None are passed to this gate. Only BASE_IMAGE is passed via --build-arg, +# always as a public, non-secret image reference. for dockerfile in \ agents/langchain-deepagents-code/Dockerfile.base \ test/Dockerfile.dcode-profile-missing-dependencies \ agents/langchain-deepagents-code/Dockerfile; do while IFS= read -r arg_name; do case "${arg_name}" in - BASE_IMAGE | NEMOCLAW_CORPORATE_CA_B64 | NEMOCLAW_MODEL | NEMOCLAW_INFERENCE_PROVIDER_ID | NEMOCLAW_PROVIDER_KEY | NEMOCLAW_UPSTREAM_PROVIDER | NEMOCLAW_UPSTREAM_ENDPOINT_URL | NEMOCLAW_INFERENCE_BASE_URL | NEMOCLAW_INFERENCE_API | NEMOCLAW_TOOL_DISCLOSURE | NEMOCLAW_DCODE_AUTO_APPROVAL | NEMOCLAW_BUILD_ID | NEMOCLAW_DARWIN_VM_COMPAT | NEMOCLAW_PROXY_HOST | NEMOCLAW_PROXY_PORT) ;; + BASE_IMAGE | NEMOCLAW_CORPORATE_CA_B64 | NEMOCLAW_MODEL | NEMOCLAW_INFERENCE_PROVIDER_ID | NEMOCLAW_PROVIDER_KEY | NEMOCLAW_UPSTREAM_PROVIDER | NEMOCLAW_UPSTREAM_ENDPOINT_URL | NEMOCLAW_INFERENCE_BASE_URL | NEMOCLAW_INFERENCE_API | NEMOCLAW_TOOL_DISCLOSURE | NEMOCLAW_DCODE_AUTO_APPROVAL | NEMOCLAW_BUILD_ID | NEMOCLAW_DARWIN_VM_COMPAT | NEMOCLAW_PROXY_HOST | NEMOCLAW_PROXY_PORT | PERL_VERSION | PERL_SHA256 | PERL_PACKAGE_REVISION) ;; *) echo "ERROR: plain-progress build refuses unreviewed ARG ${arg_name} in ${dockerfile}" >&2 exit 1 diff --git a/scripts/security/build-perl-security-packages.sh b/scripts/security/build-perl-security-packages.sh index ae479b92fee..fbc4b4ea9cb 100755 --- a/scripts/security/build-perl-security-packages.sh +++ b/scripts/security/build-perl-security-packages.sh @@ -77,9 +77,10 @@ tar -xJf "${source_archive}" -C "${source_dir}" --strip-components=1 cmp \ "${build_root}/perl-tests-full.sorted" \ "${build_root}/perl-tests-combined.sorted" + # harness -dumptests reports paths from the source root and removes ../. test "$( grep -Fxc \ - '../cpan/ExtUtils-Constant/t/Constant.t' \ + 'cpan/ExtUtils-Constant/t/Constant.t' \ "${build_root}/perl-tests-combined.sorted" )" -eq 1 # Perl's test_harness runs the same upstream suite while TEST_JOBS lets its TAP diff --git a/test/helpers/base-apt-security-functions.ts b/test/helpers/base-apt-security-functions.ts index 31c64485a77..cfede8ee924 100644 --- a/test/helpers/base-apt-security-functions.ts +++ b/test/helpers/base-apt-security-functions.ts @@ -100,7 +100,8 @@ export function baseAptSecurityFunctions(architecture: DebianArchitecture): stri ' libexpat1) printf "2.8.2-1" ;;', ' libonig5) printf "6.9.9-1+b1" ;;', ' libjq1|jq) printf "1.8.2-1" ;;', - ' perl) printf "5.40.1-6" ;;', + ' perl-base) [[ "${perl_packages_installed:-0}" == "1" ]] || return 64; printf "5.44.0-1nemoclaw1" ;;', + ' perl) if [[ "${perl_packages_installed:-0}" == "1" ]]; then printf "5.44.0-1nemoclaw1"; else printf "5.40.1-6"; fi ;;', ' vim-common|vim-tiny) printf "2:9.2.0782-1" ;;', ' libssh2-1t64) printf "1.11.1-1+deb13u1+nemoclaw1" ;;', ' nemoclaw-python3.13-htmlparser-fix) printf "3.13.5-2+deb13u4+nemoclaw1" ;;', @@ -184,6 +185,22 @@ export function baseAptSecurityFunctions(architecture: DebianArchitecture): stri ' printf "VIM - Vi IMproved 9.2 (2024 Jan 2)\\n"', "}", ].join("\n"), + [ + "perl() {", + ' [[ "${perl_packages_installed:-0}" == "1" ]] || return 64', + ' case "$*" in', + ' "-e print \\$^V") printf "v5.44.0" ;;', + ' "-MSocket -e print Socket-"*) printf "2.041" ;;', + ' "-MStorable -e print Storable-"*) printf "3.41" ;;', + ' "-MHTTP::Tiny -e print HTTP::Tiny-"*) printf "0.096" ;;', + ' "-MIO::Compress::Base -e print IO::Compress::Base-"*) printf "2.223" ;;', + ' "-MIO::Uncompress::Unzip -e print IO::Uncompress::Unzip-"*) printf "2.223" ;;', + ' "-MFile::GlobMapper -e print File::GlobMapper-"*) printf "1.001" ;;', + ' "-MSocket=pack_ip_mreq_source -e "*|"-e my \\$x = join "*) ;;', + " *) return 64 ;;", + " esac", + "}", + ].join("\n"), ]; } diff --git a/test/hermes-share-mount-deps.test.ts b/test/hermes-share-mount-deps.test.ts index fea8d0fc96f..1c9f1b96ae2 100644 --- a/test/hermes-share-mount-deps.test.ts +++ b/test/hermes-share-mount-deps.test.ts @@ -78,7 +78,8 @@ function runLoggedShell(command: string, tmp: string, functionDefs: string[] = [ "#!/usr/bin/env bash", "set -euo pipefail", `call_log=${JSON.stringify(logPath)}`, - 'apt-get() { printf "apt-get %s\\n" "$*" >> "$call_log"; }', + "perl_packages_installed=0", + 'apt-get() { printf "apt-get %s\\n" "$*" >> "$call_log"; [[ "$*" != *"/perl-base.deb"* ]] || perl_packages_installed=1; }', ...functionDefs, command, ].join("\n"); diff --git a/test/langchain-deepagents-code-profile-build-gate.test.ts b/test/langchain-deepagents-code-profile-build-gate.test.ts index d890231c63c..8246b9eea91 100644 --- a/test/langchain-deepagents-code-profile-build-gate.test.ts +++ b/test/langchain-deepagents-code-profile-build-gate.test.ts @@ -108,7 +108,10 @@ describe("LangChain Deep Agents Code profile build gate", () => { it.each([ "NEMOCLAW_CORPORATE_CA_B64", "NEMOCLAW_UPSTREAM_ENDPOINT_URL", - ])("accepts %s as a reviewed source-gate ARG (#6901)", (reviewedArg) => { + "PERL_VERSION", + "PERL_SHA256", + "PERL_PACKAGE_REVISION", + ])("accepts %s as a reviewed public build ARG", (reviewedArg) => { const result = runGateWithFakeDocker("expected-failure-with-marker", (fixtureRoot) => fs.appendFileSync(path.join(fixtureRoot, reviewedDockerfiles[0]), `\nARG ${reviewedArg}\n`), ); diff --git a/test/perl-critical-cve-remediation.test.ts b/test/perl-critical-cve-remediation.test.ts index e2ea56f96aa..09cc6b58a11 100644 --- a/test/perl-critical-cve-remediation.test.ts +++ b/test/perl-critical-cve-remediation.test.ts @@ -155,6 +155,8 @@ describe("managed base-image Perl CVE remediation", () => { ); expect(packageBuilder).toContain("consecutive amd64 and arm64 base-image builds"); expect(packageBuilder).toContain("'../cpan/ExtUtils-Constant/t/Constant.t'"); + expect(packageBuilder).toContain("'cpan/ExtUtils-Constant/t/Constant.t'"); + expect(packageBuilder).toContain("harness -dumptests reports paths from the source root"); expect(packageBuilder).not.toMatch(/\bmake\s+(?:-j[^\n]+\s+)?test(?:\s|\\|$)/m); }); diff --git a/test/sandbox-base-security-packages.test.ts b/test/sandbox-base-security-packages.test.ts index 3c286da244b..50d145aaa20 100644 --- a/test/sandbox-base-security-packages.test.ts +++ b/test/sandbox-base-security-packages.test.ts @@ -82,8 +82,11 @@ function sandboxSecurityCommand( return { command, inventory, debianSecurityDebs, nativeSecurityDebs, pythonShim }; } -function securityInventory(architecture: (typeof ARCHITECTURES)[number]): string { - return [ +function securityInventory( + architecture: (typeof ARCHITECTURES)[number], + includePerl = true, +): string { + const inventory = [ `architecture=${architecture}`, "libexpat1=2.8.2-1", "libonig5=6.9.9-1+b1", @@ -93,8 +96,11 @@ function securityInventory(architecture: (typeof ARCHITECTURES)[number]): string "vim-tiny=2:9.2.0782-1", "libssh2-1t64=1.11.1-1+deb13u1+nemoclaw1", "nemoclaw-python3.13-htmlparser-fix=3.13.5-2+deb13u4+nemoclaw1", - "", - ].join("\n"); + ]; + if (includePerl) { + inventory.push("perl-base=5.44.0-1nemoclaw1", "perl=5.44.0-1nemoclaw1"); + } + return `${inventory.join("\n")}\n`; } function completedImageSecurityCommand( @@ -125,7 +131,8 @@ describe("sandbox base security packages", () => { try { const result = runLoggedDockerShell(prepared.command, tmp, [ - 'apt-get() { printf "apt-get %s\\n" "$*" >> "$call_log"; }', + "perl_packages_installed=0", + 'apt-get() { printf "apt-get %s\\n" "$*" >> "$call_log"; [[ "$*" != *"/perl-base.deb"* ]] || perl_packages_installed=1; }', 'install() { [[ "$#" -eq 8 && "$1" == "-d" && "$2" == "-o" && "$3" == "root" && "$4" == "-g" && "$5" == "root" && "$6" == "-m" && "$7" == "0755" ]] || return 64; mkdir -p "$8"; }', 'chown() { [[ "$#" -eq 2 && "$1" == "root:root" ]] || return 64; }', ...useRealPatchedParser(baseAptSecurityFunctions(architecture), prepared.pythonShim), @@ -133,8 +140,11 @@ describe("sandbox base security packages", () => { expect({ status: result.status, stderr: result.stderr }).toEqual({ status: 0, stderr: "" }); const calls = fs.readFileSync(path.join(tmp, "calls.log"), "utf-8"); expect(calls).toContain("dpkg-install"); - expect(fs.readFileSync(prepared.inventory, "utf-8")).toBe(securityInventory(architecture)); - expect(fs.statSync(prepared.inventory).mode & 0o777).toBe(0o444); + const includesPerl = image.name !== "OpenClaw"; + expect(fs.readFileSync(prepared.inventory, "utf-8")).toBe( + securityInventory(architecture, includesPerl), + ); + expect(fs.statSync(prepared.inventory).mode & 0o777).toBe(includesPerl ? 0o444 : 0o644); expect( calls .split("\n") @@ -164,6 +174,7 @@ describe("sandbox base security packages", () => { try { const result = runLoggedDockerShell(prepared.command, tmp, [ + "perl_packages_installed=1", [ "stat() {", ` [[ "$#" -eq 3 && "$1" == "-c" && "$2" == "%u:%g:%a" && "$3" == ${JSON.stringify(prepared.inventory)} ]] || return 64`, From c6a7b0aa06d82e40f102d3e4aa9cd3a5fb8184b3 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Wed, 29 Jul 2026 17:38:02 -0700 Subject: [PATCH 05/15] test(ci): keep inventory fixture linear Signed-off-by: Senthil Ravichandran --- test/sandbox-base-security-packages.test.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/test/sandbox-base-security-packages.test.ts b/test/sandbox-base-security-packages.test.ts index 50d145aaa20..cd641ad43d5 100644 --- a/test/sandbox-base-security-packages.test.ts +++ b/test/sandbox-base-security-packages.test.ts @@ -86,7 +86,8 @@ function securityInventory( architecture: (typeof ARCHITECTURES)[number], includePerl = true, ): string { - const inventory = [ + const perlPackages = includePerl ? ["perl-base=5.44.0-1nemoclaw1", "perl=5.44.0-1nemoclaw1"] : []; + return `${[ `architecture=${architecture}`, "libexpat1=2.8.2-1", "libonig5=6.9.9-1+b1", @@ -96,11 +97,8 @@ function securityInventory( "vim-tiny=2:9.2.0782-1", "libssh2-1t64=1.11.1-1+deb13u1+nemoclaw1", "nemoclaw-python3.13-htmlparser-fix=3.13.5-2+deb13u4+nemoclaw1", - ]; - if (includePerl) { - inventory.push("perl-base=5.44.0-1nemoclaw1", "perl=5.44.0-1nemoclaw1"); - } - return `${inventory.join("\n")}\n`; + ...perlPackages, + ].join("\n")}\n`; } function completedImageSecurityCommand( From 8c478e72239f23089b2704e8c3566aed1f1bf52a Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Wed, 29 Jul 2026 17:52:21 -0700 Subject: [PATCH 06/15] fix(onboard): refresh Dockerfile attestation digest Signed-off-by: Senthil Ravichandran --- src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts b/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts index f63bef91cbd..a716a0c07ec 100644 --- a/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts +++ b/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts @@ -56,7 +56,7 @@ const CANONICAL_POST_GENERATOR_RUN_SHA256 = new Set([ "83567d1fa0e73bef6a3333383c13ace05e26704964ae6a7a76ee24a2f2be3d7e", "ca1f7b1cb9dd5d467f806792c4072a84ef1e6402c3e8650b6325b95cc186ccdf", "7e6a6879382f833f17be02ca7d287685b6afa1c423b1e087b3b05dd677d6e325", - "22406cef76f7a66a3d527c17f2a5bc6a217c71c753c979406fb2c3fa7cd8f0eb", + "4a54da2c1c33c681ae0dad181a5a7456c926051d91420aa60cf7edef6330ba65", ]); function instructionSha256(text: string): string { From 6cfc74a8c62c1f1bd94f2e864f6cc2c069493626 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Wed, 29 Jul 2026 18:22:26 -0700 Subject: [PATCH 07/15] test(security): model Perl packages independently Signed-off-by: Senthil Ravichandran --- test/helpers/base-apt-security-functions.ts | 6 +-- test/hermes-share-mount-deps.test.ts | 5 ++- test/sandbox-base-security-packages.test.ts | 43 +++++++++++++-------- 3 files changed, 32 insertions(+), 22 deletions(-) diff --git a/test/helpers/base-apt-security-functions.ts b/test/helpers/base-apt-security-functions.ts index cfede8ee924..e19c62c350c 100644 --- a/test/helpers/base-apt-security-functions.ts +++ b/test/helpers/base-apt-security-functions.ts @@ -100,8 +100,8 @@ export function baseAptSecurityFunctions(architecture: DebianArchitecture): stri ' libexpat1) printf "2.8.2-1" ;;', ' libonig5) printf "6.9.9-1+b1" ;;', ' libjq1|jq) printf "1.8.2-1" ;;', - ' perl-base) [[ "${perl_packages_installed:-0}" == "1" ]] || return 64; printf "5.44.0-1nemoclaw1" ;;', - ' perl) if [[ "${perl_packages_installed:-0}" == "1" ]]; then printf "5.44.0-1nemoclaw1"; else printf "5.40.1-6"; fi ;;', + ' perl-base) [[ "${perl_base_installed:-0}" == "1" ]] || return 64; printf "5.44.0-1nemoclaw1" ;;', + ' perl) if [[ "${perl_installed:-0}" == "1" ]]; then printf "5.44.0-1nemoclaw1"; else printf "5.40.1-6"; fi ;;', ' vim-common|vim-tiny) printf "2:9.2.0782-1" ;;', ' libssh2-1t64) printf "1.11.1-1+deb13u1+nemoclaw1" ;;', ' nemoclaw-python3.13-htmlparser-fix) printf "3.13.5-2+deb13u4+nemoclaw1" ;;', @@ -187,7 +187,7 @@ export function baseAptSecurityFunctions(architecture: DebianArchitecture): stri ].join("\n"), [ "perl() {", - ' [[ "${perl_packages_installed:-0}" == "1" ]] || return 64', + ' [[ "${perl_base_installed:-0}" == "1" ]] || return 64', ' case "$*" in', ' "-e print \\$^V") printf "v5.44.0" ;;', ' "-MSocket -e print Socket-"*) printf "2.041" ;;', diff --git a/test/hermes-share-mount-deps.test.ts b/test/hermes-share-mount-deps.test.ts index 1c9f1b96ae2..d7e48062104 100644 --- a/test/hermes-share-mount-deps.test.ts +++ b/test/hermes-share-mount-deps.test.ts @@ -78,8 +78,9 @@ function runLoggedShell(command: string, tmp: string, functionDefs: string[] = [ "#!/usr/bin/env bash", "set -euo pipefail", `call_log=${JSON.stringify(logPath)}`, - "perl_packages_installed=0", - 'apt-get() { printf "apt-get %s\\n" "$*" >> "$call_log"; [[ "$*" != *"/perl-base.deb"* ]] || perl_packages_installed=1; }', + "perl_base_installed=0", + "perl_installed=0", + 'apt-get() { printf "apt-get %s\\n" "$*" >> "$call_log"; [[ "$*" != *"/perl-base.deb"* ]] || perl_base_installed=1; [[ "$*" != *"/perl.deb"* ]] || perl_installed=1; }', ...functionDefs, command, ].join("\n"); diff --git a/test/sandbox-base-security-packages.test.ts b/test/sandbox-base-security-packages.test.ts index cd641ad43d5..bd1f8c0f8fc 100644 --- a/test/sandbox-base-security-packages.test.ts +++ b/test/sandbox-base-security-packages.test.ts @@ -20,6 +20,8 @@ const SECURITY_IMAGES = [ dockerfile: path.join(ROOT, "Dockerfile.base"), finalDockerfile: path.join(ROOT, "Dockerfile"), startMarker: "# Trixie has not published fixes", + additionalStartMarker: + "RUN apt-get update \\\n && apt-get install -y --no-install-recommends \\\n /tmp/nemoclaw-native-security/perl-base.deb", endMarker: "# gosu for privilege separation", }, { @@ -27,6 +29,7 @@ const SECURITY_IMAGES = [ dockerfile: path.join(ROOT, "agents", "hermes", "Dockerfile.base"), finalDockerfile: path.join(ROOT, "agents", "hermes", "Dockerfile"), startMarker: "# Install the reviewed libexpat, jq, and Vim packages", + additionalStartMarker: null, endMarker: "COPY scripts/lib/reviewed-npm-archive.mts", }, { @@ -34,6 +37,7 @@ const SECURITY_IMAGES = [ dockerfile: path.join(ROOT, "agents", "langchain-deepagents-code", "Dockerfile.base"), finalDockerfile: path.join(ROOT, "agents", "langchain-deepagents-code", "Dockerfile"), startMarker: "# Install the reviewed libexpat, jq, and Vim packages", + additionalStartMarker: null, endMarker: "# Node remains available", }, ] as const; @@ -45,6 +49,7 @@ const SECURITY_CASES = SECURITY_IMAGES.flatMap((image) => function sandboxSecurityCommand( image: (typeof SECURITY_IMAGES)[number], tmp: string, + includeAdditionalLayer = true, ): { command: string; inventory: string; @@ -70,7 +75,15 @@ function sandboxSecurityCommand( ); const dockerfile = fs.readFileSync(image.dockerfile, "utf-8"); - const command = dockerRunCommandBetween(dockerfile, image.startMarker, image.endMarker) + const commands = [ + dockerRunCommandBetween(dockerfile, image.startMarker, image.endMarker), + image.additionalStartMarker === null || !includeAdditionalLayer + ? "" + : dockerRunCommandBetween(dockerfile, image.additionalStartMarker, image.endMarker), + ]; + const command = commands + .filter(Boolean) + .join("\n") .replaceAll("/var/lib/apt/lists", lists) .replaceAll("/tmp/nemoclaw-debian-security", debianSecurityDebs) .replaceAll("/tmp/nemoclaw-native-security", nativeSecurityDebs) @@ -82,11 +95,7 @@ function sandboxSecurityCommand( return { command, inventory, debianSecurityDebs, nativeSecurityDebs, pythonShim }; } -function securityInventory( - architecture: (typeof ARCHITECTURES)[number], - includePerl = true, -): string { - const perlPackages = includePerl ? ["perl-base=5.44.0-1nemoclaw1", "perl=5.44.0-1nemoclaw1"] : []; +function securityInventory(architecture: (typeof ARCHITECTURES)[number]): string { return `${[ `architecture=${architecture}`, "libexpat1=2.8.2-1", @@ -97,7 +106,8 @@ function securityInventory( "vim-tiny=2:9.2.0782-1", "libssh2-1t64=1.11.1-1+deb13u1+nemoclaw1", "nemoclaw-python3.13-htmlparser-fix=3.13.5-2+deb13u4+nemoclaw1", - ...perlPackages, + "perl-base=5.44.0-1nemoclaw1", + "perl=5.44.0-1nemoclaw1", ].join("\n")}\n`; } @@ -129,8 +139,9 @@ describe("sandbox base security packages", () => { try { const result = runLoggedDockerShell(prepared.command, tmp, [ - "perl_packages_installed=0", - 'apt-get() { printf "apt-get %s\\n" "$*" >> "$call_log"; [[ "$*" != *"/perl-base.deb"* ]] || perl_packages_installed=1; }', + "perl_base_installed=0", + "perl_installed=0", + 'apt-get() { printf "apt-get %s\\n" "$*" >> "$call_log"; [[ "$*" != *"/perl-base.deb"* ]] || perl_base_installed=1; [[ "$*" != *"/perl.deb"* ]] || perl_installed=1; }', 'install() { [[ "$#" -eq 8 && "$1" == "-d" && "$2" == "-o" && "$3" == "root" && "$4" == "-g" && "$5" == "root" && "$6" == "-m" && "$7" == "0755" ]] || return 64; mkdir -p "$8"; }', 'chown() { [[ "$#" -eq 2 && "$1" == "root:root" ]] || return 64; }', ...useRealPatchedParser(baseAptSecurityFunctions(architecture), prepared.pythonShim), @@ -138,11 +149,8 @@ describe("sandbox base security packages", () => { expect({ status: result.status, stderr: result.stderr }).toEqual({ status: 0, stderr: "" }); const calls = fs.readFileSync(path.join(tmp, "calls.log"), "utf-8"); expect(calls).toContain("dpkg-install"); - const includesPerl = image.name !== "OpenClaw"; - expect(fs.readFileSync(prepared.inventory, "utf-8")).toBe( - securityInventory(architecture, includesPerl), - ); - expect(fs.statSync(prepared.inventory).mode & 0o777).toBe(includesPerl ? 0o444 : 0o644); + expect(fs.readFileSync(prepared.inventory, "utf-8")).toBe(securityInventory(architecture)); + expect(fs.statSync(prepared.inventory).mode & 0o777).toBe(0o444); expect( calls .split("\n") @@ -158,7 +166,7 @@ describe("sandbox base security packages", () => { ]); expect(prepared.debianSecurityDebs).not.toBe(prepared.nativeSecurityDebs); expect(fs.existsSync(prepared.debianSecurityDebs)).toBe(false); - expect(fs.existsSync(prepared.nativeSecurityDebs)).toBe(image.name === "OpenClaw"); + expect(fs.existsSync(prepared.nativeSecurityDebs)).toBe(false); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } @@ -172,7 +180,8 @@ describe("sandbox base security packages", () => { try { const result = runLoggedDockerShell(prepared.command, tmp, [ - "perl_packages_installed=1", + "perl_base_installed=1", + "perl_installed=1", [ "stat() {", ` [[ "$#" -eq 3 && "$1" == "-c" && "$2" == "%u:%g:%a" && "$3" == ${JSON.stringify(prepared.inventory)} ]] || return 64`, @@ -191,7 +200,7 @@ describe("sandbox base security packages", () => { SECURITY_CASES, )("rejects a changed expected checksum before installing packages for %s on %s", (_name, architecture, image) => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-base-checksum-")); - const prepared = sandboxSecurityCommand(image, tmp); + const prepared = sandboxSecurityCommand(image, tmp, false); const command = prepared.command.replace( BASE_APT_SECURITY_HASHES[architecture].libexpat, "0".repeat(64), From f8e22d585b4f8768a5a4eba2699cbda450dc060a Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Wed, 29 Jul 2026 19:10:14 -0700 Subject: [PATCH 08/15] fix(security): resolve exact base candidates Signed-off-by: Senthil Ravichandran --- agents/hermes/Dockerfile | 2 +- .../managed-base-perl-5.44-dependency-review.md | 2 ++ src/lib/sandbox-base-image-resolution.test.ts | 15 +++++++++++++++ src/lib/sandbox-base-image.ts | 12 ++++++------ 4 files changed, 24 insertions(+), 7 deletions(-) diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 2f5865aa71d..65b8f522ffd 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -6,7 +6,7 @@ # Layers PR-specific code (plugin, config, startup script) on top of the # pre-built Hermes base image. Mirrors the OpenClaw Dockerfile structure. -ARG BASE_IMAGE=ghcr.io/nvidia/nemoclaw/hermes-sandbox-base@sha256:61feb0e33fae77ad2fcd1ae3aca2d6c484d7ef2c6ed8ebd431d3e514eab6cf2b +ARG BASE_IMAGE=ghcr.io/nvidia/nemoclaw/hermes-sandbox-base@sha256:c4899e00817acb3421037efba013c720a79895bc8c4448b47aa9aca0def3104b ARG NEMOCLAW_CORPORATE_CA_B64= FROM node:22-trixie-slim@sha256:e6d9a389d34ff9678438af985c9913fbd1eb6ed36e80fea56644f4b4f6dd70ba AS mcp-tool-discovery-runtime diff --git a/docs/security/managed-base-perl-5.44-dependency-review.md b/docs/security/managed-base-perl-5.44-dependency-review.md index 6c454bfe031..cf5c4402d75 100644 --- a/docs/security/managed-base-perl-5.44-dependency-review.md +++ b/docs/security/managed-base-perl-5.44-dependency-review.md @@ -52,6 +52,8 @@ The base-image publisher now builds these image and platform pairs on native run All six platform builds run independently. Each final publisher creates tags only after both platform digests for its image pass. The publisher then verifies that the manifest contains amd64 and arm64. +Clean committed checkouts resolve an available exact source-SHA image before attempting a local rebuild. +Dirty base-image inputs still require a local build and cannot consume a published candidate. ## Runtime proof diff --git a/src/lib/sandbox-base-image-resolution.test.ts b/src/lib/sandbox-base-image-resolution.test.ts index e05ef8aa1f7..16cff52cf58 100644 --- a/src/lib/sandbox-base-image-resolution.test.ts +++ b/src/lib/sandbox-base-image-resolution.test.ts @@ -637,6 +637,21 @@ describe("sandbox base-image warm resolution", () => { expect(dockerMocks.build).not.toHaveBeenCalled(); }); + it("uses an exact source-SHA image before rebuilding committed branch inputs (#4680)", () => { + sourceMocks.inputsChanged.mockReturnValue(true); + dockerMocks.imageInspect.mockReturnValue({ status: 0 }); + + const resolved = resolveSandboxBaseImage(resolutionOptions()); + + expect(resolved).toMatchObject({ source: "source-sha" }); + expect(dockerMocks.imageInspect).toHaveBeenCalledWith(`${IMAGE_NAME}:12345678`, { + ignoreError: true, + suppressOutput: true, + }); + expect(dockerMocks.pull).not.toHaveBeenCalled(); + expect(dockerMocks.build).not.toHaveBeenCalled(); + }); + it("uses a Dockerfile-pinned remote image before moving published tags (#4680)", () => { dockerMocks.imageInspect.mockImplementation((ref: string) => ({ status: ref === REF ? 0 : 1, diff --git a/src/lib/sandbox-base-image.ts b/src/lib/sandbox-base-image.ts index 3348f6e664c..fd786439179 100644 --- a/src/lib/sandbox-base-image.ts +++ b/src/lib/sandbox-base-image.ts @@ -515,6 +515,12 @@ export function resolveSandboxBaseImage( const versionTagResolution = resolveVersionTags(versionTags); if (versionTagResolution) return versionTagResolution; + for (const tag of getSourceShortShaTags(options.rootDir || ROOT, env)) { + const imageRef = `${options.imageName}:${tag}`; + const resolved = resolvePulledCandidate(options.imageName, imageRef, "source-sha", options); + if (resolved) return finish(resolved); + } + if (baseImageInputsChangedSinceMain(rootDir, env, inputPaths)) return resolveChangedInputs(); if (!preferPinnedRemoteRef && options.pinnedRemoteRef) { @@ -534,12 +540,6 @@ export function resolveSandboxBaseImage( const nearestVersionTagResolution = resolveVersionTags(nearestVersionTags); if (nearestVersionTagResolution) return nearestVersionTagResolution; - for (const tag of getSourceShortShaTags(options.rootDir || ROOT, env)) { - const imageRef = `${options.imageName}:${tag}`; - const resolved = resolvePulledCandidate(options.imageName, imageRef, "source-sha", options); - if (resolved) return finish(resolved); - } - const latestRef = `${options.imageName}:${SANDBOX_BASE_TAG}`; const resolved = resolvePulledCandidate(options.imageName, latestRef, "latest", options); if (resolved) return finish(resolved); From 9f1c53b06bce1c7e5425a1f497ebcf984f66648f Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Wed, 29 Jul 2026 19:15:58 -0700 Subject: [PATCH 09/15] docs(security): clarify base candidate order Signed-off-by: Senthil Ravichandran --- docs/reference/commands.mdx | 4 +++- docs/security/managed-base-perl-5.44-dependency-review.md | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 7468c15e23e..f4d83aee124 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -371,7 +371,9 @@ After a cache miss, source checkouts require a fresh local build before candidat For a clean release checkout or versioned install, NemoClaw first accepts the exact release-version image. If that tag exists locally but fails compatibility validation, NemoClaw refreshes the same tag from the registry once and validates it again. If the release-version image is missing or still incompatible, NemoClaw builds a compatible local base instead of falling back to mutable `:latest`. -For unversioned development checkouts, NemoClaw tries the image tagged with the newest reachable release version from `origin` before source-commit images, and only uses `:latest` when no version tag is discoverable. +For clean unversioned development checkouts, NemoClaw first tries the image tagged with the exact source commit. +If that image is unavailable and committed base-image inputs differ from `main`, NemoClaw requires a compatible local build. +When committed base-image inputs match `main`, NemoClaw tries the image tagged with the newest reachable release version from `origin` and only uses `:latest` when no version tag is discoverable. When a stable tag and a prerelease tag share the same version, NemoClaw prefers the stable tag. If `origin` tag lookup is unavailable, NemoClaw uses the newest reachable local release tag as a fallback. If that nearest release-version image is missing or incompatible, NemoClaw builds a compatible local base instead of falling back to mutable `:latest`. diff --git a/docs/security/managed-base-perl-5.44-dependency-review.md b/docs/security/managed-base-perl-5.44-dependency-review.md index cf5c4402d75..6278deffce1 100644 --- a/docs/security/managed-base-perl-5.44-dependency-review.md +++ b/docs/security/managed-base-perl-5.44-dependency-review.md @@ -9,7 +9,7 @@ Date: 2026-07-29 ## Scope -This review extends the existing Perl 5.44.0 remediation to the Hermes and Deep Agents Code base images. +This review extends the existing Perl 5.44.0 remediation to the Hermes and LangChain Deep Agents Code base images. The OpenClaw base image already installs the reviewed packages. The sibling images used Debian Perl 5.40.1-6 before this change. @@ -86,8 +86,10 @@ The image build also executes the reviewed Socket argument-length rejection and ## Downstream boundaries -The change does not modify agent configuration, credentials, network policy, runtime entrypoints, persistent state, or image selection. +The change does not modify agent configuration, credentials, network policy, runtime entrypoints, or persistent state. It changes the Perl files and dpkg identities inside the three existing managed base images. +It also lets a clean committed checkout resolve its exact published source-SHA candidate before the committed-input divergence check requires a local build. +Dirty base-image inputs still require a local build. Rollback selects an earlier immutable base-image digest. The change does not add a data migration or a compatibility fallback. From 2f8fd448429b4d0ddf885d1ce9db7d0e3801d2a5 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Wed, 29 Jul 2026 19:17:47 -0700 Subject: [PATCH 10/15] docs(security): scope source candidate wording Signed-off-by: Senthil Ravichandran --- docs/security/managed-base-perl-5.44-dependency-review.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/security/managed-base-perl-5.44-dependency-review.md b/docs/security/managed-base-perl-5.44-dependency-review.md index 6278deffce1..7b71ecb4a9a 100644 --- a/docs/security/managed-base-perl-5.44-dependency-review.md +++ b/docs/security/managed-base-perl-5.44-dependency-review.md @@ -52,7 +52,7 @@ The base-image publisher now builds these image and platform pairs on native run All six platform builds run independently. Each final publisher creates tags only after both platform digests for its image pass. The publisher then verifies that the manifest contains amd64 and arm64. -Clean committed checkouts resolve an available exact source-SHA image before attempting a local rebuild. +Clean unversioned development checkouts resolve an available exact source-SHA image before attempting a local rebuild. Dirty base-image inputs still require a local build and cannot consume a published candidate. ## Runtime proof @@ -88,7 +88,7 @@ The image build also executes the reviewed Socket argument-length rejection and The change does not modify agent configuration, credentials, network policy, runtime entrypoints, or persistent state. It changes the Perl files and dpkg identities inside the three existing managed base images. -It also lets a clean committed checkout resolve its exact published source-SHA candidate before the committed-input divergence check requires a local build. +It also lets a clean unversioned development checkout resolve its exact published source-SHA candidate before the committed-input divergence check requires a local build. Dirty base-image inputs still require a local build. Rollback selects an earlier immutable base-image digest. From 14c5ad41ff57e2ff2203f3c5e182256d8e938714 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Wed, 29 Jul 2026 20:14:23 -0700 Subject: [PATCH 11/15] test(security): align source image resolution expectation Signed-off-by: Senthil Ravichandran --- .../sandbox-base-image-release-resolution.test.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/lib/sandbox-base-image-release-resolution.test.ts b/src/lib/sandbox-base-image-release-resolution.test.ts index 41a299dd922..5c707175fd3 100644 --- a/src/lib/sandbox-base-image-release-resolution.test.ts +++ b/src/lib/sandbox-base-image-release-resolution.test.ts @@ -258,7 +258,7 @@ describe("sandbox base-image release resolution", () => { ).toThrow("versioned base image"); }); - it("tries the nearest release-tag base before latest for source checkouts (#6456)", () => { + it("prefers the exact source-SHA base over a nearby release tag for source checkouts (#6456)", () => { sourceMocks.nearestTags.mockReturnValue(["v0.0.78"]); const sourceShaRef = `${IMAGE_NAME}:12345678`; installDockerState({ present: [NEAREST_RELEASE_REF, sourceShaRef] }); @@ -266,14 +266,17 @@ describe("sandbox base-image release resolution", () => { const resolved = resolveSandboxBaseImage(resolutionOptions()); expect(resolved).toMatchObject({ - ref: NEAREST_RELEASE_REF, - source: "version-tag", + ref: sourceShaRef, + source: "source-sha", }); - expect(dockerMocks.imageInspect).toHaveBeenCalledWith(NEAREST_RELEASE_REF, { + expect(dockerMocks.imageInspect).toHaveBeenCalledWith(sourceShaRef, { ignoreError: true, suppressOutput: true, }); - expect(dockerMocks.imageInspect).not.toHaveBeenCalledWith(sourceShaRef, expect.anything()); + expect(dockerMocks.imageInspect).not.toHaveBeenCalledWith( + NEAREST_RELEASE_REF, + expect.anything(), + ); expect(dockerMocks.build).not.toHaveBeenCalled(); }); From 1ba633dacb3c296f66f1597ef83aee0050483655 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Wed, 29 Jul 2026 20:24:50 -0700 Subject: [PATCH 12/15] test(security): tighten image inspection assertion Signed-off-by: Senthil Ravichandran --- src/lib/sandbox-base-image-release-resolution.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/lib/sandbox-base-image-release-resolution.test.ts b/src/lib/sandbox-base-image-release-resolution.test.ts index 5c707175fd3..fe55d78e42b 100644 --- a/src/lib/sandbox-base-image-release-resolution.test.ts +++ b/src/lib/sandbox-base-image-release-resolution.test.ts @@ -273,9 +273,8 @@ describe("sandbox base-image release resolution", () => { ignoreError: true, suppressOutput: true, }); - expect(dockerMocks.imageInspect).not.toHaveBeenCalledWith( + expect(dockerMocks.imageInspect.mock.calls.map(([ref]) => ref)).not.toContain( NEAREST_RELEASE_REF, - expect.anything(), ); expect(dockerMocks.build).not.toHaveBeenCalled(); }); From ee94a496cef99ea97a0cef950b6c520400ce47fe Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Wed, 29 Jul 2026 21:03:31 -0700 Subject: [PATCH 13/15] fix(security): align base image inventory validation Signed-off-by: Senthil Ravichandran --- src/lib/onboard/base-image.test.ts | 2 ++ .../sandbox-base-image/security-inventory.ts | 19 ++++++++++++++++++- test/helpers/onboard-script-mocks.cjs | 2 +- test/sandbox-base-security-packages.test.ts | 15 ++------------- 4 files changed, 23 insertions(+), 15 deletions(-) diff --git a/src/lib/onboard/base-image.test.ts b/src/lib/onboard/base-image.test.ts index 7271f87a4c7..1b77cb7f9f2 100644 --- a/src/lib/onboard/base-image.test.ts +++ b/src/lib/onboard/base-image.test.ts @@ -46,6 +46,8 @@ describe("OpenClaw sandbox base image validation", () => { expect(probe).toContain(`stat -c '%u:%g:%a'`); expect(probe).toContain('"0:0:444"'); expect(probe).toContain(`cmp -s - "$security_inventory"`); + expect(probe).toContain('"perl-base=5.44.0-1nemoclaw1"'); + expect(probe).toContain('"perl=5.44.0-1nemoclaw1"'); expect(options).toEqual({ ignoreError: true, timeout: 20_000 }); }); diff --git a/src/lib/sandbox-base-image/security-inventory.ts b/src/lib/sandbox-base-image/security-inventory.ts index 7063f3f5bf5..212cd9b04a4 100644 --- a/src/lib/sandbox-base-image/security-inventory.ts +++ b/src/lib/sandbox-base-image/security-inventory.ts @@ -5,6 +5,23 @@ import { dockerCapture } from "../adapters/docker"; const SECURITY_INVENTORY_PROBE_OK = "nemoclaw-security-inventory-ok"; +export const SANDBOX_BASE_SECURITY_PACKAGE_INVENTORY = [ + "libexpat1=2.8.2-1", + "libonig5=6.9.9-1+b1", + "libjq1=1.8.2-1", + "jq=1.8.2-1", + "vim-common=2:9.2.0782-1", + "vim-tiny=2:9.2.0782-1", + "libssh2-1t64=1.11.1-1+deb13u1+nemoclaw1", + "nemoclaw-python3.13-htmlparser-fix=3.13.5-2+deb13u4+nemoclaw1", + "perl-base=5.44.0-1nemoclaw1", + "perl=5.44.0-1nemoclaw1", +] as const; + +const SECURITY_PACKAGE_INVENTORY_SHELL_ARGUMENTS = SANDBOX_BASE_SECURITY_PACKAGE_INVENTORY.map( + (packageSpec) => `"${packageSpec}"`, +).join(" "); + /** * Reject a published or cached base that predates the immutable security * package inventory consumed by completed-image verification. @@ -32,7 +49,7 @@ export function sandboxBaseImageHasSecurityInventory(imageRef: string): boolean 'test -f "$security_inventory"', 'test ! -L "$security_inventory"', `test "$(stat -c '%u:%g:%a' "$security_inventory")" = "0:0:444"`, - `printf '%s\\n' "architecture=$arch" "libexpat1=2.8.2-1" "libonig5=6.9.9-1+b1" "libjq1=1.8.2-1" "jq=1.8.2-1" "vim-common=2:9.2.0782-1" "vim-tiny=2:9.2.0782-1" "libssh2-1t64=1.11.1-1+deb13u1+nemoclaw1" "nemoclaw-python3.13-htmlparser-fix=3.13.5-2+deb13u4+nemoclaw1" | cmp -s - "$security_inventory"`, + `printf '%s\\n' "architecture=$arch" ${SECURITY_PACKAGE_INVENTORY_SHELL_ARGUMENTS} | cmp -s - "$security_inventory"`, `printf '%s\\n' "${SECURITY_INVENTORY_PROBE_OK}"`, ].join("; "), ], diff --git a/test/helpers/onboard-script-mocks.cjs b/test/helpers/onboard-script-mocks.cjs index c84db62246a..abaf4cb4186 100644 --- a/test/helpers/onboard-script-mocks.cjs +++ b/test/helpers/onboard-script-mocks.cjs @@ -71,7 +71,7 @@ const OPENCLAW_SECURITY_INVENTORY_PROBE = [ 'test -f "$security_inventory"', 'test ! -L "$security_inventory"', `test "$(stat -c '%u:%g:%a' "$security_inventory")" = "0:0:444"`, - `printf '%s\\n' "architecture=$arch" "libexpat1=2.8.2-1" "libonig5=6.9.9-1+b1" "libjq1=1.8.2-1" "jq=1.8.2-1" "vim-common=2:9.2.0782-1" "vim-tiny=2:9.2.0782-1" "libssh2-1t64=1.11.1-1+deb13u1+nemoclaw1" "nemoclaw-python3.13-htmlparser-fix=3.13.5-2+deb13u4+nemoclaw1" | cmp -s - "$security_inventory"`, + `printf '%s\\n' "architecture=$arch" "libexpat1=2.8.2-1" "libonig5=6.9.9-1+b1" "libjq1=1.8.2-1" "jq=1.8.2-1" "vim-common=2:9.2.0782-1" "vim-tiny=2:9.2.0782-1" "libssh2-1t64=1.11.1-1+deb13u1+nemoclaw1" "nemoclaw-python3.13-htmlparser-fix=3.13.5-2+deb13u4+nemoclaw1" "perl-base=5.44.0-1nemoclaw1" "perl=5.44.0-1nemoclaw1" | cmp -s - "$security_inventory"`, `printf '%s\\n' "nemoclaw-security-inventory-ok"`, ].join("; "); diff --git a/test/sandbox-base-security-packages.test.ts b/test/sandbox-base-security-packages.test.ts index bd1f8c0f8fc..d8e7a10c6a1 100644 --- a/test/sandbox-base-security-packages.test.ts +++ b/test/sandbox-base-security-packages.test.ts @@ -5,6 +5,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { SANDBOX_BASE_SECURITY_PACKAGE_INVENTORY } from "../src/lib/sandbox-base-image/security-inventory"; import { BASE_APT_SECURITY_HASHES, baseAptSecurityFunctions, @@ -96,19 +97,7 @@ function sandboxSecurityCommand( } function securityInventory(architecture: (typeof ARCHITECTURES)[number]): string { - return `${[ - `architecture=${architecture}`, - "libexpat1=2.8.2-1", - "libonig5=6.9.9-1+b1", - "libjq1=1.8.2-1", - "jq=1.8.2-1", - "vim-common=2:9.2.0782-1", - "vim-tiny=2:9.2.0782-1", - "libssh2-1t64=1.11.1-1+deb13u1+nemoclaw1", - "nemoclaw-python3.13-htmlparser-fix=3.13.5-2+deb13u4+nemoclaw1", - "perl-base=5.44.0-1nemoclaw1", - "perl=5.44.0-1nemoclaw1", - ].join("\n")}\n`; + return `${[`architecture=${architecture}`, ...SANDBOX_BASE_SECURITY_PACKAGE_INVENTORY].join("\n")}\n`; } function completedImageSecurityCommand( From 02f2f065f4eced399684f56992e236da26096ad0 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Wed, 29 Jul 2026 21:12:12 -0700 Subject: [PATCH 14/15] test(security): keep inventory fixture independent Signed-off-by: Senthil Ravichandran --- test/sandbox-base-security-packages.test.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/test/sandbox-base-security-packages.test.ts b/test/sandbox-base-security-packages.test.ts index d8e7a10c6a1..2a735d65529 100644 --- a/test/sandbox-base-security-packages.test.ts +++ b/test/sandbox-base-security-packages.test.ts @@ -43,6 +43,18 @@ const SECURITY_IMAGES = [ }, ] as const; const ARCHITECTURES = ["amd64", "arm64"] as const; +const EXPECTED_SECURITY_PACKAGE_INVENTORY = [ + "libexpat1=2.8.2-1", + "libonig5=6.9.9-1+b1", + "libjq1=1.8.2-1", + "jq=1.8.2-1", + "vim-common=2:9.2.0782-1", + "vim-tiny=2:9.2.0782-1", + "libssh2-1t64=1.11.1-1+deb13u1+nemoclaw1", + "nemoclaw-python3.13-htmlparser-fix=3.13.5-2+deb13u4+nemoclaw1", + "perl-base=5.44.0-1nemoclaw1", + "perl=5.44.0-1nemoclaw1", +] as const; const SECURITY_CASES = SECURITY_IMAGES.flatMap((image) => ARCHITECTURES.map((architecture) => [image.name, architecture, image] as const), ); @@ -97,7 +109,7 @@ function sandboxSecurityCommand( } function securityInventory(architecture: (typeof ARCHITECTURES)[number]): string { - return `${[`architecture=${architecture}`, ...SANDBOX_BASE_SECURITY_PACKAGE_INVENTORY].join("\n")}\n`; + return `${[`architecture=${architecture}`, ...EXPECTED_SECURITY_PACKAGE_INVENTORY].join("\n")}\n`; } function completedImageSecurityCommand( @@ -120,6 +132,10 @@ function completedImageSecurityCommand( } describe("sandbox base security packages", () => { + it("keeps runtime validation aligned with the independent image inventory", () => { + expect(SANDBOX_BASE_SECURITY_PACKAGE_INVENTORY).toEqual(EXPECTED_SECURITY_PACKAGE_INVENTORY); + }); + it.each( SECURITY_CASES, )("executes the exact security package contract for %s on %s", (_name, architecture, image) => { From 515b5d57f2796e3752c960277f2889f2354934c5 Mon Sep 17 00:00:00 2001 From: Senthil Ravichandran Date: Wed, 29 Jul 2026 21:23:34 -0700 Subject: [PATCH 15/15] fix(security): track Perl builder as image input Signed-off-by: Senthil Ravichandran --- src/lib/sandbox-base-image/source-identity.test.ts | 12 ++++++++++++ src/lib/sandbox-base-image/source-identity.ts | 1 + 2 files changed, 13 insertions(+) diff --git a/src/lib/sandbox-base-image/source-identity.test.ts b/src/lib/sandbox-base-image/source-identity.test.ts index 20ac03b6fa0..0362482de90 100644 --- a/src/lib/sandbox-base-image/source-identity.test.ts +++ b/src/lib/sandbox-base-image/source-identity.test.ts @@ -176,6 +176,7 @@ describe("sandbox base-image source identity", () => { "scripts/lib/sandbox-rlimits.sh", "agents/openclaw/mcporter-runtime/package.json", "agents/openclaw/mcporter-runtime/package-lock.json", + "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", @@ -365,6 +366,17 @@ describe("sandbox base-image source identity", () => { expect(baseImageInputsChangedSinceMain(root, gitEnv)).toBe(true); }); + it("detects committed Perl package builder changes relative to origin/main", () => { + const root = createGitFixture(); + git(root, ["switch", "-c", "feature"]); + writeFixture(root, "scripts/security/build-perl-security-packages.sh", "#!/bin/sh\nexit 0\n"); + git(root, ["add", "scripts/security/build-perl-security-packages.sh"]); + git(root, ["commit", "-m", "change Perl package builder"]); + + expect(baseImageInputsDirty(root, gitEnv)).toBe(false); + expect(baseImageInputsChangedSinceMain(root, gitEnv)).toBe(true); + }); + it("fetches the base ref before deciding detached dispatch checkouts can use latest", () => { const root = createGitFixtureWithRemoteOnlyBaseRef(); git(root, ["switch", "-c", "feature"]); diff --git a/src/lib/sandbox-base-image/source-identity.ts b/src/lib/sandbox-base-image/source-identity.ts index 4336981ecc5..a5fb341fa8f 100644 --- a/src/lib/sandbox-base-image/source-identity.ts +++ b/src/lib/sandbox-base-image/source-identity.ts @@ -13,6 +13,7 @@ export const BASE_IMAGE_INPUT_PATHS = [ "scripts/lib/sandbox-rlimits.sh", "agents/openclaw/mcporter-runtime/package.json", "agents/openclaw/mcporter-runtime/package-lock.json", + "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",